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.*;
18 import java.nio.charset.StandardCharsets;
19 import java.util.Collection;
20 import java.util.List;
22 import java.util.Objects;
23 import java.util.Scanner;
24 import java.util.concurrent.CopyOnWriteArrayList;
25 import java.util.concurrent.ExecutionException;
26 import java.util.concurrent.ScheduledFuture;
27 import java.util.concurrent.TimeUnit;
28 import java.util.concurrent.TimeoutException;
30 import org.eclipse.jdt.annotation.NonNullByDefault;
31 import org.eclipse.jdt.annotation.Nullable;
32 import org.eclipse.jetty.client.HttpClient;
33 import org.eclipse.jetty.client.api.ContentResponse;
34 import org.eclipse.jetty.client.api.Request;
35 import org.eclipse.jetty.client.util.StringContentProvider;
36 import org.eclipse.jetty.http.HttpMethod;
37 import org.eclipse.jetty.http.HttpStatus;
38 import org.openhab.binding.nanoleaf.internal.NanoleafBindingConstants;
39 import org.openhab.binding.nanoleaf.internal.NanoleafControllerListener;
40 import org.openhab.binding.nanoleaf.internal.NanoleafException;
41 import org.openhab.binding.nanoleaf.internal.NanoleafUnauthorizedException;
42 import org.openhab.binding.nanoleaf.internal.OpenAPIUtils;
43 import org.openhab.binding.nanoleaf.internal.commanddescription.NanoleafCommandDescriptionProvider;
44 import org.openhab.binding.nanoleaf.internal.config.NanoleafControllerConfig;
45 import org.openhab.binding.nanoleaf.internal.discovery.NanoleafPanelsDiscoveryService;
46 import org.openhab.binding.nanoleaf.internal.model.AuthToken;
47 import org.openhab.binding.nanoleaf.internal.model.BooleanState;
48 import org.openhab.binding.nanoleaf.internal.model.Brightness;
49 import org.openhab.binding.nanoleaf.internal.model.ControllerInfo;
50 import org.openhab.binding.nanoleaf.internal.model.Ct;
51 import org.openhab.binding.nanoleaf.internal.model.Effects;
52 import org.openhab.binding.nanoleaf.internal.model.Hue;
53 import org.openhab.binding.nanoleaf.internal.model.IntegerState;
54 import org.openhab.binding.nanoleaf.internal.model.Layout;
55 import org.openhab.binding.nanoleaf.internal.model.On;
56 import org.openhab.binding.nanoleaf.internal.model.PanelLayout;
57 import org.openhab.binding.nanoleaf.internal.model.Rhythm;
58 import org.openhab.binding.nanoleaf.internal.model.Sat;
59 import org.openhab.binding.nanoleaf.internal.model.State;
60 import org.openhab.binding.nanoleaf.internal.model.TouchEvents;
61 import org.openhab.core.config.core.Configuration;
62 import org.openhab.core.io.net.http.HttpClientFactory;
63 import org.openhab.core.library.types.DecimalType;
64 import org.openhab.core.library.types.HSBType;
65 import org.openhab.core.library.types.IncreaseDecreaseType;
66 import org.openhab.core.library.types.OnOffType;
67 import org.openhab.core.library.types.PercentType;
68 import org.openhab.core.library.types.StringType;
69 import org.openhab.core.thing.Bridge;
70 import org.openhab.core.thing.ChannelUID;
71 import org.openhab.core.thing.Thing;
72 import org.openhab.core.thing.ThingStatus;
73 import org.openhab.core.thing.ThingStatusDetail;
74 import org.openhab.core.thing.binding.BaseBridgeHandler;
75 import org.openhab.core.thing.binding.ThingHandlerService;
76 import org.openhab.core.types.Command;
77 import org.openhab.core.types.RefreshType;
78 import org.slf4j.Logger;
79 import org.slf4j.LoggerFactory;
81 import com.google.gson.Gson;
82 import com.google.gson.JsonSyntaxException;
85 * The {@link NanoleafControllerHandler} is responsible for handling commands to the controller which
86 * affect all panels connected to it (e.g. selected effect)
88 * @author Martin Raepple - Initial contribution
89 * @author Stefan Höhn - Canvas Touch Support
90 * @author Kai Kreuzer - refactoring, bug fixing and code clean up
93 public class NanoleafControllerHandler extends BaseBridgeHandler {
95 // Pairing interval in seconds
96 private static final int PAIRING_INTERVAL = 10;
97 private static final int CONNECT_TIMEOUT = 10;
99 private final Logger logger = LoggerFactory.getLogger(NanoleafControllerHandler.class);
100 private HttpClientFactory httpClientFactory;
101 private HttpClient httpClient;
103 private @Nullable HttpClient httpClientSSETouchEvent;
104 private @Nullable Request sseTouchjobRequest;
105 private List<NanoleafControllerListener> controllerListeners = new CopyOnWriteArrayList<NanoleafControllerListener>();
107 private @NonNullByDefault({}) ScheduledFuture<?> pairingJob;
108 private @NonNullByDefault({}) ScheduledFuture<?> updateJob;
109 private @NonNullByDefault({}) ScheduledFuture<?> touchJob;
110 private final Gson gson = new Gson();
112 private @Nullable String address;
114 private int refreshIntervall;
115 private @Nullable String authToken;
116 private @Nullable String deviceType;
117 private @NonNullByDefault({}) ControllerInfo controllerInfo;
119 private boolean touchJobRunning = false;
121 public NanoleafControllerHandler(Bridge bridge, HttpClientFactory httpClientFactory) {
123 this.httpClientFactory = httpClientFactory;
124 this.httpClient = httpClientFactory.getCommonHttpClient();
127 private void initializeTouchHttpClient() {
128 String httpClientName = thing.getUID().getId();
131 httpClientSSETouchEvent = httpClientFactory.createHttpClient(httpClientName);
132 final HttpClient localHttpClientSSETouchEvent = this.httpClientSSETouchEvent;
133 if (localHttpClientSSETouchEvent != null) {
134 localHttpClientSSETouchEvent.setConnectTimeout(CONNECT_TIMEOUT * 1000L);
135 localHttpClientSSETouchEvent.start();
137 } catch (Exception e) {
139 "Long running HttpClient for Nanoleaf controller handler {} cannot be started. Creating Handler failed.",
141 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
144 logger.debug("Using long SSE httpClient={} for {}}", httpClientSSETouchEvent, httpClientName);
148 public void initialize() {
149 logger.debug("Initializing the controller (bridge)");
150 updateStatus(ThingStatus.UNKNOWN);
151 NanoleafControllerConfig config = getConfigAs(NanoleafControllerConfig.class);
152 setAddress(config.address);
153 setPort(config.port);
154 setRefreshIntervall(config.refreshInterval);
155 String authToken = (config.authToken != null) ? config.authToken : "";
156 setAuthToken(authToken);
157 Map<String, String> properties = getThing().getProperties();
158 String propertyModelId = properties.get(Thing.PROPERTY_MODEL_ID);
159 if (hasTouchSupport(propertyModelId)) {
160 config.deviceType = DEVICE_TYPE_TOUCHSUPPORT;
161 initializeTouchHttpClient();
163 config.deviceType = DEVICE_TYPE_LIGHTPANELS;
166 setDeviceType(config.deviceType);
167 String propertyFirmwareVersion = properties.get(Thing.PROPERTY_FIRMWARE_VERSION);
170 if (!config.address.isEmpty() && !String.valueOf(config.port).isEmpty()) {
171 if (propertyFirmwareVersion != null && !propertyFirmwareVersion.isEmpty() && !OpenAPIUtils
172 .checkRequiredFirmware(properties.get(Thing.PROPERTY_MODEL_ID), propertyFirmwareVersion)) {
173 logger.warn("Nanoleaf controller firmware is too old: {}. Must be equal or higher than {}",
174 propertyFirmwareVersion, API_MIN_FW_VER_LIGHTPANELS);
175 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
176 "@text/error.nanoleaf.controller.incompatibleFirmware");
178 } else if (authToken != null && !authToken.isEmpty()) {
183 logger.debug("No token found. Start pairing background job");
184 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_PENDING,
185 "@text/error.nanoleaf.controller.noToken");
190 logger.warn("No IP address and port configured for the Nanoleaf controller");
191 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_PENDING,
192 "@text/error.nanoleaf.controller.noIp");
195 } catch (IllegalArgumentException iae) {
196 logger.warn("Nanoleaf controller firmware version not in format x.y.z: {}",
197 getThing().getProperties().get(Thing.PROPERTY_FIRMWARE_VERSION));
198 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
199 "@text/error.nanoleaf.controller.incompatibleFirmware");
204 public void handleCommand(ChannelUID channelUID, Command command) {
205 logger.debug("Received command {} for channel {}", command, channelUID);
206 if (!ThingStatus.ONLINE.equals(getThing().getStatusInfo().getStatus())) {
207 logger.debug("Cannot handle command. Bridge is not online.");
210 if (command instanceof RefreshType) {
211 updateFromControllerInfo();
213 switch (channelUID.getId()) {
215 case CHANNEL_COLOR_TEMPERATURE:
216 case CHANNEL_COLOR_TEMPERATURE_ABS:
217 sendStateCommand(channelUID.getId(), command);
220 sendEffectCommand(command);
222 case CHANNEL_RHYTHM_MODE:
223 sendRhythmCommand(command);
226 logger.warn("Channel with id {} not handled", channelUID.getId());
230 } catch (NanoleafUnauthorizedException nue) {
231 logger.debug("Authorization for command {} to channelUID {} failed: {}", command, channelUID,
233 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
234 "@text/error.nanoleaf.controller.invalidToken");
235 } catch (NanoleafException ne) {
236 logger.debug("Handling command {} to channelUID {} failed: {}", command, channelUID, ne.getMessage());
237 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
238 "@text/error.nanoleaf.controller.communication");
244 public void handleRemoval() {
245 scheduler.execute(() -> {
247 Request deleteTokenRequest = OpenAPIUtils.requestBuilder(httpClient, getControllerConfig(),
248 API_DELETE_USER, HttpMethod.DELETE);
249 ContentResponse deleteTokenResponse = OpenAPIUtils.sendOpenAPIRequest(deleteTokenRequest);
250 if (deleteTokenResponse.getStatus() != HttpStatus.NO_CONTENT_204) {
251 logger.warn("Failed to delete token for openHAB. Response code is {}",
252 deleteTokenResponse.getStatus());
255 logger.debug("Successfully deleted token for openHAB from controller");
256 } catch (NanoleafUnauthorizedException e) {
257 logger.warn("Attempt to delete token for openHAB failed. Token unauthorized.");
258 } catch (NanoleafException ne) {
259 logger.warn("Attempt to delete token for openHAB failed : {}", ne.getMessage());
262 super.handleRemoval();
263 logger.debug("Nanoleaf controller removed");
268 public void dispose() {
271 logger.debug("Disposing handler for Nanoleaf controller {}", getThing().getUID());
275 public Collection<Class<? extends ThingHandlerService>> getServices() {
276 return List.of(NanoleafPanelsDiscoveryService.class, NanoleafCommandDescriptionProvider.class);
279 public boolean registerControllerListener(NanoleafControllerListener controllerListener) {
280 logger.debug("Register new listener for controller {}", getThing().getUID());
281 return controllerListeners.add(controllerListener);
284 public boolean unregisterControllerListener(NanoleafControllerListener controllerListener) {
285 logger.debug("Unregister listener for controller {}", getThing().getUID());
286 return controllerListeners.remove(controllerListener);
289 public NanoleafControllerConfig getControllerConfig() {
290 NanoleafControllerConfig config = new NanoleafControllerConfig();
291 config.address = Objects.requireNonNullElse(getAddress(), "");
292 config.port = getPort();
293 config.refreshInterval = getRefreshInterval();
294 config.authToken = getAuthToken();
295 config.deviceType = Objects.requireNonNullElse(getDeviceType(), "");
299 public String getLayout() {
300 String layoutView = "";
301 if (controllerInfo != null) {
302 PanelLayout panelLayout = controllerInfo.getPanelLayout();
303 Layout layout = panelLayout.getLayout();
304 layoutView = layout != null ? layout.getLayoutView() : "";
310 public synchronized void startPairingJob() {
311 if (pairingJob == null || pairingJob.isCancelled()) {
312 logger.debug("Start pairing job, interval={} sec", PAIRING_INTERVAL);
313 pairingJob = scheduler.scheduleWithFixedDelay(this::runPairing, 0L, PAIRING_INTERVAL, TimeUnit.SECONDS);
317 private synchronized void stopPairingJob() {
318 logger.debug("Stop pairing job {}", pairingJob != null ? pairingJob.isCancelled() : "pairing job = null");
319 if (pairingJob != null && !pairingJob.isCancelled()) {
320 pairingJob.cancel(true);
322 logger.debug("Stopped pairing job");
326 private synchronized void startUpdateJob() {
327 final String localAuthToken = getAuthToken();
328 if (localAuthToken != null && !localAuthToken.isEmpty()) {
329 if (updateJob == null || updateJob.isCancelled()) {
330 logger.debug("Start controller status job, repeat every {} sec", getRefreshInterval());
331 updateJob = scheduler.scheduleWithFixedDelay(this::runUpdate, 0L, getRefreshInterval(),
335 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_PENDING,
336 "@text/error.nanoleaf.controller.noToken");
340 private synchronized void stopUpdateJob() {
341 logger.debug("Stop update job {}", updateJob != null ? updateJob.isCancelled() : "update job = null");
342 if (updateJob != null && !updateJob.isCancelled()) {
343 updateJob.cancel(true);
345 logger.debug("Stopped status job");
349 private synchronized void startTouchJob() {
350 NanoleafControllerConfig config = getConfigAs(NanoleafControllerConfig.class);
351 if (!config.deviceType.equals(DEVICE_TYPE_TOUCHSUPPORT)) {
353 "NOT starting TouchJob for Controller {} because it has wrong device type '{}' vs required '{}'",
354 this.getThing().getUID(), config.deviceType, DEVICE_TYPE_TOUCHSUPPORT);
356 logger.debug("Starting TouchJob for Controller {}", getThing().getUID());
357 final String localAuthToken = getAuthToken();
358 if (localAuthToken != null && !localAuthToken.isEmpty()) {
359 if (touchJob != null && !touchJob.isDone()) {
360 logger.trace("tj: tj={} already running touchJobRunning = {} cancelled={} done={}", touchJob,
361 touchJobRunning, touchJob == null ? null : touchJob.isCancelled(),
362 touchJob == null ? null : touchJob.isDone());
364 logger.debug("tj: Starting NEW touch job : tj={} touchJobRunning={} cancelled={} done={}",
365 touchJob, touchJobRunning, touchJob == null ? null : touchJob.isCancelled(),
366 touchJob == null ? null : touchJob.isDone());
367 touchJob = scheduler.scheduleWithFixedDelay(this::runTouchDetection, 0L, 1L, TimeUnit.SECONDS);
370 logger.error("starting TouchJob for Controller {} failed - missing token", getThing().getUID());
376 private synchronized void stopTouchJob() {
377 logger.debug("Stop touch job {}", touchJob != null ? touchJob.isCancelled() : "touchJob job = null");
378 if (touchJob != null) {
379 logger.trace("tj: touch job stopping for {} with client {}", thing.getUID(), httpClientSSETouchEvent);
381 final Request localSSERequest = sseTouchjobRequest;
382 if (localSSERequest != null) {
383 localSSERequest.abort(new NanoleafException("Touch detection stopped"));
385 if (!touchJob.isCancelled()) {
386 touchJob.cancel(true);
390 touchJobRunning = false;
391 logger.debug("tj: touch job stopped for {} with client {}", thing.getUID(), httpClientSSETouchEvent);
395 private boolean hasTouchSupport(@Nullable String deviceType) {
396 return NanoleafBindingConstants.MODELS_WITH_TOUCHSUPPORT.contains(deviceType);
399 private void runUpdate() {
400 logger.debug("Run update job");
403 updateFromControllerInfo();
405 updateStatus(ThingStatus.ONLINE);
406 } catch (NanoleafUnauthorizedException nae) {
407 logger.debug("Status update unauthorized for controller {}: {}", getThing().getUID(), nae.getMessage());
408 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
409 "@text/error.nanoleaf.controller.invalidToken");
410 final String localAuthToken = getAuthToken();
411 if (localAuthToken == null || localAuthToken.isEmpty()) {
412 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_PENDING,
413 "@text/error.nanoleaf.controller.noToken");
415 } catch (NanoleafException ne) {
416 logger.debug("Status update failed for controller {} : {}", getThing().getUID(), ne.getMessage());
417 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
418 "@text/error.nanoleaf.controller.communication");
419 } catch (RuntimeException e) {
420 logger.debug("Update job failed", e);
421 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, "@text/error.nanoleaf.controller.runtime");
425 private void runPairing() {
426 logger.debug("Run pairing job");
429 final String localAuthToken = getAuthToken();
430 if (localAuthToken != null && !localAuthToken.isEmpty()) {
431 if (pairingJob != null) {
432 pairingJob.cancel(false);
435 logger.debug("Authentication token found. Canceling pairing job");
439 ContentResponse authTokenResponse = OpenAPIUtils
440 .requestBuilder(httpClient, getControllerConfig(), API_ADD_USER, HttpMethod.POST)
441 .timeout(20L, TimeUnit.SECONDS).send();
442 String authTokenResponseString = (authTokenResponse != null) ? authTokenResponse.getContentAsString() : "";
443 if (logger.isTraceEnabled()) {
444 logger.trace("Auth token response: {}", authTokenResponseString);
447 if (authTokenResponse != null && authTokenResponse.getStatus() != HttpStatus.OK_200) {
448 logger.debug("Pairing pending for {}. Controller returns status code {}", getThing().getUID(),
449 authTokenResponse.getStatus());
451 AuthToken authTokenObject = gson.fromJson(authTokenResponseString, AuthToken.class);
452 authTokenObject = (authTokenObject != null) ? authTokenObject : new AuthToken();
453 if (authTokenObject.getAuthToken().isEmpty()) {
454 logger.debug("No auth token found in response: {}", authTokenResponseString);
455 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
456 "@text/error.nanoleaf.controller.pairingFailed");
457 throw new NanoleafException(authTokenResponseString);
460 logger.debug("Pairing succeeded.");
461 Configuration config = editConfiguration();
463 config.put(NanoleafControllerConfig.AUTH_TOKEN, authTokenObject.getAuthToken());
464 updateConfiguration(config);
465 updateStatus(ThingStatus.ONLINE);
466 // Update local field
467 setAuthToken(authTokenObject.getAuthToken());
473 } catch (JsonSyntaxException e) {
474 logger.warn("Received invalid data", e);
475 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
476 "@text/error.nanoleaf.controller.invalidData");
477 } catch (NanoleafException ne) {
478 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
479 "@text/error.nanoleaf.controller.noTokenReceived");
480 } catch (ExecutionException | TimeoutException | InterruptedException e) {
481 logger.debug("Cannot send authorization request to controller: ", e);
482 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
483 "@text/error.nanoleaf.controller.authRequest");
484 } catch (RuntimeException e) {
485 logger.warn("Pairing job failed", e);
486 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, "@text/error.nanoleaf.controller.runtime");
487 } catch (Exception e) {
488 logger.warn("Cannot start http client", e);
489 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
490 "@text/error.nanoleaf.controller.noClient");
494 private synchronized void runTouchDetection() {
495 final HttpClient localhttpSSEClientTouchEvent = httpClientSSETouchEvent;
496 int eventHashcode = -1;
497 if (localhttpSSEClientTouchEvent != null) {
498 eventHashcode = localhttpSSEClientTouchEvent.hashCode();
500 if (touchJobRunning) {
501 logger.trace("tj: touch job {} touch job already running. quitting. {} controller {} with {}\",\n",
502 touchJob, eventHashcode, thing.getUID(), httpClientSSETouchEvent);
505 URI eventUri = OpenAPIUtils.getUri(getControllerConfig(), API_EVENTS, "id=4");
506 logger.debug("tj: touch job request registering for {} with client {}", thing.getUID(),
507 httpClientSSETouchEvent);
508 touchJobRunning = true;
509 if (localhttpSSEClientTouchEvent != null) {
510 localhttpSSEClientTouchEvent.setIdleTimeout(CONNECT_TIMEOUT * 1000L);
511 sseTouchjobRequest = localhttpSSEClientTouchEvent.newRequest(eventUri);
512 final Request localSSETouchjobRequest = sseTouchjobRequest;
513 int requestHashCode = -1;
514 if (localSSETouchjobRequest != null) {
515 requestHashCode = localSSETouchjobRequest.hashCode();
517 logger.debug("tj: triggering new touch job request {} for {} with client {}", requestHashCode,
518 thing.getUID(), eventHashcode);
519 localSSETouchjobRequest.onResponseContent((response, content) -> {
520 String s = StandardCharsets.UTF_8.decode(content).toString();
521 logger.debug("touch detected for controller {}", thing.getUID());
522 logger.trace("content {}", s);
523 Scanner eventContent = new Scanner(s);
525 while (eventContent.hasNextLine()) {
526 String line = eventContent.nextLine().trim();
527 if (line.startsWith("data:")) {
528 String json = line.substring(5).trim();
531 TouchEvents touchEvents = gson.fromJson(json, TouchEvents.class);
532 handleTouchEvents(Objects.requireNonNull(touchEvents));
533 } catch (JsonSyntaxException e) {
534 logger.error("Couldn't parse touch event json {}", json);
539 eventContent.close();
540 logger.debug("leaving touch onContent");
541 }).onResponseSuccess((response) -> {
542 logger.trace("tj: r={} touch event SUCCESS: {}", response.getRequest(), response);
543 }).onResponseFailure((response, failure) -> {
544 logger.trace("tj: r={} touch event FAILURE. Touchjob not running anymore for controller {}",
545 response.getRequest(), thing.getUID());
546 }).send((result) -> {
548 "tj: r={} touch event COMPLETE. Touchjob not running anymore for controller {} failed: {} succeeded: {}",
549 result.getRequest(), thing.getUID(), result.isFailed(), result.isSucceeded());
550 touchJobRunning = false;
554 logger.trace("tj: started touch job request for {} with {} at {}", thing.getUID(),
555 httpClientSSETouchEvent, eventUri);
556 } catch (NanoleafException | RuntimeException e) {
557 logger.warn("tj: setting up TouchDetection failed for controller {} with {}\",\n", thing.getUID(),
558 httpClientSSETouchEvent);
559 logger.warn("tj: setting up TouchDetection failed with exception", e);
561 logger.trace("tj: touch job {} started for new request {} controller {} with {}\",\n",
562 touchJob.hashCode(), eventHashcode, thing.getUID(), httpClientSSETouchEvent);
568 private void handleTouchEvents(TouchEvents touchEvents) {
569 touchEvents.getEvents().forEach((event) -> {
570 logger.debug("panel: {} gesture id: {}", event.getPanelId(), event.getGesture());
571 // Swipes go to the controller, taps go to the individual panel
572 if (event.getPanelId().equals(CONTROLLER_PANEL_ID)) {
573 logger.debug("Triggering controller {} with gesture {}.", thing.getUID(), event.getGesture());
574 updateControllerGesture(event.getGesture());
576 getThing().getThings().forEach((child) -> {
577 NanoleafPanelHandler panelHandler = (NanoleafPanelHandler) child.getHandler();
578 if (panelHandler != null) {
579 logger.trace("Checking available panel -{}- versus event panel -{}-", panelHandler.getPanelID(),
581 if (panelHandler.getPanelID().equals(event.getPanelId())) {
582 logger.debug("Panel {} found. Triggering item with gesture {}.", panelHandler.getPanelID(),
584 panelHandler.updatePanelGesture(event.getGesture());
594 * Apply the swipe gesture to the controller
596 * @param gesture Only swipes are supported on the complete nanoleaf panels
598 private void updateControllerGesture(int gesture) {
601 triggerChannel(CHANNEL_SWIPE, CHANNEL_SWIPE_EVENT_UP);
604 triggerChannel(CHANNEL_SWIPE, CHANNEL_SWIPE_EVENT_DOWN);
607 triggerChannel(CHANNEL_SWIPE, CHANNEL_SWIPE_EVENT_LEFT);
610 triggerChannel(CHANNEL_SWIPE, CHANNEL_SWIPE_EVENT_RIGHT);
615 private void updateFromControllerInfo() throws NanoleafException {
616 logger.debug("Update channels for controller {}", thing.getUID());
617 controllerInfo = receiveControllerInfo();
618 State state = controllerInfo.getState();
620 OnOffType powerState = state.getOnOff();
622 Ct colorTemperature = state.getColorTemperature();
624 float colorTempPercent = 0.0F;
627 if (colorTemperature != null) {
628 updateState(CHANNEL_COLOR_TEMPERATURE_ABS, new DecimalType(colorTemperature.getValue()));
629 Integer min = colorTemperature.getMin();
630 hue = min == null ? 0 : min;
631 Integer max = colorTemperature.getMax();
632 saturation = max == null ? 0 : max;
633 colorTempPercent = (colorTemperature.getValue() - hue) / (saturation - hue)
634 * PercentType.HUNDRED.intValue();
637 updateState(CHANNEL_COLOR_TEMPERATURE, new PercentType(Float.toString(colorTempPercent)));
638 updateState(CHANNEL_EFFECT, new StringType(controllerInfo.getEffects().getSelect()));
639 Hue stateHue = state.getHue();
640 hue = stateHue != null ? stateHue.getValue() : 0;
642 Sat stateSaturation = state.getSaturation();
643 saturation = stateSaturation != null ? stateSaturation.getValue() : 0;
645 Brightness stateBrightness = state.getBrightness();
646 int brightness = stateBrightness != null ? stateBrightness.getValue() : 0;
648 updateState(CHANNEL_COLOR, new HSBType(new DecimalType(hue), new PercentType(saturation),
649 new PercentType(powerState == OnOffType.ON ? brightness : 0)));
650 updateState(CHANNEL_COLOR_MODE, new StringType(state.getColorMode()));
651 updateState(CHANNEL_RHYTHM_ACTIVE, controllerInfo.getRhythm().getRhythmActive() ? OnOffType.ON : OnOffType.OFF);
652 updateState(CHANNEL_RHYTHM_MODE, new DecimalType(controllerInfo.getRhythm().getRhythmMode()));
653 updateState(CHANNEL_RHYTHM_STATE,
654 controllerInfo.getRhythm().getRhythmConnected() ? OnOffType.ON : OnOffType.OFF);
656 // update the color channels of each panel
657 getThing().getThings().forEach(child -> {
658 NanoleafPanelHandler panelHandler = (NanoleafPanelHandler) child.getHandler();
659 if (panelHandler != null) {
660 logger.debug("Update color channel for panel {}", panelHandler.getThing().getUID());
661 panelHandler.updatePanelColorChannel();
666 updateConfiguration();
668 for (NanoleafControllerListener controllerListener : controllerListeners) {
669 controllerListener.onControllerInfoFetched(getThing().getUID(), controllerInfo);
673 private void updateConfiguration() {
674 // only update the Thing config if value isn't set yet
675 if (getConfig().get(NanoleafControllerConfig.DEVICE_TYPE) == null) {
676 Configuration config = editConfiguration();
677 if (hasTouchSupport(controllerInfo.getModel())) {
678 config.put(NanoleafControllerConfig.DEVICE_TYPE, DEVICE_TYPE_TOUCHSUPPORT);
679 logger.debug("Set to device type {}", DEVICE_TYPE_TOUCHSUPPORT);
681 config.put(NanoleafControllerConfig.DEVICE_TYPE, DEVICE_TYPE_LIGHTPANELS);
682 logger.debug("Set to device type {}", DEVICE_TYPE_LIGHTPANELS);
684 updateConfiguration(config);
685 if (logger.isTraceEnabled()) {
686 getConfig().getProperties().forEach((key, value) -> {
687 logger.trace("Configuration property: key {} value {}", key, value);
693 private void updateProperties() {
694 // update bridge properties which may have changed, or are not present during discovery
695 Map<String, String> properties = editProperties();
696 properties.put(Thing.PROPERTY_SERIAL_NUMBER, controllerInfo.getSerialNo());
697 properties.put(Thing.PROPERTY_FIRMWARE_VERSION, controllerInfo.getFirmwareVersion());
698 properties.put(Thing.PROPERTY_MODEL_ID, controllerInfo.getModel());
699 properties.put(Thing.PROPERTY_VENDOR, controllerInfo.getManufacturer());
700 updateProperties(properties);
701 if (logger.isTraceEnabled()) {
702 getThing().getProperties().forEach((key, value) -> {
703 logger.trace("Thing property: key {} value {}", key, value);
708 private ControllerInfo receiveControllerInfo() throws NanoleafException, NanoleafUnauthorizedException {
709 ContentResponse controllerlInfoJSON = OpenAPIUtils.sendOpenAPIRequest(OpenAPIUtils.requestBuilder(httpClient,
710 getControllerConfig(), API_GET_CONTROLLER_INFO, HttpMethod.GET));
711 ControllerInfo controllerInfo = gson.fromJson(controllerlInfoJSON.getContentAsString(), ControllerInfo.class);
712 return Objects.requireNonNull(controllerInfo);
715 private void sendStateCommand(String channel, Command command) throws NanoleafException {
716 State stateObject = new State();
719 if (command instanceof OnOffType) {
720 // On/Off command - turns controller on/off
721 BooleanState state = new On();
722 state.setValue(OnOffType.ON.equals(command));
723 stateObject.setState(state);
724 } else if (command instanceof HSBType) {
725 // regular color HSB command
726 IntegerState h = new Hue();
727 IntegerState s = new Sat();
728 IntegerState b = new Brightness();
729 h.setValue(((HSBType) command).getHue().intValue());
730 s.setValue(((HSBType) command).getSaturation().intValue());
731 b.setValue(((HSBType) command).getBrightness().intValue());
732 stateObject.setState(h);
733 stateObject.setState(s);
734 stateObject.setState(b);
735 } else if (command instanceof PercentType) {
736 // brightness command
737 IntegerState b = new Brightness();
738 b.setValue(((PercentType) command).intValue());
739 stateObject.setState(b);
740 } else if (command instanceof IncreaseDecreaseType) {
741 // increase/decrease brightness
742 if (controllerInfo != null) {
744 Brightness brightness = controllerInfo.getState().getBrightness();
747 if (brightness != null) {
749 Integer min = brightness.getMin();
750 brightnessMin = (min == null) ? 0 : min;
752 Integer max = brightness.getMax();
753 brightnessMax = (max == null) ? 0 : max;
755 if (IncreaseDecreaseType.INCREASE.equals(command)) {
757 Math.min(brightnessMax, brightness.getValue() + BRIGHTNESS_STEP_SIZE));
760 Math.max(brightnessMin, brightness.getValue() - BRIGHTNESS_STEP_SIZE));
762 stateObject.setState(brightness);
763 logger.debug("Setting controller brightness to {}", brightness.getValue());
764 // update controller info in case new command is sent before next update job interval
765 controllerInfo.getState().setBrightness(brightness);
767 logger.debug("Couldn't set brightness as it was null!");
771 logger.warn("Unhandled command {} with command type: {}", command, command.getClass().getName());
775 case CHANNEL_COLOR_TEMPERATURE:
776 if (command instanceof PercentType) {
777 // Color temperature (percent)
778 IntegerState state = new Ct();
780 Ct colorTemperature = controllerInfo.getState().getColorTemperature();
784 if (colorTemperature != null) {
786 Integer min = colorTemperature.getMin();
787 colorMin = (min == null) ? 0 : min;
790 Integer max = colorTemperature.getMax();
791 colorMax = (max == null) ? 0 : max;
794 state.setValue(Math.round((colorMax - colorMin) * (100 - ((PercentType) command).intValue())
795 / PercentType.HUNDRED.floatValue() + colorMin));
796 stateObject.setState(state);
798 logger.warn("Unhandled command type: {}", command.getClass().getName());
802 case CHANNEL_COLOR_TEMPERATURE_ABS:
803 if (command instanceof DecimalType) {
804 // Color temperature (absolute)
805 IntegerState state = new Ct();
806 state.setValue(((DecimalType) command).intValue());
807 stateObject.setState(state);
809 logger.warn("Unhandled command type: {}", command.getClass().getName());
814 logger.warn("Unhandled command type: {}", command.getClass().getName());
818 Request setNewStateRequest = OpenAPIUtils.requestBuilder(httpClient, getControllerConfig(), API_SET_VALUE,
820 setNewStateRequest.content(new StringContentProvider(gson.toJson(stateObject)), "application/json");
821 OpenAPIUtils.sendOpenAPIRequest(setNewStateRequest);
824 private void sendEffectCommand(Command command) throws NanoleafException {
825 Effects effects = new Effects();
826 if (command instanceof StringType) {
827 effects.setSelect(command.toString());
828 Request setNewEffectRequest = OpenAPIUtils.requestBuilder(httpClient, getControllerConfig(), API_EFFECT,
830 String content = gson.toJson(effects);
831 logger.debug("sending effect command from controller {}: {}", getThing().getUID(), content);
832 setNewEffectRequest.content(new StringContentProvider(content), "application/json");
833 OpenAPIUtils.sendOpenAPIRequest(setNewEffectRequest);
835 logger.warn("Unhandled command type: {}", command.getClass().getName());
839 private void sendRhythmCommand(Command command) throws NanoleafException {
840 Rhythm rhythm = new Rhythm();
841 if (command instanceof DecimalType) {
842 rhythm.setRhythmMode(((DecimalType) command).intValue());
843 Request setNewRhythmRequest = OpenAPIUtils.requestBuilder(httpClient, getControllerConfig(),
844 API_RHYTHM_MODE, HttpMethod.PUT);
845 setNewRhythmRequest.content(new StringContentProvider(gson.toJson(rhythm)), "application/json");
846 OpenAPIUtils.sendOpenAPIRequest(setNewRhythmRequest);
848 logger.warn("Unhandled command type: {}", command.getClass().getName());
852 private @Nullable String getAddress() {
856 private void setAddress(String address) {
857 this.address = address;
860 private int getPort() {
864 private void setPort(int port) {
868 private int getRefreshInterval() {
869 return refreshIntervall;
872 private void setRefreshIntervall(int refreshIntervall) {
873 this.refreshIntervall = refreshIntervall;
877 private String getAuthToken() {
881 private void setAuthToken(@Nullable String authToken) {
882 this.authToken = authToken;
886 private String getDeviceType() {
890 private void setDeviceType(String deviceType) {
891 this.deviceType = deviceType;
894 private void stopAllJobs() {