2 * Copyright (c) 2010-2022 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.nanoleaf.internal.handler;
15 import static org.openhab.binding.nanoleaf.internal.NanoleafBindingConstants.*;
17 import java.io.IOException;
19 import java.nio.charset.StandardCharsets;
20 import java.util.Collection;
21 import java.util.List;
23 import java.util.Objects;
24 import java.util.Scanner;
25 import java.util.concurrent.CopyOnWriteArrayList;
26 import java.util.concurrent.ExecutionException;
27 import java.util.concurrent.ScheduledFuture;
28 import java.util.concurrent.TimeUnit;
29 import java.util.concurrent.TimeoutException;
31 import org.eclipse.jdt.annotation.NonNullByDefault;
32 import org.eclipse.jdt.annotation.Nullable;
33 import org.eclipse.jetty.client.HttpClient;
34 import org.eclipse.jetty.client.api.ContentResponse;
35 import org.eclipse.jetty.client.api.Request;
36 import org.eclipse.jetty.client.util.StringContentProvider;
37 import org.eclipse.jetty.http.HttpMethod;
38 import org.eclipse.jetty.http.HttpStatus;
39 import org.openhab.binding.nanoleaf.internal.NanoleafBindingConstants;
40 import org.openhab.binding.nanoleaf.internal.NanoleafControllerListener;
41 import org.openhab.binding.nanoleaf.internal.NanoleafException;
42 import org.openhab.binding.nanoleaf.internal.NanoleafUnauthorizedException;
43 import org.openhab.binding.nanoleaf.internal.OpenAPIUtils;
44 import org.openhab.binding.nanoleaf.internal.commanddescription.NanoleafCommandDescriptionProvider;
45 import org.openhab.binding.nanoleaf.internal.config.NanoleafControllerConfig;
46 import org.openhab.binding.nanoleaf.internal.discovery.NanoleafPanelsDiscoveryService;
47 import org.openhab.binding.nanoleaf.internal.layout.LayoutSettings;
48 import org.openhab.binding.nanoleaf.internal.layout.NanoleafLayout;
49 import org.openhab.binding.nanoleaf.internal.layout.PanelState;
50 import org.openhab.binding.nanoleaf.internal.model.AuthToken;
51 import org.openhab.binding.nanoleaf.internal.model.BooleanState;
52 import org.openhab.binding.nanoleaf.internal.model.Brightness;
53 import org.openhab.binding.nanoleaf.internal.model.ControllerInfo;
54 import org.openhab.binding.nanoleaf.internal.model.Ct;
55 import org.openhab.binding.nanoleaf.internal.model.Effects;
56 import org.openhab.binding.nanoleaf.internal.model.Hue;
57 import org.openhab.binding.nanoleaf.internal.model.IntegerState;
58 import org.openhab.binding.nanoleaf.internal.model.Layout;
59 import org.openhab.binding.nanoleaf.internal.model.On;
60 import org.openhab.binding.nanoleaf.internal.model.PanelLayout;
61 import org.openhab.binding.nanoleaf.internal.model.Rhythm;
62 import org.openhab.binding.nanoleaf.internal.model.Sat;
63 import org.openhab.binding.nanoleaf.internal.model.State;
64 import org.openhab.binding.nanoleaf.internal.model.TouchEvents;
65 import org.openhab.core.config.core.Configuration;
66 import org.openhab.core.io.net.http.HttpClientFactory;
67 import org.openhab.core.library.types.DecimalType;
68 import org.openhab.core.library.types.HSBType;
69 import org.openhab.core.library.types.IncreaseDecreaseType;
70 import org.openhab.core.library.types.OnOffType;
71 import org.openhab.core.library.types.PercentType;
72 import org.openhab.core.library.types.RawType;
73 import org.openhab.core.library.types.StringType;
74 import org.openhab.core.thing.Bridge;
75 import org.openhab.core.thing.ChannelUID;
76 import org.openhab.core.thing.Thing;
77 import org.openhab.core.thing.ThingStatus;
78 import org.openhab.core.thing.ThingStatusDetail;
79 import org.openhab.core.thing.binding.BaseBridgeHandler;
80 import org.openhab.core.thing.binding.ThingHandlerCallback;
81 import org.openhab.core.thing.binding.ThingHandlerService;
82 import org.openhab.core.types.Command;
83 import org.openhab.core.types.RefreshType;
84 import org.slf4j.Logger;
85 import org.slf4j.LoggerFactory;
87 import com.google.gson.Gson;
88 import com.google.gson.JsonSyntaxException;
91 * The {@link NanoleafControllerHandler} is responsible for handling commands to the controller which
92 * affect all panels connected to it (e.g. selected effect)
94 * @author Martin Raepple - Initial contribution
95 * @author Stefan Höhn - Canvas Touch Support
96 * @author Kai Kreuzer - refactoring, bug fixing and code clean up
99 public class NanoleafControllerHandler extends BaseBridgeHandler {
101 // Pairing interval in seconds
102 private static final int PAIRING_INTERVAL = 10;
103 private static final int CONNECT_TIMEOUT = 10;
105 private final Logger logger = LoggerFactory.getLogger(NanoleafControllerHandler.class);
106 private final HttpClientFactory httpClientFactory;
107 private final HttpClient httpClient;
109 private @Nullable HttpClient httpClientSSETouchEvent;
110 private @Nullable Request sseTouchjobRequest;
111 private final List<NanoleafControllerListener> controllerListeners = new CopyOnWriteArrayList<NanoleafControllerListener>();
112 private PanelLayout previousPanelLayout = new PanelLayout();
114 private @NonNullByDefault({}) ScheduledFuture<?> pairingJob;
115 private @NonNullByDefault({}) ScheduledFuture<?> updateJob;
116 private @NonNullByDefault({}) ScheduledFuture<?> touchJob;
117 private final Gson gson = new Gson();
119 private @Nullable String address;
121 private int refreshIntervall;
122 private @Nullable String authToken;
123 private @Nullable String deviceType;
124 private @NonNullByDefault({}) ControllerInfo controllerInfo;
126 private boolean touchJobRunning = false;
128 public NanoleafControllerHandler(Bridge bridge, HttpClientFactory httpClientFactory) {
130 this.httpClientFactory = httpClientFactory;
131 this.httpClient = httpClientFactory.getCommonHttpClient();
134 private void initializeTouchHttpClient() {
135 String httpClientName = thing.getUID().getId();
138 httpClientSSETouchEvent = httpClientFactory.createHttpClient(httpClientName);
139 final HttpClient localHttpClientSSETouchEvent = this.httpClientSSETouchEvent;
140 if (localHttpClientSSETouchEvent != null) {
141 localHttpClientSSETouchEvent.setConnectTimeout(CONNECT_TIMEOUT * 1000L);
142 localHttpClientSSETouchEvent.start();
144 } catch (Exception e) {
146 "Long running HttpClient for Nanoleaf controller handler {} cannot be started. Creating Handler failed.",
148 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
151 logger.debug("Using long SSE httpClient={} for {}}", httpClientSSETouchEvent, httpClientName);
155 public void initialize() {
156 logger.debug("Initializing the controller (bridge)");
157 updateStatus(ThingStatus.UNKNOWN);
158 NanoleafControllerConfig config = getConfigAs(NanoleafControllerConfig.class);
159 setAddress(config.address);
160 setPort(config.port);
161 setRefreshIntervall(config.refreshInterval);
162 String authToken = (config.authToken != null) ? config.authToken : "";
163 setAuthToken(authToken);
164 Map<String, String> properties = getThing().getProperties();
165 String propertyModelId = properties.get(Thing.PROPERTY_MODEL_ID);
166 if (hasTouchSupport(propertyModelId)) {
167 config.deviceType = DEVICE_TYPE_TOUCHSUPPORT;
168 initializeTouchHttpClient();
170 config.deviceType = DEVICE_TYPE_LIGHTPANELS;
173 setDeviceType(config.deviceType);
174 String propertyFirmwareVersion = properties.get(Thing.PROPERTY_FIRMWARE_VERSION);
177 if (!config.address.isEmpty() && !String.valueOf(config.port).isEmpty()) {
178 if (propertyFirmwareVersion != null && !propertyFirmwareVersion.isEmpty() && !OpenAPIUtils
179 .checkRequiredFirmware(properties.get(Thing.PROPERTY_MODEL_ID), propertyFirmwareVersion)) {
180 logger.warn("Nanoleaf controller firmware is too old: {}. Must be equal or higher than {}",
181 propertyFirmwareVersion, API_MIN_FW_VER_LIGHTPANELS);
182 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
183 "@text/error.nanoleaf.controller.incompatibleFirmware");
185 } else if (authToken != null && !authToken.isEmpty()) {
190 logger.debug("No token found. Start pairing background job");
191 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_PENDING,
192 "@text/error.nanoleaf.controller.noToken");
197 logger.warn("No IP address and port configured for the Nanoleaf controller");
198 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_PENDING,
199 "@text/error.nanoleaf.controller.noIp");
202 } catch (IllegalArgumentException iae) {
203 logger.warn("Nanoleaf controller firmware version not in format x.y.z: {}",
204 getThing().getProperties().get(Thing.PROPERTY_FIRMWARE_VERSION));
205 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
206 "@text/error.nanoleaf.controller.incompatibleFirmware");
211 public void handleCommand(ChannelUID channelUID, Command command) {
212 logger.debug("Received command {} for channel {}", command, channelUID);
213 if (!ThingStatus.ONLINE.equals(getThing().getStatusInfo().getStatus())) {
214 logger.debug("Cannot handle command. Bridge is not online.");
217 if (command instanceof RefreshType) {
218 updateFromControllerInfo();
220 switch (channelUID.getId()) {
222 case CHANNEL_COLOR_TEMPERATURE:
223 case CHANNEL_COLOR_TEMPERATURE_ABS:
224 sendStateCommand(channelUID.getId(), command);
227 sendEffectCommand(command);
229 case CHANNEL_RHYTHM_MODE:
230 sendRhythmCommand(command);
233 logger.warn("Channel with id {} not handled", channelUID.getId());
237 } catch (NanoleafUnauthorizedException nue) {
238 logger.debug("Authorization for command {} to channelUID {} failed: {}", command, channelUID,
240 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
241 "@text/error.nanoleaf.controller.invalidToken");
242 } catch (NanoleafException ne) {
243 logger.debug("Handling command {} to channelUID {} failed: {}", command, channelUID, ne.getMessage());
244 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
245 "@text/error.nanoleaf.controller.communication");
251 public void handleRemoval() {
252 scheduler.execute(() -> {
254 Request deleteTokenRequest = OpenAPIUtils.requestBuilder(httpClient, getControllerConfig(),
255 API_DELETE_USER, HttpMethod.DELETE);
256 ContentResponse deleteTokenResponse = OpenAPIUtils.sendOpenAPIRequest(deleteTokenRequest);
257 if (deleteTokenResponse.getStatus() != HttpStatus.NO_CONTENT_204) {
258 logger.warn("Failed to delete token for openHAB. Response code is {}",
259 deleteTokenResponse.getStatus());
262 logger.debug("Successfully deleted token for openHAB from controller");
263 } catch (NanoleafUnauthorizedException e) {
264 logger.warn("Attempt to delete token for openHAB failed. Token unauthorized.");
265 } catch (NanoleafException ne) {
266 logger.warn("Attempt to delete token for openHAB failed : {}", ne.getMessage());
269 super.handleRemoval();
270 logger.debug("Nanoleaf controller removed");
275 public void dispose() {
278 logger.debug("Disposing handler for Nanoleaf controller {}", getThing().getUID());
282 public Collection<Class<? extends ThingHandlerService>> getServices() {
283 return List.of(NanoleafPanelsDiscoveryService.class, NanoleafCommandDescriptionProvider.class);
286 public boolean registerControllerListener(NanoleafControllerListener controllerListener) {
287 logger.debug("Register new listener for controller {}", getThing().getUID());
288 return controllerListeners.add(controllerListener);
291 public boolean unregisterControllerListener(NanoleafControllerListener controllerListener) {
292 logger.debug("Unregister listener for controller {}", getThing().getUID());
293 return controllerListeners.remove(controllerListener);
296 public NanoleafControllerConfig getControllerConfig() {
297 NanoleafControllerConfig config = new NanoleafControllerConfig();
298 config.address = Objects.requireNonNullElse(getAddress(), "");
299 config.port = getPort();
300 config.refreshInterval = getRefreshInterval();
301 config.authToken = getAuthToken();
302 config.deviceType = Objects.requireNonNullElse(getDeviceType(), "");
306 public String getLayout() {
307 String layoutView = "";
308 if (controllerInfo != null) {
309 PanelLayout panelLayout = controllerInfo.getPanelLayout();
310 Layout layout = panelLayout.getLayout();
311 layoutView = layout != null ? layout.getLayoutView() : "";
317 public synchronized void startPairingJob() {
318 if (pairingJob == null || pairingJob.isCancelled()) {
319 logger.debug("Start pairing job, interval={} sec", PAIRING_INTERVAL);
320 pairingJob = scheduler.scheduleWithFixedDelay(this::runPairing, 0L, PAIRING_INTERVAL, TimeUnit.SECONDS);
324 private synchronized void stopPairingJob() {
325 logger.debug("Stop pairing job {}", pairingJob != null ? pairingJob.isCancelled() : "pairing job = null");
326 if (pairingJob != null && !pairingJob.isCancelled()) {
327 pairingJob.cancel(true);
329 logger.debug("Stopped pairing job");
333 private synchronized void startUpdateJob() {
334 final String localAuthToken = getAuthToken();
335 if (localAuthToken != null && !localAuthToken.isEmpty()) {
336 if (updateJob == null || updateJob.isCancelled()) {
337 logger.debug("Start controller status job, repeat every {} sec", getRefreshInterval());
338 updateJob = scheduler.scheduleWithFixedDelay(this::runUpdate, 0L, getRefreshInterval(),
342 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_PENDING,
343 "@text/error.nanoleaf.controller.noToken");
347 private synchronized void stopUpdateJob() {
348 logger.debug("Stop update job {}", updateJob != null ? updateJob.isCancelled() : "update job = null");
349 if (updateJob != null && !updateJob.isCancelled()) {
350 updateJob.cancel(true);
352 logger.debug("Stopped status job");
356 private synchronized void startTouchJob() {
357 NanoleafControllerConfig config = getConfigAs(NanoleafControllerConfig.class);
358 if (!config.deviceType.equals(DEVICE_TYPE_TOUCHSUPPORT)) {
360 "NOT starting TouchJob for Controller {} because it has wrong device type '{}' vs required '{}'",
361 this.getThing().getUID(), config.deviceType, DEVICE_TYPE_TOUCHSUPPORT);
363 logger.debug("Starting TouchJob for Controller {}", getThing().getUID());
364 final String localAuthToken = getAuthToken();
365 if (localAuthToken != null && !localAuthToken.isEmpty()) {
366 if (touchJob != null && !touchJob.isDone()) {
367 logger.trace("tj: tj={} already running touchJobRunning = {} cancelled={} done={}", touchJob,
368 touchJobRunning, touchJob == null ? null : touchJob.isCancelled(),
369 touchJob == null ? null : touchJob.isDone());
371 logger.debug("tj: Starting NEW touch job : tj={} touchJobRunning={} cancelled={} done={}",
372 touchJob, touchJobRunning, touchJob == null ? null : touchJob.isCancelled(),
373 touchJob == null ? null : touchJob.isDone());
374 touchJob = scheduler.scheduleWithFixedDelay(this::runTouchDetection, 0L, 1L, TimeUnit.SECONDS);
377 logger.error("starting TouchJob for Controller {} failed - missing token", getThing().getUID());
383 private synchronized void stopTouchJob() {
384 logger.debug("Stop touch job {}", touchJob != null ? touchJob.isCancelled() : "touchJob job = null");
385 if (touchJob != null) {
386 logger.trace("tj: touch job stopping for {} with client {}", thing.getUID(), httpClientSSETouchEvent);
388 final Request localSSERequest = sseTouchjobRequest;
389 if (localSSERequest != null) {
390 localSSERequest.abort(new NanoleafException("Touch detection stopped"));
392 if (!touchJob.isCancelled()) {
393 touchJob.cancel(true);
397 touchJobRunning = false;
398 logger.debug("tj: touch job stopped for {} with client {}", thing.getUID(), httpClientSSETouchEvent);
402 private boolean hasTouchSupport(@Nullable String deviceType) {
403 return NanoleafBindingConstants.MODELS_WITH_TOUCHSUPPORT.contains(deviceType);
406 private void runUpdate() {
407 logger.debug("Run update job");
410 updateFromControllerInfo();
412 updateStatus(ThingStatus.ONLINE);
413 } catch (NanoleafUnauthorizedException nae) {
414 logger.debug("Status update unauthorized for controller {}: {}", getThing().getUID(), nae.getMessage());
415 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
416 "@text/error.nanoleaf.controller.invalidToken");
417 final String localAuthToken = getAuthToken();
418 if (localAuthToken == null || localAuthToken.isEmpty()) {
419 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_PENDING,
420 "@text/error.nanoleaf.controller.noToken");
422 } catch (NanoleafException ne) {
423 logger.debug("Status update failed for controller {} : {}", getThing().getUID(), ne.getMessage());
424 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
425 "@text/error.nanoleaf.controller.communication");
426 } catch (RuntimeException e) {
427 logger.debug("Update job failed", e);
428 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, "@text/error.nanoleaf.controller.runtime");
432 private void runPairing() {
433 logger.debug("Run pairing job");
436 final String localAuthToken = getAuthToken();
437 if (localAuthToken != null && !localAuthToken.isEmpty()) {
438 if (pairingJob != null) {
439 pairingJob.cancel(false);
442 logger.debug("Authentication token found. Canceling pairing job");
446 ContentResponse authTokenResponse = OpenAPIUtils
447 .requestBuilder(httpClient, getControllerConfig(), API_ADD_USER, HttpMethod.POST)
448 .timeout(20L, TimeUnit.SECONDS).send();
449 String authTokenResponseString = (authTokenResponse != null) ? authTokenResponse.getContentAsString() : "";
450 if (logger.isTraceEnabled()) {
451 logger.trace("Auth token response: {}", authTokenResponseString);
454 if (authTokenResponse != null && authTokenResponse.getStatus() != HttpStatus.OK_200) {
455 logger.debug("Pairing pending for {}. Controller returns status code {}", getThing().getUID(),
456 authTokenResponse.getStatus());
458 AuthToken authTokenObject = gson.fromJson(authTokenResponseString, AuthToken.class);
459 authTokenObject = (authTokenObject != null) ? authTokenObject : new AuthToken();
460 if (authTokenObject.getAuthToken().isEmpty()) {
461 logger.debug("No auth token found in response: {}", authTokenResponseString);
462 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
463 "@text/error.nanoleaf.controller.pairingFailed");
464 throw new NanoleafException(authTokenResponseString);
467 logger.debug("Pairing succeeded.");
468 Configuration config = editConfiguration();
470 config.put(NanoleafControllerConfig.AUTH_TOKEN, authTokenObject.getAuthToken());
471 updateConfiguration(config);
472 updateStatus(ThingStatus.ONLINE);
473 // Update local field
474 setAuthToken(authTokenObject.getAuthToken());
480 } catch (JsonSyntaxException e) {
481 logger.warn("Received invalid data", e);
482 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
483 "@text/error.nanoleaf.controller.invalidData");
484 } catch (NanoleafException ne) {
485 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
486 "@text/error.nanoleaf.controller.noTokenReceived");
487 } catch (ExecutionException | TimeoutException | InterruptedException e) {
488 logger.debug("Cannot send authorization request to controller: ", e);
489 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
490 "@text/error.nanoleaf.controller.authRequest");
491 } catch (RuntimeException e) {
492 logger.warn("Pairing job failed", e);
493 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, "@text/error.nanoleaf.controller.runtime");
494 } catch (Exception e) {
495 logger.warn("Cannot start http client", e);
496 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
497 "@text/error.nanoleaf.controller.noClient");
501 private synchronized void runTouchDetection() {
502 final HttpClient localhttpSSEClientTouchEvent = httpClientSSETouchEvent;
503 int eventHashcode = -1;
504 if (localhttpSSEClientTouchEvent != null) {
505 eventHashcode = localhttpSSEClientTouchEvent.hashCode();
507 if (touchJobRunning) {
508 logger.trace("tj: touch job {} touch job already running. quitting. {} controller {} with {}\",\n",
509 touchJob, eventHashcode, thing.getUID(), httpClientSSETouchEvent);
512 URI eventUri = OpenAPIUtils.getUri(getControllerConfig(), API_EVENTS, "id=4");
513 logger.debug("tj: touch job request registering for {} with client {}", thing.getUID(),
514 httpClientSSETouchEvent);
515 touchJobRunning = true;
516 if (localhttpSSEClientTouchEvent != null) {
517 localhttpSSEClientTouchEvent.setIdleTimeout(CONNECT_TIMEOUT * 1000L);
518 sseTouchjobRequest = localhttpSSEClientTouchEvent.newRequest(eventUri);
519 final Request localSSETouchjobRequest = sseTouchjobRequest;
520 if (localSSETouchjobRequest != null) {
521 int requestHashCode = localSSETouchjobRequest.hashCode();
523 logger.debug("tj: triggering new touch job request {} for {} with client {}", requestHashCode,
524 thing.getUID(), eventHashcode);
525 localSSETouchjobRequest.onResponseContent((response, content) -> {
526 String s = StandardCharsets.UTF_8.decode(content).toString();
527 logger.debug("touch detected for controller {}", thing.getUID());
528 logger.trace("content {}", s);
529 try (Scanner eventContent = new Scanner(s)) {
530 while (eventContent.hasNextLine()) {
531 String line = eventContent.nextLine().trim();
532 if (line.startsWith("data:")) {
533 String json = line.substring(5).trim();
536 TouchEvents touchEvents = gson.fromJson(json, TouchEvents.class);
537 handleTouchEvents(Objects.requireNonNull(touchEvents));
538 } catch (JsonSyntaxException e) {
539 logger.error("Couldn't parse touch event json {}", json);
544 logger.debug("leaving touch onContent");
545 }).onResponseSuccess((response) -> {
546 logger.trace("tj: r={} touch event SUCCESS: {}", response.getRequest(), response);
547 }).onResponseFailure((response, failure) -> {
548 logger.trace("tj: r={} touch event FAILURE. Touchjob not running anymore for controller {}",
549 response.getRequest(), thing.getUID());
550 }).send((result) -> {
552 "tj: r={} touch event COMPLETE. Touchjob not running anymore for controller {} failed: {} succeeded: {}",
553 result.getRequest(), thing.getUID(), result.isFailed(), result.isSucceeded());
554 touchJobRunning = false;
558 logger.trace("tj: started touch job request for {} with {} at {}", thing.getUID(),
559 httpClientSSETouchEvent, eventUri);
560 } catch (NanoleafException | RuntimeException e) {
561 logger.warn("tj: setting up TouchDetection failed for controller {} with {}\",\n", thing.getUID(),
562 httpClientSSETouchEvent);
563 logger.warn("tj: setting up TouchDetection failed with exception", e);
565 logger.trace("tj: touch job {} started for new request {} controller {} with {}\",\n",
566 touchJob.hashCode(), eventHashcode, thing.getUID(), httpClientSSETouchEvent);
572 private void handleTouchEvents(TouchEvents touchEvents) {
573 touchEvents.getEvents().forEach((event) -> {
574 logger.debug("panel: {} gesture id: {}", event.getPanelId(), event.getGesture());
575 // Swipes go to the controller, taps go to the individual panel
576 if (event.getPanelId().equals(CONTROLLER_PANEL_ID)) {
577 logger.debug("Triggering controller {} with gesture {}.", thing.getUID(), event.getGesture());
578 updateControllerGesture(event.getGesture());
580 getThing().getThings().forEach((child) -> {
581 NanoleafPanelHandler panelHandler = (NanoleafPanelHandler) child.getHandler();
582 if (panelHandler != null) {
583 logger.trace("Checking available panel -{}- versus event panel -{}-", panelHandler.getPanelID(),
585 if (panelHandler.getPanelID().equals(event.getPanelId())) {
586 logger.debug("Panel {} found. Triggering item with gesture {}.", panelHandler.getPanelID(),
588 panelHandler.updatePanelGesture(event.getGesture());
598 * Apply the swipe gesture to the controller
600 * @param gesture Only swipes are supported on the complete nanoleaf panels
602 private void updateControllerGesture(int gesture) {
605 triggerChannel(CHANNEL_SWIPE, CHANNEL_SWIPE_EVENT_UP);
608 triggerChannel(CHANNEL_SWIPE, CHANNEL_SWIPE_EVENT_DOWN);
611 triggerChannel(CHANNEL_SWIPE, CHANNEL_SWIPE_EVENT_LEFT);
614 triggerChannel(CHANNEL_SWIPE, CHANNEL_SWIPE_EVENT_RIGHT);
619 private void updateFromControllerInfo() throws NanoleafException {
620 logger.debug("Update channels for controller {}", thing.getUID());
621 controllerInfo = receiveControllerInfo();
622 State state = controllerInfo.getState();
624 OnOffType powerState = state.getOnOff();
626 Ct colorTemperature = state.getColorTemperature();
628 float colorTempPercent = 0.0F;
631 if (colorTemperature != null) {
632 updateState(CHANNEL_COLOR_TEMPERATURE_ABS, new DecimalType(colorTemperature.getValue()));
633 Integer min = colorTemperature.getMin();
634 hue = min == null ? 0 : min;
635 Integer max = colorTemperature.getMax();
636 saturation = max == null ? 0 : max;
637 colorTempPercent = (colorTemperature.getValue() - hue) / (saturation - hue)
638 * PercentType.HUNDRED.intValue();
641 updateState(CHANNEL_COLOR_TEMPERATURE, new PercentType(Float.toString(colorTempPercent)));
642 updateState(CHANNEL_EFFECT, new StringType(controllerInfo.getEffects().getSelect()));
643 Hue stateHue = state.getHue();
644 hue = stateHue != null ? stateHue.getValue() : 0;
646 Sat stateSaturation = state.getSaturation();
647 saturation = stateSaturation != null ? stateSaturation.getValue() : 0;
649 Brightness stateBrightness = state.getBrightness();
650 int brightness = stateBrightness != null ? stateBrightness.getValue() : 0;
652 updateState(CHANNEL_COLOR, new HSBType(new DecimalType(hue), new PercentType(saturation),
653 new PercentType(powerState == OnOffType.ON ? brightness : 0)));
654 updateState(CHANNEL_COLOR_MODE, new StringType(state.getColorMode()));
655 updateState(CHANNEL_RHYTHM_ACTIVE, controllerInfo.getRhythm().getRhythmActive() ? OnOffType.ON : OnOffType.OFF);
656 updateState(CHANNEL_RHYTHM_MODE, new DecimalType(controllerInfo.getRhythm().getRhythmMode()));
657 updateState(CHANNEL_RHYTHM_STATE,
658 controllerInfo.getRhythm().getRhythmConnected() ? OnOffType.ON : OnOffType.OFF);
660 // update the color channels of each panel
661 getThing().getThings().forEach(child -> {
662 NanoleafPanelHandler panelHandler = (NanoleafPanelHandler) child.getHandler();
663 if (panelHandler != null) {
664 logger.debug("Update color channel for panel {}", panelHandler.getThing().getUID());
665 panelHandler.updatePanelColorChannel();
670 updateConfiguration();
671 updateLayout(controllerInfo.getPanelLayout());
672 updateVisualState(controllerInfo.getPanelLayout());
674 for (NanoleafControllerListener controllerListener : controllerListeners) {
675 controllerListener.onControllerInfoFetched(getThing().getUID(), controllerInfo);
679 private void updateConfiguration() {
680 // only update the Thing config if value isn't set yet
681 if (getConfig().get(NanoleafControllerConfig.DEVICE_TYPE) == null) {
682 Configuration config = editConfiguration();
683 if (hasTouchSupport(controllerInfo.getModel())) {
684 config.put(NanoleafControllerConfig.DEVICE_TYPE, DEVICE_TYPE_TOUCHSUPPORT);
685 logger.debug("Set to device type {}", DEVICE_TYPE_TOUCHSUPPORT);
687 config.put(NanoleafControllerConfig.DEVICE_TYPE, DEVICE_TYPE_LIGHTPANELS);
688 logger.debug("Set to device type {}", DEVICE_TYPE_LIGHTPANELS);
690 updateConfiguration(config);
691 if (logger.isTraceEnabled()) {
692 getConfig().getProperties().forEach((key, value) -> {
693 logger.trace("Configuration property: key {} value {}", key, value);
699 private void updateProperties() {
700 // update bridge properties which may have changed, or are not present during discovery
701 Map<String, String> properties = editProperties();
702 properties.put(Thing.PROPERTY_SERIAL_NUMBER, controllerInfo.getSerialNo());
703 properties.put(Thing.PROPERTY_FIRMWARE_VERSION, controllerInfo.getFirmwareVersion());
704 properties.put(Thing.PROPERTY_MODEL_ID, controllerInfo.getModel());
705 properties.put(Thing.PROPERTY_VENDOR, controllerInfo.getManufacturer());
706 updateProperties(properties);
707 if (logger.isTraceEnabled()) {
708 getThing().getProperties().forEach((key, value) -> {
709 logger.trace("Thing property: key {} value {}", key, value);
714 private void updateVisualState(PanelLayout panelLayout) {
715 ChannelUID stateChannel = new ChannelUID(getThing().getUID(), CHANNEL_VISUAL_STATE);
717 Bridge bridge = getThing();
718 List<Thing> things = bridge.getThings();
719 if (things == null) {
720 logger.trace("No things to get state from!");
725 LayoutSettings settings = new LayoutSettings(false, true, true, true);
726 logger.trace("Getting panel state for {} things", things.size());
727 PanelState panelState = new PanelState(things);
728 byte[] bytes = NanoleafLayout.render(panelLayout, panelState, settings);
729 if (bytes.length > 0) {
730 updateState(stateChannel, new RawType(bytes, "image/png"));
731 logger.trace("Rendered visual state of panel {} in updateState has {} bytes", getThing().getUID(),
734 logger.debug("Visual state of {} failed to produce any image", getThing().getUID());
737 previousPanelLayout = panelLayout;
738 } catch (IOException ioex) {
739 logger.warn("Failed to create state image", ioex);
743 private void updateLayout(PanelLayout panelLayout) {
744 ChannelUID layoutChannel = new ChannelUID(getThing().getUID(), CHANNEL_LAYOUT);
745 ThingHandlerCallback callback = getCallback();
746 if (callback != null) {
747 if (!callback.isChannelLinked(layoutChannel)) {
748 // Don't generate image unless it is used
753 if (previousPanelLayout.equals(panelLayout)) {
754 logger.trace("Not rendering panel layout for {} as it is the same as previous rendered panel layout",
755 getThing().getUID());
759 Bridge bridge = getThing();
760 List<Thing> things = bridge.getThings();
762 LayoutSettings settings = new LayoutSettings(true, false, true, false);
763 byte[] bytes = NanoleafLayout.render(panelLayout, new PanelState(things), settings);
764 if (bytes.length > 0) {
765 updateState(layoutChannel, new RawType(bytes, "image/png"));
766 logger.trace("Rendered layout of panel {} in updateState has {} bytes", getThing().getUID(),
769 logger.debug("Layout of {} failed to produce any image", getThing().getUID());
772 previousPanelLayout = panelLayout;
773 } catch (IOException ioex) {
774 logger.warn("Failed to create layout image", ioex);
778 private ControllerInfo receiveControllerInfo() throws NanoleafException, NanoleafUnauthorizedException {
779 ContentResponse controllerlInfoJSON = OpenAPIUtils.sendOpenAPIRequest(OpenAPIUtils.requestBuilder(httpClient,
780 getControllerConfig(), API_GET_CONTROLLER_INFO, HttpMethod.GET));
781 ControllerInfo controllerInfo = gson.fromJson(controllerlInfoJSON.getContentAsString(), ControllerInfo.class);
782 return Objects.requireNonNull(controllerInfo);
785 private void sendStateCommand(String channel, Command command) throws NanoleafException {
786 State stateObject = new State();
789 if (command instanceof OnOffType) {
790 // On/Off command - turns controller on/off
791 BooleanState state = new On();
792 state.setValue(OnOffType.ON.equals(command));
793 stateObject.setState(state);
794 } else if (command instanceof HSBType) {
795 // regular color HSB command
796 IntegerState h = new Hue();
797 IntegerState s = new Sat();
798 IntegerState b = new Brightness();
799 h.setValue(((HSBType) command).getHue().intValue());
800 s.setValue(((HSBType) command).getSaturation().intValue());
801 b.setValue(((HSBType) command).getBrightness().intValue());
802 stateObject.setState(h);
803 stateObject.setState(s);
804 stateObject.setState(b);
805 } else if (command instanceof PercentType) {
806 // brightness command
807 IntegerState b = new Brightness();
808 b.setValue(((PercentType) command).intValue());
809 stateObject.setState(b);
810 } else if (command instanceof IncreaseDecreaseType) {
811 // increase/decrease brightness
812 if (controllerInfo != null) {
814 Brightness brightness = controllerInfo.getState().getBrightness();
817 if (brightness != null) {
819 Integer min = brightness.getMin();
820 brightnessMin = (min == null) ? 0 : min;
822 Integer max = brightness.getMax();
823 brightnessMax = (max == null) ? 0 : max;
825 if (IncreaseDecreaseType.INCREASE.equals(command)) {
827 Math.min(brightnessMax, brightness.getValue() + BRIGHTNESS_STEP_SIZE));
830 Math.max(brightnessMin, brightness.getValue() - BRIGHTNESS_STEP_SIZE));
832 stateObject.setState(brightness);
833 logger.debug("Setting controller brightness to {}", brightness.getValue());
834 // update controller info in case new command is sent before next update job interval
835 controllerInfo.getState().setBrightness(brightness);
837 logger.debug("Couldn't set brightness as it was null!");
841 logger.warn("Unhandled command {} with command type: {}", command, command.getClass().getName());
845 case CHANNEL_COLOR_TEMPERATURE:
846 if (command instanceof PercentType) {
847 // Color temperature (percent)
848 IntegerState state = new Ct();
850 Ct colorTemperature = controllerInfo.getState().getColorTemperature();
854 if (colorTemperature != null) {
856 Integer min = colorTemperature.getMin();
857 colorMin = (min == null) ? 0 : min;
860 Integer max = colorTemperature.getMax();
861 colorMax = (max == null) ? 0 : max;
864 state.setValue(Math.round((colorMax - colorMin) * (100 - ((PercentType) command).intValue())
865 / PercentType.HUNDRED.floatValue() + colorMin));
866 stateObject.setState(state);
868 logger.warn("Unhandled command type: {}", command.getClass().getName());
872 case CHANNEL_COLOR_TEMPERATURE_ABS:
873 if (command instanceof DecimalType) {
874 // Color temperature (absolute)
875 IntegerState state = new Ct();
876 state.setValue(((DecimalType) command).intValue());
877 stateObject.setState(state);
879 logger.warn("Unhandled command type: {}", command.getClass().getName());
884 logger.warn("Unhandled command type: {}", command.getClass().getName());
888 Request setNewStateRequest = OpenAPIUtils.requestBuilder(httpClient, getControllerConfig(), API_SET_VALUE,
890 setNewStateRequest.content(new StringContentProvider(gson.toJson(stateObject)), "application/json");
891 OpenAPIUtils.sendOpenAPIRequest(setNewStateRequest);
894 private void sendEffectCommand(Command command) throws NanoleafException {
895 Effects effects = new Effects();
896 if (command instanceof StringType) {
897 effects.setSelect(command.toString());
898 Request setNewEffectRequest = OpenAPIUtils.requestBuilder(httpClient, getControllerConfig(), API_EFFECT,
900 String content = gson.toJson(effects);
901 logger.debug("sending effect command from controller {}: {}", getThing().getUID(), content);
902 setNewEffectRequest.content(new StringContentProvider(content), "application/json");
903 OpenAPIUtils.sendOpenAPIRequest(setNewEffectRequest);
905 logger.warn("Unhandled command type: {}", command.getClass().getName());
909 private void sendRhythmCommand(Command command) throws NanoleafException {
910 Rhythm rhythm = new Rhythm();
911 if (command instanceof DecimalType) {
912 rhythm.setRhythmMode(((DecimalType) command).intValue());
913 Request setNewRhythmRequest = OpenAPIUtils.requestBuilder(httpClient, getControllerConfig(),
914 API_RHYTHM_MODE, HttpMethod.PUT);
915 setNewRhythmRequest.content(new StringContentProvider(gson.toJson(rhythm)), "application/json");
916 OpenAPIUtils.sendOpenAPIRequest(setNewRhythmRequest);
918 logger.warn("Unhandled command type: {}", command.getClass().getName());
922 private @Nullable String getAddress() {
926 private void setAddress(String address) {
927 this.address = address;
930 private int getPort() {
934 private void setPort(int port) {
938 private int getRefreshInterval() {
939 return refreshIntervall;
942 private void setRefreshIntervall(int refreshIntervall) {
943 this.refreshIntervall = refreshIntervall;
947 private String getAuthToken() {
951 private void setAuthToken(@Nullable String authToken) {
952 this.authToken = authToken;
956 private String getDeviceType() {
960 private void setDeviceType(String deviceType) {
961 this.deviceType = deviceType;
964 private void stopAllJobs() {