2 * Copyright (c) 2010-2023 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.ArrayList;
21 import java.util.Arrays;
22 import java.util.Collection;
23 import java.util.List;
25 import java.util.Objects;
26 import java.util.Scanner;
27 import java.util.concurrent.CopyOnWriteArrayList;
28 import java.util.concurrent.ExecutionException;
29 import java.util.concurrent.ScheduledFuture;
30 import java.util.concurrent.TimeUnit;
31 import java.util.concurrent.TimeoutException;
33 import org.eclipse.jdt.annotation.NonNullByDefault;
34 import org.eclipse.jdt.annotation.Nullable;
35 import org.eclipse.jetty.client.HttpClient;
36 import org.eclipse.jetty.client.api.ContentResponse;
37 import org.eclipse.jetty.client.api.Request;
38 import org.eclipse.jetty.client.util.StringContentProvider;
39 import org.eclipse.jetty.http.HttpMethod;
40 import org.eclipse.jetty.http.HttpStatus;
41 import org.openhab.binding.nanoleaf.internal.NanoleafBadRequestException;
42 import org.openhab.binding.nanoleaf.internal.NanoleafBindingConstants;
43 import org.openhab.binding.nanoleaf.internal.NanoleafControllerListener;
44 import org.openhab.binding.nanoleaf.internal.NanoleafException;
45 import org.openhab.binding.nanoleaf.internal.NanoleafNotFoundException;
46 import org.openhab.binding.nanoleaf.internal.NanoleafUnauthorizedException;
47 import org.openhab.binding.nanoleaf.internal.OpenAPIUtils;
48 import org.openhab.binding.nanoleaf.internal.colors.NanoleafControllerColorChangeListener;
49 import org.openhab.binding.nanoleaf.internal.colors.NanoleafPanelColors;
50 import org.openhab.binding.nanoleaf.internal.commanddescription.NanoleafCommandDescriptionProvider;
51 import org.openhab.binding.nanoleaf.internal.config.NanoleafControllerConfig;
52 import org.openhab.binding.nanoleaf.internal.discovery.NanoleafPanelsDiscoveryService;
53 import org.openhab.binding.nanoleaf.internal.layout.ConstantPanelState;
54 import org.openhab.binding.nanoleaf.internal.layout.LayoutSettings;
55 import org.openhab.binding.nanoleaf.internal.layout.LivePanelState;
56 import org.openhab.binding.nanoleaf.internal.layout.NanoleafLayout;
57 import org.openhab.binding.nanoleaf.internal.layout.PanelState;
58 import org.openhab.binding.nanoleaf.internal.model.AuthToken;
59 import org.openhab.binding.nanoleaf.internal.model.BooleanState;
60 import org.openhab.binding.nanoleaf.internal.model.Brightness;
61 import org.openhab.binding.nanoleaf.internal.model.ControllerInfo;
62 import org.openhab.binding.nanoleaf.internal.model.Ct;
63 import org.openhab.binding.nanoleaf.internal.model.Effects;
64 import org.openhab.binding.nanoleaf.internal.model.Hue;
65 import org.openhab.binding.nanoleaf.internal.model.IntegerState;
66 import org.openhab.binding.nanoleaf.internal.model.Layout;
67 import org.openhab.binding.nanoleaf.internal.model.On;
68 import org.openhab.binding.nanoleaf.internal.model.PanelLayout;
69 import org.openhab.binding.nanoleaf.internal.model.PositionDatum;
70 import org.openhab.binding.nanoleaf.internal.model.Rhythm;
71 import org.openhab.binding.nanoleaf.internal.model.Sat;
72 import org.openhab.binding.nanoleaf.internal.model.State;
73 import org.openhab.binding.nanoleaf.internal.model.TouchEvents;
74 import org.openhab.binding.nanoleaf.internal.model.Write;
75 import org.openhab.core.config.core.Configuration;
76 import org.openhab.core.io.net.http.HttpClientFactory;
77 import org.openhab.core.library.types.DecimalType;
78 import org.openhab.core.library.types.HSBType;
79 import org.openhab.core.library.types.IncreaseDecreaseType;
80 import org.openhab.core.library.types.OnOffType;
81 import org.openhab.core.library.types.PercentType;
82 import org.openhab.core.library.types.QuantityType;
83 import org.openhab.core.library.types.RawType;
84 import org.openhab.core.library.types.StringType;
85 import org.openhab.core.library.unit.Units;
86 import org.openhab.core.thing.Bridge;
87 import org.openhab.core.thing.ChannelUID;
88 import org.openhab.core.thing.Thing;
89 import org.openhab.core.thing.ThingStatus;
90 import org.openhab.core.thing.ThingStatusDetail;
91 import org.openhab.core.thing.binding.BaseBridgeHandler;
92 import org.openhab.core.thing.binding.ThingHandlerCallback;
93 import org.openhab.core.thing.binding.ThingHandlerService;
94 import org.openhab.core.thing.util.ThingWebClientUtil;
95 import org.openhab.core.types.Command;
96 import org.openhab.core.types.RefreshType;
97 import org.slf4j.Logger;
98 import org.slf4j.LoggerFactory;
100 import com.google.gson.Gson;
101 import com.google.gson.JsonSyntaxException;
104 * The {@link NanoleafControllerHandler} is responsible for handling commands to the controller which
105 * affect all panels connected to it (e.g. selected effect)
107 * @author Martin Raepple - Initial contribution
108 * @author Stefan Höhn - Canvas Touch Support
109 * @author Kai Kreuzer - refactoring, bug fixing and code clean up
112 public class NanoleafControllerHandler extends BaseBridgeHandler implements NanoleafControllerColorChangeListener {
114 // Pairing interval in seconds
115 private static final int PAIRING_INTERVAL = 10;
116 private static final int CONNECT_TIMEOUT = 10;
118 private final Logger logger = LoggerFactory.getLogger(NanoleafControllerHandler.class);
119 private final HttpClientFactory httpClientFactory;
120 private final HttpClient httpClient;
122 private @Nullable HttpClient httpClientSSETouchEvent;
123 private @Nullable Request sseTouchjobRequest;
124 private final List<NanoleafControllerListener> controllerListeners = new CopyOnWriteArrayList<NanoleafControllerListener>();
125 private PanelLayout previousPanelLayout = new PanelLayout();
126 private final NanoleafPanelColors panelColors = new NanoleafPanelColors();
127 private boolean updateVisualLayout = true;
128 private byte @Nullable [] layoutImage;
130 private @NonNullByDefault({}) ScheduledFuture<?> pairingJob;
131 private @NonNullByDefault({}) ScheduledFuture<?> updateJob;
132 private @NonNullByDefault({}) ScheduledFuture<?> touchJob;
133 private final Gson gson = new Gson();
135 private @Nullable String address;
137 private int refreshIntervall;
138 private @Nullable String authToken;
139 private @Nullable String deviceType;
140 private @NonNullByDefault({}) ControllerInfo controllerInfo;
142 private boolean touchJobRunning = false;
144 public NanoleafControllerHandler(Bridge bridge, HttpClientFactory httpClientFactory) {
146 this.httpClientFactory = httpClientFactory;
147 this.httpClient = httpClientFactory.getCommonHttpClient();
150 private void initializeTouchHttpClient() {
151 String httpClientName = ThingWebClientUtil.buildWebClientConsumerName(thing.getUID(), null);
154 httpClientSSETouchEvent = httpClientFactory.createHttpClient(httpClientName);
155 final HttpClient localHttpClientSSETouchEvent = this.httpClientSSETouchEvent;
156 if (localHttpClientSSETouchEvent != null) {
157 localHttpClientSSETouchEvent.setConnectTimeout(CONNECT_TIMEOUT * 1000L);
158 localHttpClientSSETouchEvent.start();
160 } catch (Exception e) {
162 "Long running HttpClient for Nanoleaf controller handler {} cannot be started. Creating Handler failed.",
164 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
167 logger.debug("Using long SSE httpClient={} for {}}", httpClientSSETouchEvent, httpClientName);
171 public void initialize() {
172 logger.debug("Initializing the controller (bridge)");
173 this.panelColors.registerChangeListener(this);
174 updateStatus(ThingStatus.UNKNOWN);
175 NanoleafControllerConfig config = getConfigAs(NanoleafControllerConfig.class);
176 setAddress(config.address);
177 setPort(config.port);
178 setRefreshIntervall(config.refreshInterval);
179 String authToken = (config.authToken != null) ? config.authToken : "";
180 setAuthToken(authToken);
181 Map<String, String> properties = getThing().getProperties();
182 String propertyModelId = properties.get(Thing.PROPERTY_MODEL_ID);
183 if (hasTouchSupport(propertyModelId)) {
184 config.deviceType = DEVICE_TYPE_TOUCHSUPPORT;
185 initializeTouchHttpClient();
187 config.deviceType = DEVICE_TYPE_LIGHTPANELS;
190 setDeviceType(config.deviceType);
191 String propertyFirmwareVersion = properties.get(Thing.PROPERTY_FIRMWARE_VERSION);
194 if (!config.address.isEmpty() && !String.valueOf(config.port).isEmpty()) {
195 if (propertyFirmwareVersion != null && !propertyFirmwareVersion.isEmpty() && !OpenAPIUtils
196 .checkRequiredFirmware(properties.get(Thing.PROPERTY_MODEL_ID), propertyFirmwareVersion)) {
197 logger.warn("Nanoleaf controller firmware is too old: {}. Must be equal or higher than {}",
198 propertyFirmwareVersion, API_MIN_FW_VER_LIGHTPANELS);
199 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
200 "@text/error.nanoleaf.controller.incompatibleFirmware");
202 } else if (authToken != null && !authToken.isEmpty()) {
207 logger.debug("No token found. Start pairing background job");
208 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_PENDING,
209 "@text/error.nanoleaf.controller.noToken");
214 logger.warn("No IP address and port configured for the Nanoleaf controller");
215 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_PENDING,
216 "@text/error.nanoleaf.controller.noIp");
219 } catch (IllegalArgumentException iae) {
220 logger.warn("Nanoleaf controller firmware version not in format x.y.z: {}",
221 getThing().getProperties().get(Thing.PROPERTY_FIRMWARE_VERSION));
222 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
223 "@text/error.nanoleaf.controller.incompatibleFirmware");
228 public void handleCommand(ChannelUID channelUID, Command command) {
229 logger.debug("Received command {} for channel {}", command, channelUID);
230 if (!ThingStatus.ONLINE.equals(getThing().getStatusInfo().getStatus())) {
231 logger.debug("Cannot handle command. Bridge is not online.");
234 if (command instanceof RefreshType) {
235 updateFromControllerInfo();
237 switch (channelUID.getId()) {
239 case CHANNEL_COLOR_TEMPERATURE:
240 case CHANNEL_COLOR_TEMPERATURE_ABS:
241 sendStateCommand(channelUID.getId(), command);
244 sendEffectCommand(command);
246 case CHANNEL_RHYTHM_MODE:
247 sendRhythmCommand(command);
250 logger.warn("Channel with id {} not handled", channelUID.getId());
254 } catch (NanoleafUnauthorizedException nue) {
255 logger.debug("Authorization for command {} to channelUID {} failed: {}", command, channelUID,
257 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
258 "@text/error.nanoleaf.controller.invalidToken");
259 } catch (NanoleafException ne) {
260 logger.debug("Handling command {} to channelUID {} failed: {}", command, channelUID, ne.getMessage());
261 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
262 "@text/error.nanoleaf.controller.communication");
268 public void handleRemoval() {
269 scheduler.execute(() -> {
271 Request deleteTokenRequest = OpenAPIUtils.requestBuilder(httpClient, getControllerConfig(),
272 API_DELETE_USER, HttpMethod.DELETE);
273 ContentResponse deleteTokenResponse = OpenAPIUtils.sendOpenAPIRequest(deleteTokenRequest);
274 if (deleteTokenResponse.getStatus() != HttpStatus.NO_CONTENT_204) {
275 logger.warn("Failed to delete token for openHAB. Response code is {}",
276 deleteTokenResponse.getStatus());
279 logger.debug("Successfully deleted token for openHAB from controller");
280 } catch (NanoleafUnauthorizedException e) {
281 logger.warn("Attempt to delete token for openHAB failed. Token unauthorized.");
282 } catch (NanoleafException ne) {
283 logger.warn("Attempt to delete token for openHAB failed : {}", ne.getMessage());
286 super.handleRemoval();
287 logger.debug("Nanoleaf controller removed");
292 public void dispose() {
294 HttpClient localHttpClientSSETouchEvent = this.httpClientSSETouchEvent;
295 if (localHttpClientSSETouchEvent != null) {
297 localHttpClientSSETouchEvent.stop();
298 } catch (Exception e) {
300 this.httpClientSSETouchEvent = null;
303 logger.debug("Disposing handler for Nanoleaf controller {}", getThing().getUID());
307 public Collection<Class<? extends ThingHandlerService>> getServices() {
308 return List.of(NanoleafPanelsDiscoveryService.class, NanoleafCommandDescriptionProvider.class);
311 public boolean registerControllerListener(NanoleafControllerListener controllerListener) {
312 logger.debug("Register new listener for controller {}", getThing().getUID());
313 return controllerListeners.add(controllerListener);
316 public boolean unregisterControllerListener(NanoleafControllerListener controllerListener) {
317 logger.debug("Unregister listener for controller {}", getThing().getUID());
318 return controllerListeners.remove(controllerListener);
321 public NanoleafControllerConfig getControllerConfig() {
322 NanoleafControllerConfig config = new NanoleafControllerConfig();
323 config.address = Objects.requireNonNullElse(getAddress(), "");
324 config.port = getPort();
325 config.refreshInterval = getRefreshInterval();
326 config.authToken = getAuthToken();
327 config.deviceType = Objects.requireNonNullElse(getDeviceType(), "");
331 public String getLayout() {
332 String layoutView = "";
333 if (controllerInfo != null) {
334 PanelLayout panelLayout = controllerInfo.getPanelLayout();
335 Layout layout = panelLayout.getLayout();
336 layoutView = layout != null ? layout.getLayoutView() : "";
342 public synchronized void startPairingJob() {
343 if (pairingJob == null || pairingJob.isCancelled()) {
344 logger.debug("Start pairing job, interval={} sec", PAIRING_INTERVAL);
345 pairingJob = scheduler.scheduleWithFixedDelay(this::runPairing, 0L, PAIRING_INTERVAL, TimeUnit.SECONDS);
349 private synchronized void stopPairingJob() {
350 logger.debug("Stop pairing job {}", pairingJob != null ? pairingJob.isCancelled() : "pairing job = null");
351 if (pairingJob != null && !pairingJob.isCancelled()) {
352 pairingJob.cancel(true);
354 logger.debug("Stopped pairing job");
358 private synchronized void startUpdateJob() {
359 final String localAuthToken = getAuthToken();
360 if (localAuthToken != null && !localAuthToken.isEmpty()) {
361 if (updateJob == null || updateJob.isCancelled()) {
362 logger.debug("Start controller status job, repeat every {} sec", getRefreshInterval());
363 updateJob = scheduler.scheduleWithFixedDelay(this::runUpdate, 0L, getRefreshInterval(),
367 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_PENDING,
368 "@text/error.nanoleaf.controller.noToken");
372 private synchronized void stopUpdateJob() {
373 logger.debug("Stop update job {}", updateJob != null ? updateJob.isCancelled() : "update job = null");
374 if (updateJob != null && !updateJob.isCancelled()) {
375 updateJob.cancel(true);
377 logger.debug("Stopped status job");
381 private synchronized void startTouchJob() {
382 NanoleafControllerConfig config = getConfigAs(NanoleafControllerConfig.class);
383 if (!config.deviceType.equals(DEVICE_TYPE_TOUCHSUPPORT)) {
385 "NOT starting TouchJob for Controller {} because it has wrong device type '{}' vs required '{}'",
386 this.getThing().getUID(), config.deviceType, DEVICE_TYPE_TOUCHSUPPORT);
388 logger.debug("Starting TouchJob for Controller {}", getThing().getUID());
389 final String localAuthToken = getAuthToken();
390 if (localAuthToken != null && !localAuthToken.isEmpty()) {
391 if (touchJob != null && !touchJob.isDone()) {
392 logger.trace("tj: tj={} already running touchJobRunning = {} cancelled={} done={}", touchJob,
393 touchJobRunning, touchJob == null ? null : touchJob.isCancelled(),
394 touchJob == null ? null : touchJob.isDone());
396 logger.debug("tj: Starting NEW touch job : tj={} touchJobRunning={} cancelled={} done={}",
397 touchJob, touchJobRunning, touchJob == null ? null : touchJob.isCancelled(),
398 touchJob == null ? null : touchJob.isDone());
399 touchJob = scheduler.scheduleWithFixedDelay(this::runTouchDetection, 0L, 1L, TimeUnit.SECONDS);
402 logger.error("starting TouchJob for Controller {} failed - missing token", getThing().getUID());
408 private synchronized void stopTouchJob() {
409 logger.debug("Stop touch job {}", touchJob != null ? touchJob.isCancelled() : "touchJob job = null");
410 if (touchJob != null) {
411 logger.trace("tj: touch job stopping for {} with client {}", thing.getUID(), httpClientSSETouchEvent);
413 final Request localSSERequest = sseTouchjobRequest;
414 if (localSSERequest != null) {
415 localSSERequest.abort(new NanoleafException("Touch detection stopped"));
417 if (!touchJob.isCancelled()) {
418 touchJob.cancel(true);
422 touchJobRunning = false;
423 logger.debug("tj: touch job stopped for {} with client {}", thing.getUID(), httpClientSSETouchEvent);
427 private boolean hasTouchSupport(@Nullable String deviceType) {
428 return NanoleafBindingConstants.MODELS_WITH_TOUCHSUPPORT.contains(deviceType);
431 private void runUpdate() {
432 logger.debug("Run update job");
435 updateFromControllerInfo();
437 updateStatus(ThingStatus.ONLINE);
438 } catch (NanoleafUnauthorizedException nae) {
439 logger.debug("Status update unauthorized for controller {}: {}", getThing().getUID(), nae.getMessage());
440 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
441 "@text/error.nanoleaf.controller.invalidToken");
442 final String localAuthToken = getAuthToken();
443 if (localAuthToken == null || localAuthToken.isEmpty()) {
444 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_PENDING,
445 "@text/error.nanoleaf.controller.noToken");
447 } catch (NanoleafException ne) {
448 logger.debug("Status update failed for controller {} : {}", getThing().getUID(), ne.getMessage());
449 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
450 "@text/error.nanoleaf.controller.communication");
451 } catch (RuntimeException e) {
452 logger.debug("Update job failed", e);
453 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, "@text/error.nanoleaf.controller.runtime");
457 private void runPairing() {
458 logger.debug("Run pairing job");
461 final String localAuthToken = getAuthToken();
462 if (localAuthToken != null && !localAuthToken.isEmpty()) {
463 if (pairingJob != null) {
464 pairingJob.cancel(false);
467 logger.debug("Authentication token found. Canceling pairing job");
471 ContentResponse authTokenResponse = OpenAPIUtils
472 .requestBuilder(httpClient, getControllerConfig(), API_ADD_USER, HttpMethod.POST)
473 .timeout(20L, TimeUnit.SECONDS).send();
474 String authTokenResponseString = (authTokenResponse != null) ? authTokenResponse.getContentAsString() : "";
475 if (logger.isTraceEnabled()) {
476 logger.trace("Auth token response: {}", authTokenResponseString);
479 if (authTokenResponse != null && authTokenResponse.getStatus() != HttpStatus.OK_200) {
480 logger.debug("Pairing pending for {}. Controller returns status code {}", getThing().getUID(),
481 authTokenResponse.getStatus());
483 AuthToken authTokenObject = gson.fromJson(authTokenResponseString, AuthToken.class);
484 authTokenObject = (authTokenObject != null) ? authTokenObject : new AuthToken();
485 if (authTokenObject.getAuthToken().isEmpty()) {
486 logger.debug("No auth token found in response: {}", authTokenResponseString);
487 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
488 "@text/error.nanoleaf.controller.pairingFailed");
489 throw new NanoleafException(authTokenResponseString);
492 logger.debug("Pairing succeeded.");
493 Configuration config = editConfiguration();
495 config.put(NanoleafControllerConfig.AUTH_TOKEN, authTokenObject.getAuthToken());
496 updateConfiguration(config);
497 updateStatus(ThingStatus.ONLINE);
498 // Update local field
499 setAuthToken(authTokenObject.getAuthToken());
505 } catch (JsonSyntaxException e) {
506 logger.warn("Received invalid data", e);
507 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
508 "@text/error.nanoleaf.controller.invalidData");
509 } catch (NanoleafException ne) {
510 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
511 "@text/error.nanoleaf.controller.noTokenReceived");
512 } catch (ExecutionException | TimeoutException | InterruptedException e) {
513 logger.debug("Cannot send authorization request to controller: ", e);
514 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
515 "@text/error.nanoleaf.controller.authRequest");
516 } catch (RuntimeException e) {
517 logger.warn("Pairing job failed", e);
518 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, "@text/error.nanoleaf.controller.runtime");
519 } catch (Exception e) {
520 logger.warn("Cannot start http client", e);
521 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
522 "@text/error.nanoleaf.controller.noClient");
526 private synchronized void runTouchDetection() {
527 final HttpClient localhttpSSEClientTouchEvent = httpClientSSETouchEvent;
528 int eventHashcode = -1;
529 if (localhttpSSEClientTouchEvent != null) {
530 eventHashcode = localhttpSSEClientTouchEvent.hashCode();
532 if (touchJobRunning) {
533 logger.trace("tj: touch job {} touch job already running. quitting. {} controller {} with {}\",\n",
534 touchJob, eventHashcode, thing.getUID(), httpClientSSETouchEvent);
537 URI eventUri = OpenAPIUtils.getUri(getControllerConfig(), API_EVENTS, "id=4");
538 logger.debug("tj: touch job request registering for {} with client {}", thing.getUID(),
539 httpClientSSETouchEvent);
540 touchJobRunning = true;
541 if (localhttpSSEClientTouchEvent != null) {
542 localhttpSSEClientTouchEvent.setIdleTimeout(CONNECT_TIMEOUT * 1000L);
543 sseTouchjobRequest = localhttpSSEClientTouchEvent.newRequest(eventUri);
544 final Request localSSETouchjobRequest = sseTouchjobRequest;
545 if (localSSETouchjobRequest != null) {
546 int requestHashCode = localSSETouchjobRequest.hashCode();
548 logger.debug("tj: triggering new touch job request {} for {} with client {}", requestHashCode,
549 thing.getUID(), eventHashcode);
550 localSSETouchjobRequest.onResponseContent((response, content) -> {
551 String s = StandardCharsets.UTF_8.decode(content).toString();
552 logger.debug("touch detected for controller {}", thing.getUID());
553 logger.trace("content {}", s);
554 try (Scanner eventContent = new Scanner(s)) {
555 while (eventContent.hasNextLine()) {
556 String line = eventContent.nextLine().trim();
557 if (line.startsWith("data:")) {
558 String json = line.substring(5).trim();
561 TouchEvents touchEvents = gson.fromJson(json, TouchEvents.class);
562 handleTouchEvents(Objects.requireNonNull(touchEvents));
563 } catch (JsonSyntaxException e) {
564 logger.error("Couldn't parse touch event json {}", json);
569 logger.debug("leaving touch onContent");
570 }).onResponseSuccess((response) -> {
571 logger.trace("tj: r={} touch event SUCCESS: {}", response.getRequest(), response);
572 }).onResponseFailure((response, failure) -> {
573 logger.trace("tj: r={} touch event FAILURE. Touchjob not running anymore for controller {}",
574 response.getRequest(), thing.getUID());
575 }).send((result) -> {
577 "tj: r={} touch event COMPLETE. Touchjob not running anymore for controller {} failed: {} succeeded: {}",
578 result.getRequest(), thing.getUID(), result.isFailed(), result.isSucceeded());
579 touchJobRunning = false;
583 logger.trace("tj: started touch job request for {} with {} at {}", thing.getUID(),
584 httpClientSSETouchEvent, eventUri);
585 } catch (NanoleafException | RuntimeException e) {
586 logger.warn("tj: setting up TouchDetection failed for controller {} with {}\",\n", thing.getUID(),
587 httpClientSSETouchEvent);
588 logger.warn("tj: setting up TouchDetection failed with exception", e);
590 logger.trace("tj: touch job {} started for new request {} controller {} with {}\",\n",
591 touchJob.hashCode(), eventHashcode, thing.getUID(), httpClientSSETouchEvent);
597 private void handleTouchEvents(TouchEvents touchEvents) {
598 touchEvents.getEvents().forEach((event) -> {
599 logger.debug("panel: {} gesture id: {}", event.getPanelId(), event.getGesture());
600 // Swipes go to the controller, taps go to the individual panel
601 if (event.getPanelId().equals(CONTROLLER_PANEL_ID)) {
602 logger.debug("Triggering controller {} with gesture {}.", thing.getUID(), event.getGesture());
603 updateControllerGesture(event.getGesture());
605 getThing().getThings().forEach((child) -> {
606 NanoleafPanelHandler panelHandler = (NanoleafPanelHandler) child.getHandler();
607 if (panelHandler != null) {
608 logger.trace("Checking available panel -{}- versus event panel -{}-", panelHandler.getPanelID(),
610 if (panelHandler.getPanelID().equals(Integer.valueOf(event.getPanelId()))) {
611 logger.debug("Panel {} found. Triggering item with gesture {}.", panelHandler.getPanelID(),
613 panelHandler.updatePanelGesture(event.getGesture());
623 * Apply the swipe gesture to the controller
625 * @param gesture Only swipes are supported on the complete nanoleaf panels
627 private void updateControllerGesture(int gesture) {
630 triggerChannel(CHANNEL_SWIPE, CHANNEL_SWIPE_EVENT_UP);
633 triggerChannel(CHANNEL_SWIPE, CHANNEL_SWIPE_EVENT_DOWN);
636 triggerChannel(CHANNEL_SWIPE, CHANNEL_SWIPE_EVENT_LEFT);
639 triggerChannel(CHANNEL_SWIPE, CHANNEL_SWIPE_EVENT_RIGHT);
644 private void updateFromControllerInfo() throws NanoleafException {
645 logger.debug("Update channels for controller {}", thing.getUID());
646 controllerInfo = receiveControllerInfo();
647 State state = controllerInfo.getState();
649 OnOffType powerState = state.getOnOff();
651 Ct colorTemperature = state.getColorTemperature();
653 float colorTempPercent = 0.0F;
656 if (colorTemperature != null) {
657 updateState(CHANNEL_COLOR_TEMPERATURE_ABS, new QuantityType(colorTemperature.getValue(), Units.KELVIN));
658 Integer min = colorTemperature.getMin();
659 hue = min == null ? 0 : min;
660 Integer max = colorTemperature.getMax();
661 saturation = max == null ? 0 : max;
662 colorTempPercent = (colorTemperature.getValue() - hue) / (saturation - hue)
663 * PercentType.HUNDRED.intValue();
666 updateState(CHANNEL_COLOR_TEMPERATURE, new PercentType(Float.toString(colorTempPercent)));
667 updateState(CHANNEL_EFFECT, new StringType(controllerInfo.getEffects().getSelect()));
668 Hue stateHue = state.getHue();
669 hue = stateHue != null ? stateHue.getValue() : 0;
671 Sat stateSaturation = state.getSaturation();
672 saturation = stateSaturation != null ? stateSaturation.getValue() : 0;
674 Brightness stateBrightness = state.getBrightness();
675 int brightness = stateBrightness != null ? stateBrightness.getValue() : 0;
676 HSBType stateColor = new HSBType(new DecimalType(hue), new PercentType(saturation),
677 new PercentType(powerState == OnOffType.ON ? brightness : 0));
679 updateState(CHANNEL_COLOR, stateColor);
680 updateState(CHANNEL_COLOR_MODE, new StringType(state.getColorMode()));
681 updateState(CHANNEL_RHYTHM_ACTIVE, controllerInfo.getRhythm().getRhythmActive() ? OnOffType.ON : OnOffType.OFF);
682 updateState(CHANNEL_RHYTHM_MODE, new DecimalType(controllerInfo.getRhythm().getRhythmMode()));
683 updateState(CHANNEL_RHYTHM_STATE,
684 controllerInfo.getRhythm().getRhythmConnected() ? OnOffType.ON : OnOffType.OFF);
687 if (EFFECT_NAME_SOLID_COLOR.equals(controllerInfo.getEffects().getSelect())) {
688 setSolidColor(stateColor);
691 updateConfiguration();
692 updateLayout(controllerInfo.getPanelLayout());
693 updateVisualState(controllerInfo.getPanelLayout(), powerState);
695 for (NanoleafControllerListener controllerListener : controllerListeners) {
696 controllerListener.onControllerInfoFetched(getThing().getUID(), controllerInfo);
700 private void setSolidColor(HSBType color) {
701 // If the panels are set to solid color, they are read from the state
702 PanelLayout panelLayout = controllerInfo.getPanelLayout();
703 Layout layout = panelLayout.getLayout();
705 if (layout != null) {
706 List<PositionDatum> positionData = layout.getPositionData();
707 if (positionData != null) {
708 List<Integer> allPanelIds = new ArrayList<>(positionData.size());
709 for (PositionDatum pd : positionData) {
710 allPanelIds.add(pd.getPanelId());
713 panelColors.setMultiple(allPanelIds, color);
715 logger.debug("Missing position datum when setting solid color for {}", getThing().getUID());
718 logger.debug("Missing layout when setting solid color for {}", getThing().getUID());
722 private void updateConfiguration() {
723 // only update the Thing config if value isn't set yet
724 if (getConfig().get(NanoleafControllerConfig.DEVICE_TYPE) == null) {
725 Configuration config = editConfiguration();
726 if (hasTouchSupport(controllerInfo.getModel())) {
727 config.put(NanoleafControllerConfig.DEVICE_TYPE, DEVICE_TYPE_TOUCHSUPPORT);
728 logger.debug("Set to device type {}", DEVICE_TYPE_TOUCHSUPPORT);
730 config.put(NanoleafControllerConfig.DEVICE_TYPE, DEVICE_TYPE_LIGHTPANELS);
731 logger.debug("Set to device type {}", DEVICE_TYPE_LIGHTPANELS);
733 updateConfiguration(config);
734 if (logger.isTraceEnabled()) {
735 getConfig().getProperties().forEach((key, value) -> {
736 logger.trace("Configuration property: key {} value {}", key, value);
742 private void updateProperties() {
743 // update bridge properties which may have changed, or are not present during discovery
744 Map<String, String> properties = editProperties();
745 properties.put(Thing.PROPERTY_SERIAL_NUMBER, controllerInfo.getSerialNo());
746 properties.put(Thing.PROPERTY_FIRMWARE_VERSION, controllerInfo.getFirmwareVersion());
747 properties.put(Thing.PROPERTY_MODEL_ID, controllerInfo.getModel());
748 properties.put(Thing.PROPERTY_VENDOR, controllerInfo.getManufacturer());
749 updateProperties(properties);
750 if (logger.isTraceEnabled()) {
751 getThing().getProperties().forEach((key, value) -> {
752 logger.trace("Thing property: key {} value {}", key, value);
757 private void updateVisualState(PanelLayout panelLayout, OnOffType powerState) {
758 ChannelUID stateChannel = new ChannelUID(getThing().getUID(), CHANNEL_VISUAL_STATE);
761 PanelState panelState;
762 if (OnOffType.OFF.equals(powerState)) {
763 // If powered off: show all panels as black
764 panelState = new ConstantPanelState(HSBType.BLACK);
766 // Static color for panels, use it
767 panelState = new LivePanelState(panelColors);
770 LayoutSettings settings = new LayoutSettings(false, true, true, true);
771 byte[] bytes = NanoleafLayout.render(panelLayout, panelState, settings);
772 if (bytes.length > 0) {
773 updateState(stateChannel, new RawType(bytes, "image/png"));
774 logger.trace("Rendered visual state of panel {} in updateState has {} bytes", getThing().getUID(),
777 logger.debug("Visual state of {} failed to produce any image", getThing().getUID());
779 } catch (IOException ioex) {
780 logger.warn("Failed to create state image", ioex);
784 private void updateLayout(PanelLayout panelLayout) {
785 ChannelUID layoutChannel = new ChannelUID(getThing().getUID(), CHANNEL_LAYOUT);
786 ThingHandlerCallback callback = getCallback();
787 if (callback != null) {
788 if (!callback.isChannelLinked(layoutChannel)) {
789 // Don't generate image unless it is used
794 if (layoutImage != null && previousPanelLayout.equals(panelLayout)) {
795 logger.trace("Not rendering panel layout for {} as it is the same as previous rendered panel layout",
796 getThing().getUID());
801 LayoutSettings settings = new LayoutSettings(true, false, true, false);
802 byte[] bytes = NanoleafLayout.render(panelLayout, new LivePanelState(panelColors), settings);
803 if (bytes.length > 0) {
804 updateState(layoutChannel, new RawType(bytes, "image/png"));
806 previousPanelLayout = panelLayout;
807 logger.trace("Rendered layout of panel {} in updateState has {} bytes", getThing().getUID(),
810 logger.debug("Layout of {} failed to produce any image", getThing().getUID());
813 } catch (IOException ioex) {
814 logger.warn("Failed to create layout image", ioex);
818 private ControllerInfo receiveControllerInfo() throws NanoleafException, NanoleafUnauthorizedException {
819 ContentResponse controllerlInfoJSON = OpenAPIUtils.sendOpenAPIRequest(OpenAPIUtils.requestBuilder(httpClient,
820 getControllerConfig(), API_GET_CONTROLLER_INFO, HttpMethod.GET));
821 ControllerInfo controllerInfo = gson.fromJson(controllerlInfoJSON.getContentAsString(), ControllerInfo.class);
822 return Objects.requireNonNull(controllerInfo);
825 private void sendStateCommand(String channel, Command command) throws NanoleafException {
826 State stateObject = new State();
829 if (command instanceof OnOffType) {
830 // On/Off command - turns controller on/off
831 BooleanState state = new On();
832 state.setValue(OnOffType.ON.equals(command));
833 stateObject.setState(state);
834 } else if (command instanceof HSBType) {
835 // regular color HSB command
836 IntegerState h = new Hue();
837 IntegerState s = new Sat();
838 IntegerState b = new Brightness();
839 h.setValue(((HSBType) command).getHue().intValue());
840 s.setValue(((HSBType) command).getSaturation().intValue());
841 b.setValue(((HSBType) command).getBrightness().intValue());
842 setSolidColor((HSBType) command);
843 stateObject.setState(h);
844 stateObject.setState(s);
845 stateObject.setState(b);
846 } else if (command instanceof PercentType) {
847 // brightness command
848 IntegerState b = new Brightness();
849 b.setValue(((PercentType) command).intValue());
850 stateObject.setState(b);
851 } else if (command instanceof IncreaseDecreaseType) {
852 // increase/decrease brightness
853 if (controllerInfo != null) {
855 Brightness brightness = controllerInfo.getState().getBrightness();
858 if (brightness != null) {
860 Integer min = brightness.getMin();
861 brightnessMin = (min == null) ? 0 : min;
863 Integer max = brightness.getMax();
864 brightnessMax = (max == null) ? 0 : max;
866 if (IncreaseDecreaseType.INCREASE.equals(command)) {
868 Math.min(brightnessMax, brightness.getValue() + BRIGHTNESS_STEP_SIZE));
871 Math.max(brightnessMin, brightness.getValue() - BRIGHTNESS_STEP_SIZE));
873 stateObject.setState(brightness);
874 logger.debug("Setting controller brightness to {}", brightness.getValue());
875 // update controller info in case new command is sent before next update job interval
876 controllerInfo.getState().setBrightness(brightness);
878 logger.debug("Couldn't set brightness as it was null!");
882 logger.warn("Unhandled command {} with command type: {}", command, command.getClass().getName());
886 case CHANNEL_COLOR_TEMPERATURE:
887 if (command instanceof PercentType) {
888 // Color temperature (percent)
889 IntegerState state = new Ct();
891 Ct colorTemperature = controllerInfo.getState().getColorTemperature();
895 if (colorTemperature != null) {
897 Integer min = colorTemperature.getMin();
898 colorMin = (min == null) ? 0 : min;
901 Integer max = colorTemperature.getMax();
902 colorMax = (max == null) ? 0 : max;
905 state.setValue(Math.round((colorMax - colorMin) * (100 - ((PercentType) command).intValue())
906 / PercentType.HUNDRED.floatValue() + colorMin));
907 stateObject.setState(state);
909 logger.warn("Unhandled command type: {}", command.getClass().getName());
913 case CHANNEL_COLOR_TEMPERATURE_ABS:
914 // Color temperature (absolute)
915 IntegerState state = new Ct();
916 if (command instanceof DecimalType) {
917 state.setValue(((DecimalType) command).intValue());
918 } else if (command instanceof QuantityType) {
919 QuantityType<?> tempKelvin = ((QuantityType) command).toInvertibleUnit(Units.KELVIN);
920 if (tempKelvin == null) {
921 logger.warn("Cannot convert color temperature {} to Kelvin.", command);
924 state.setValue(tempKelvin.intValue());
926 logger.warn("Unhandled command type: {}", command.getClass().getName());
930 stateObject.setState(state);
933 logger.warn("Unhandled command type: {}", command.getClass().getName());
937 Request setNewStateRequest = OpenAPIUtils.requestBuilder(httpClient, getControllerConfig(), API_SET_VALUE,
939 setNewStateRequest.content(new StringContentProvider(gson.toJson(stateObject)), "application/json");
940 OpenAPIUtils.sendOpenAPIRequest(setNewStateRequest);
943 private void sendEffectCommand(Command command) throws NanoleafException {
944 Effects effects = new Effects();
945 if (command instanceof StringType) {
946 effects.setSelect(command.toString());
947 Request setNewEffectRequest = OpenAPIUtils.requestBuilder(httpClient, getControllerConfig(), API_EFFECT,
949 String content = gson.toJson(effects);
950 logger.debug("sending effect command from controller {}: {}", getThing().getUID(), content);
951 setNewEffectRequest.content(new StringContentProvider(content), "application/json");
952 OpenAPIUtils.sendOpenAPIRequest(setNewEffectRequest);
954 logger.warn("Unhandled command type: {}", command.getClass().getName());
958 private void sendRhythmCommand(Command command) throws NanoleafException {
959 Rhythm rhythm = new Rhythm();
960 if (command instanceof DecimalType) {
961 rhythm.setRhythmMode(((DecimalType) command).intValue());
962 Request setNewRhythmRequest = OpenAPIUtils.requestBuilder(httpClient, getControllerConfig(),
963 API_RHYTHM_MODE, HttpMethod.PUT);
964 setNewRhythmRequest.content(new StringContentProvider(gson.toJson(rhythm)), "application/json");
965 OpenAPIUtils.sendOpenAPIRequest(setNewRhythmRequest);
967 logger.warn("Unhandled command type: {}", command.getClass().getName());
971 private boolean hasStaticEffect() {
972 return EFFECT_NAME_STATIC_COLOR.equals(controllerInfo.getEffects().getSelect())
973 || EFFECT_NAME_SOLID_COLOR.equals(controllerInfo.getEffects().getSelect());
977 * Checks if we are in a mode where color changes should be rendered.
979 * @return True if a color change on a panel should be rendered
981 private boolean showsUpdatedColors() {
982 if (!hasStaticEffect()) {
983 logger.trace("Not updating colors as the device doesnt have a static/solid effect");
987 State state = controllerInfo.getState();
988 OnOffType powerState = state.getOnOff();
989 return OnOffType.ON.equals(powerState);
993 public void onPanelChangedColor() {
994 if (updateVisualLayout && showsUpdatedColors()) {
995 // Update the visual state if a panel has changed color
996 updateVisualState(controllerInfo.getPanelLayout(), controllerInfo.getState().getOnOff());
998 logger.trace("Not updating colors. Update visual layout: {}", updateVisualLayout);
1003 * For individual panels to get access to the panel colors.
1005 * @return Information about colors of panels.
1007 public NanoleafPanelColors getColorInformation() {
1011 private void updatePanelColors() {
1012 // get panel color data from controller
1014 Effects effects = new Effects();
1015 Write write = new Write();
1016 write.setCommand("request");
1017 write.setAnimName(EFFECT_NAME_STATIC_COLOR);
1018 effects.setWrite(write);
1020 NanoleafControllerConfig config = getControllerConfig();
1021 logger.debug("Sending Request from Panel for getColor()");
1022 Request setPanelUpdateRequest = OpenAPIUtils.requestBuilder(httpClient, config, API_EFFECT, HttpMethod.PUT);
1023 setPanelUpdateRequest.content(new StringContentProvider(gson.toJson(effects)), "application/json");
1024 ContentResponse panelData = OpenAPIUtils.sendOpenAPIRequest(setPanelUpdateRequest);
1026 parsePanelData(config, panelData);
1028 } catch (NanoleafNotFoundException nfe) {
1029 logger.debug("Panel data could not be retrieved as no data was returned (static type missing?) : {}",
1031 } catch (NanoleafBadRequestException nfe) {
1033 "Panel data could not be retrieved as request not expected(static type missing / dynamic type on) : {}",
1035 } catch (NanoleafException nue) {
1036 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
1037 "@text/error.nanoleaf.panel.communication");
1038 logger.debug("Panel data could not be retrieved: {}", nue.getMessage());
1042 void parsePanelData(NanoleafControllerConfig config, ContentResponse panelData) {
1043 // panelData is in format (numPanels, (PanelId, 1, R, G, B, W, TransitionTime) * numPanel)
1045 Write response = null;
1047 String panelDataContent = panelData.getContentAsString();
1049 response = gson.fromJson(panelDataContent, Write.class);
1050 } catch (JsonSyntaxException jse) {
1051 logger.warn("Unable to parse panel data information from Nanoleaf", jse);
1052 logger.trace("Panel Data which couldn't be parsed: {}", panelDataContent);
1055 if (response != null) {
1057 updateVisualLayout = false;
1058 String[] tokenizedData = response.getAnimData().split(" ");
1059 if (config.deviceType.equals(CONFIG_DEVICE_TYPE_LIGHTPANELS)
1060 || config.deviceType.equals(CONFIG_DEVICE_TYPE_CANVAS)) {
1061 // panelData is in format (numPanels (PanelId 1 R G B W TransitionTime) * numPanel)
1062 String[] panelDataPoints = Arrays.copyOfRange(tokenizedData, 1, tokenizedData.length);
1063 for (int i = 0; i < panelDataPoints.length; i++) {
1065 // found panel data - store it
1066 panelColors.setPanelColor(Integer.valueOf(panelDataPoints[i]),
1067 HSBType.fromRGB(Integer.parseInt(panelDataPoints[i + 2]),
1068 Integer.parseInt(panelDataPoints[i + 3]),
1069 Integer.parseInt(panelDataPoints[i + 4])));
1073 // panelData is in format (0 numPanels (quotient(panelID) remainder(panelID) R G B W 0
1074 // quotient(TransitionTime) remainder(TransitionTime)) * numPanel)
1075 String[] panelDataPoints = Arrays.copyOfRange(tokenizedData, 2, tokenizedData.length);
1076 for (int i = 0; i < panelDataPoints.length; i++) {
1078 Integer idQuotient = Integer.valueOf(panelDataPoints[i]);
1079 Integer idRemainder = Integer.valueOf(panelDataPoints[i + 1]);
1080 Integer idNum = idQuotient * 256 + idRemainder;
1081 // found panel data - store it
1082 panelColors.setPanelColor(idNum,
1083 HSBType.fromRGB(Integer.parseInt(panelDataPoints[i + 3]),
1084 Integer.parseInt(panelDataPoints[i + 4]),
1085 Integer.parseInt(panelDataPoints[i + 5])));
1090 updateVisualLayout = true;
1091 onPanelChangedColor();
1096 private @Nullable String getAddress() {
1100 private void setAddress(String address) {
1101 this.address = address;
1104 private int getPort() {
1108 private void setPort(int port) {
1112 private int getRefreshInterval() {
1113 return refreshIntervall;
1116 private void setRefreshIntervall(int refreshIntervall) {
1117 this.refreshIntervall = refreshIntervall;
1121 private String getAuthToken() {
1125 private void setAuthToken(@Nullable String authToken) {
1126 this.authToken = authToken;
1130 private String getDeviceType() {
1134 private void setDeviceType(String deviceType) {
1135 this.deviceType = deviceType;
1138 private void stopAllJobs() {