2 * Copyright (c) 2010-2021 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.ByteBuffer;
19 import java.nio.charset.StandardCharsets;
21 import java.util.concurrent.CopyOnWriteArrayList;
22 import java.util.concurrent.ExecutionException;
23 import java.util.concurrent.ScheduledFuture;
24 import java.util.concurrent.TimeUnit;
25 import java.util.concurrent.TimeoutException;
27 import org.eclipse.jdt.annotation.NonNullByDefault;
28 import org.eclipse.jdt.annotation.Nullable;
29 import org.eclipse.jetty.client.HttpClient;
30 import org.eclipse.jetty.client.api.ContentResponse;
31 import org.eclipse.jetty.client.api.Request;
32 import org.eclipse.jetty.client.api.Response;
33 import org.eclipse.jetty.client.api.Result;
34 import org.eclipse.jetty.client.util.StringContentProvider;
35 import org.eclipse.jetty.http.HttpMethod;
36 import org.eclipse.jetty.http.HttpStatus;
37 import org.openhab.binding.nanoleaf.internal.NanoleafControllerListener;
38 import org.openhab.binding.nanoleaf.internal.NanoleafException;
39 import org.openhab.binding.nanoleaf.internal.NanoleafInterruptedException;
40 import org.openhab.binding.nanoleaf.internal.NanoleafUnauthorizedException;
41 import org.openhab.binding.nanoleaf.internal.OpenAPIUtils;
42 import org.openhab.binding.nanoleaf.internal.config.NanoleafControllerConfig;
43 import org.openhab.binding.nanoleaf.internal.model.AuthToken;
44 import org.openhab.binding.nanoleaf.internal.model.BooleanState;
45 import org.openhab.binding.nanoleaf.internal.model.Brightness;
46 import org.openhab.binding.nanoleaf.internal.model.ControllerInfo;
47 import org.openhab.binding.nanoleaf.internal.model.Ct;
48 import org.openhab.binding.nanoleaf.internal.model.Effects;
49 import org.openhab.binding.nanoleaf.internal.model.Hue;
50 import org.openhab.binding.nanoleaf.internal.model.IntegerState;
51 import org.openhab.binding.nanoleaf.internal.model.Layout;
52 import org.openhab.binding.nanoleaf.internal.model.On;
53 import org.openhab.binding.nanoleaf.internal.model.Rhythm;
54 import org.openhab.binding.nanoleaf.internal.model.Sat;
55 import org.openhab.binding.nanoleaf.internal.model.State;
56 import org.openhab.binding.nanoleaf.internal.model.TouchEvents;
57 import org.openhab.core.config.core.Configuration;
58 import org.openhab.core.library.types.DecimalType;
59 import org.openhab.core.library.types.HSBType;
60 import org.openhab.core.library.types.IncreaseDecreaseType;
61 import org.openhab.core.library.types.OnOffType;
62 import org.openhab.core.library.types.PercentType;
63 import org.openhab.core.library.types.StringType;
64 import org.openhab.core.thing.Bridge;
65 import org.openhab.core.thing.ChannelUID;
66 import org.openhab.core.thing.Thing;
67 import org.openhab.core.thing.ThingStatus;
68 import org.openhab.core.thing.ThingStatusDetail;
69 import org.openhab.core.thing.binding.BaseBridgeHandler;
70 import org.openhab.core.types.Command;
71 import org.openhab.core.types.RefreshType;
72 import org.slf4j.Logger;
73 import org.slf4j.LoggerFactory;
75 import com.google.gson.Gson;
76 import com.google.gson.JsonSyntaxException;
79 * The {@link NanoleafControllerHandler} is responsible for handling commands to the controller which
80 * affect all panels connected to it (e.g. selected effect)
82 * @author Martin Raepple - Initial contribution
83 * @author Stefan Höhn - Canvas Touch Support
86 public class NanoleafControllerHandler extends BaseBridgeHandler {
88 // Pairing interval in seconds
89 private static final int PAIRING_INTERVAL = 25;
91 // Panel discovery interval in seconds
92 private static final int PANEL_DISCOVERY_INTERVAL = 30;
94 private final Logger logger = LoggerFactory.getLogger(NanoleafControllerHandler.class);
95 private HttpClient httpClient;
96 private List<NanoleafControllerListener> controllerListeners = new CopyOnWriteArrayList<>();
98 // Pairing, update and panel discovery jobs and touch event job
99 private @NonNullByDefault({}) ScheduledFuture<?> pairingJob;
100 private @NonNullByDefault({}) ScheduledFuture<?> updateJob;
101 private @NonNullByDefault({}) ScheduledFuture<?> panelDiscoveryJob;
102 private @NonNullByDefault({}) ScheduledFuture<?> touchJob;
104 // JSON parser for API responses
105 private final Gson gson = new Gson();
107 // Controller configuration settings and channel values
108 private @Nullable String address;
110 private int refreshIntervall;
111 private @Nullable String authToken;
112 private @Nullable String deviceType;
113 private @NonNullByDefault({}) ControllerInfo controllerInfo;
115 public NanoleafControllerHandler(Bridge bridge, HttpClient httpClient) {
117 this.httpClient = httpClient;
121 public void initialize() {
122 logger.debug("Initializing the controller (bridge)");
123 updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.BRIDGE_UNINITIALIZED);
124 NanoleafControllerConfig config = getConfigAs(NanoleafControllerConfig.class);
125 setAddress(config.address);
126 setPort(config.port);
127 setRefreshIntervall(config.refreshInterval);
128 setAuthToken(config.authToken);
130 Map<String, String> properties = getThing().getProperties();
131 String propertyModelId = properties.get(Thing.PROPERTY_MODEL_ID);
132 if (hasTouchSupport(propertyModelId)) {
133 config.deviceType = DEVICE_TYPE_TOUCHSUPPORT;
135 config.deviceType = DEVICE_TYPE_LIGHTPANELS;
137 setDeviceType(config.deviceType);
139 String propertyFirmwareVersion = properties.get(Thing.PROPERTY_FIRMWARE_VERSION);
142 if (config.address.isEmpty() || String.valueOf(config.port).isEmpty()) {
143 logger.warn("No IP address and port configured for the Nanoleaf controller");
144 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_PENDING,
145 "@text/error.nanoleaf.controller.noIp");
147 } else if (propertyFirmwareVersion != null && !propertyFirmwareVersion.isEmpty() && !OpenAPIUtils
148 .checkRequiredFirmware(properties.get(Thing.PROPERTY_MODEL_ID), propertyFirmwareVersion)) {
149 logger.warn("Nanoleaf controller firmware is too old: {}. Must be equal or higher than {}",
150 propertyFirmwareVersion, API_MIN_FW_VER_LIGHTPANELS);
151 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
152 "@text/error.nanoleaf.controller.incompatibleFirmware");
154 } else if (config.authToken == null || config.authToken.isEmpty()) {
155 logger.debug("No token found. Start pairing background job");
156 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_PENDING,
157 "@text/error.nanoleaf.controller.noToken");
160 stopPanelDiscoveryJob();
162 logger.debug("Controller is online. Stop pairing job, start update & panel discovery jobs");
163 updateStatus(ThingStatus.ONLINE);
166 startPanelDiscoveryJob();
169 } catch (IllegalArgumentException iae) {
170 logger.warn("Nanoleaf controller firmware version not in format x.y.z: {}",
171 getThing().getProperties().get(Thing.PROPERTY_FIRMWARE_VERSION));
172 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
173 "@text/error.nanoleaf.controller.incompatibleFirmware");
178 public void handleCommand(ChannelUID channelUID, Command command) {
179 logger.debug("Received command {} for channel {}", command, channelUID);
180 if (!ThingStatus.ONLINE.equals(getThing().getStatusInfo().getStatus())) {
181 logger.debug("Cannot handle command. Bridge is not online.");
185 if (command instanceof RefreshType) {
186 updateFromControllerInfo();
188 switch (channelUID.getId()) {
191 case CHANNEL_COLOR_TEMPERATURE:
192 case CHANNEL_COLOR_TEMPERATURE_ABS:
193 case CHANNEL_PANEL_LAYOUT:
194 sendStateCommand(channelUID.getId(), command);
197 sendEffectCommand(command);
199 case CHANNEL_RHYTHM_MODE:
200 sendRhythmCommand(command);
203 logger.warn("Channel with id {} not handled", channelUID.getId());
207 } catch (NanoleafUnauthorizedException nae) {
208 logger.warn("Authorization for command {} to channelUID {} failed: {}", command, channelUID,
210 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
211 "@text/error.nanoleaf.controller.invalidToken");
212 } catch (NanoleafException ne) {
213 logger.warn("Handling command {} to channelUID {} failed: {}", command, channelUID, ne.getMessage());
214 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
215 "@text/error.nanoleaf.controller.communication");
220 public void handleRemoval() {
221 // delete token for openHAB
222 ContentResponse deleteTokenResponse;
224 Request deleteTokenRequest = OpenAPIUtils.requestBuilder(httpClient, getControllerConfig(), API_DELETE_USER,
226 deleteTokenResponse = OpenAPIUtils.sendOpenAPIRequest(deleteTokenRequest);
227 if (deleteTokenResponse.getStatus() != HttpStatus.NO_CONTENT_204) {
228 logger.warn("Failed to delete token for openHAB. Response code is {}", deleteTokenResponse.getStatus());
231 logger.debug("Successfully deleted token for openHAB from controller");
232 } catch (NanoleafUnauthorizedException e) {
233 logger.warn("Attempt to delete token for openHAB failed. Token unauthorized.");
234 } catch (NanoleafException ne) {
235 logger.warn("Attempt to delete token for openHAB failed : {}", ne.getMessage());
238 super.handleRemoval();
239 logger.debug("Nanoleaf controller removed");
243 public void dispose() {
246 logger.debug("Disposing handler for Nanoleaf controller {}", getThing().getUID());
249 public boolean registerControllerListener(NanoleafControllerListener controllerListener) {
250 logger.debug("Register new listener for controller {}", getThing().getUID());
251 boolean result = controllerListeners.add(controllerListener);
253 startPanelDiscoveryJob();
258 public boolean unregisterControllerListener(NanoleafControllerListener controllerListener) {
259 logger.debug("Unregister listener for controller {}", getThing().getUID());
260 boolean result = controllerListeners.remove(controllerListener);
262 stopPanelDiscoveryJob();
267 public NanoleafControllerConfig getControllerConfig() {
268 NanoleafControllerConfig config = new NanoleafControllerConfig();
269 config.address = Objects.requireNonNullElse(getAddress(), "");
270 config.port = getPort();
271 config.refreshInterval = getRefreshIntervall();
272 config.authToken = getAuthToken();
273 config.deviceType = Objects.requireNonNullElse(getDeviceType(), "");
277 public synchronized void startPairingJob() {
278 if (pairingJob == null || pairingJob.isCancelled()) {
279 logger.debug("Start pairing job, interval={} sec", PAIRING_INTERVAL);
280 pairingJob = scheduler.scheduleWithFixedDelay(this::runPairing, 0, PAIRING_INTERVAL, TimeUnit.SECONDS);
284 private synchronized void stopPairingJob() {
285 if (pairingJob != null && !pairingJob.isCancelled()) {
286 logger.debug("Stop pairing job");
287 pairingJob.cancel(true);
288 this.pairingJob = null;
292 private synchronized void startUpdateJob() {
293 String localAuthToken = getAuthToken();
294 if (localAuthToken != null && !localAuthToken.isEmpty()) {
295 if (updateJob == null || updateJob.isCancelled()) {
296 logger.debug("Start controller status job, repeat every {} sec", getRefreshIntervall());
297 updateJob = scheduler.scheduleWithFixedDelay(this::runUpdate, 0, getRefreshIntervall(),
301 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_PENDING,
302 "@text/error.nanoleaf.controller.noToken");
306 private synchronized void stopUpdateJob() {
307 if (updateJob != null && !updateJob.isCancelled()) {
308 logger.debug("Stop status job");
309 updateJob.cancel(true);
310 this.updateJob = null;
314 public synchronized void startPanelDiscoveryJob() {
315 logger.debug("Starting panel discovery job. Has Controller-Listeners: {} panelDiscoveryJob: {}",
316 !controllerListeners.isEmpty(), panelDiscoveryJob);
317 if (!controllerListeners.isEmpty() && (panelDiscoveryJob == null || panelDiscoveryJob.isCancelled())) {
318 logger.debug("Start panel discovery job, interval={} sec", PANEL_DISCOVERY_INTERVAL);
319 panelDiscoveryJob = scheduler.scheduleWithFixedDelay(this::runPanelDiscovery, 0, PANEL_DISCOVERY_INTERVAL,
324 private synchronized void stopPanelDiscoveryJob() {
325 if (controllerListeners.isEmpty() && panelDiscoveryJob != null && !panelDiscoveryJob.isCancelled()) {
326 logger.debug("Stop panel discovery job");
327 panelDiscoveryJob.cancel(true);
328 this.panelDiscoveryJob = null;
332 private synchronized void startTouchJob() {
333 NanoleafControllerConfig config = getConfigAs(NanoleafControllerConfig.class);
334 if (!config.deviceType.equals(DEVICE_TYPE_TOUCHSUPPORT)) {
335 logger.debug("NOT starting TouchJob for Panel {} because it has wrong device type '{}' vs required '{}'",
336 this.getThing().getUID(), config.deviceType, DEVICE_TYPE_TOUCHSUPPORT);
339 logger.debug("Starting TouchJob for Panel {}", this.getThing().getUID());
342 String localAuthToken = getAuthToken();
343 if (localAuthToken != null && !localAuthToken.isEmpty()) {
344 if (touchJob == null || touchJob.isCancelled()) {
345 logger.debug("Starting Touchjob now");
346 touchJob = scheduler.schedule(this::runTouchDetection, 0, TimeUnit.SECONDS);
349 logger.error("starting TouchJob for Controller {} failed - missing token", this.getThing().getUID());
353 private boolean hasTouchSupport(@Nullable String deviceType) {
354 return (MODELS_WITH_TOUCHSUPPORT.contains(deviceType));
357 private synchronized void stopTouchJob() {
358 if (touchJob != null && !touchJob.isCancelled()) {
359 logger.debug("Stop touch job");
360 touchJob.cancel(true);
361 this.touchJob = null;
365 private void runUpdate() {
366 logger.debug("Run update job");
368 updateFromControllerInfo();
369 startTouchJob(); // if device type has changed, start touch detection.
370 // controller might have been offline, e.g. for firmware update. In this case, return to online state
371 if (ThingStatus.OFFLINE.equals(getThing().getStatus())) {
372 logger.debug("Controller {} is back online", thing.getUID());
373 updateStatus(ThingStatus.ONLINE);
375 } catch (NanoleafUnauthorizedException nae) {
376 logger.warn("Status update unauthorized: {}", nae.getMessage());
377 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
378 "@text/error.nanoleaf.controller.invalidToken");
379 String localAuthToken = getAuthToken();
380 if (localAuthToken == null || localAuthToken.isEmpty()) {
381 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_PENDING,
382 "@text/error.nanoleaf.controller.noToken");
384 } catch (NanoleafException ne) {
385 logger.warn("Status update failed: {}", ne.getMessage());
386 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
387 "@text/error.nanoleaf.controller.communication");
388 } catch (RuntimeException e) {
389 logger.warn("Update job failed", e);
390 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, "@text/error.nanoleaf.controller.runtime");
394 private void runPairing() {
395 logger.debug("Run pairing job");
397 String localAuthToken = getAuthToken();
398 if (localAuthToken != null && !localAuthToken.isEmpty()) {
399 if (pairingJob != null) {
400 pairingJob.cancel(false);
402 logger.debug("Authentication token found. Canceling pairing job");
405 ContentResponse authTokenResponse = OpenAPIUtils
406 .requestBuilder(httpClient, getControllerConfig(), API_ADD_USER, HttpMethod.POST).send();
407 if (logger.isTraceEnabled()) {
408 logger.trace("Auth token response: {}", authTokenResponse.getContentAsString());
411 if (authTokenResponse.getStatus() != HttpStatus.OK_200) {
412 logger.debug("Pairing pending for {}. Controller returns status code {}", this.getThing().getUID(),
413 authTokenResponse.getStatus());
415 // get auth token from response
416 AuthToken authTokenObject = gson.fromJson(authTokenResponse.getContentAsString(), AuthToken.class);
417 localAuthToken = authTokenObject.getAuthToken();
418 if (localAuthToken != null && !localAuthToken.isEmpty()) {
419 logger.debug("Pairing succeeded.");
421 // Update and save the auth token in the thing configuration
422 Configuration config = editConfiguration();
423 config.put(NanoleafControllerConfig.AUTH_TOKEN, localAuthToken);
424 updateConfiguration(config);
426 updateStatus(ThingStatus.ONLINE);
427 // Update local field
428 setAuthToken(localAuthToken);
432 startPanelDiscoveryJob();
435 logger.debug("No auth token found in response: {}", authTokenResponse.getContentAsString());
436 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
437 "@text/error.nanoleaf.controller.pairingFailed");
438 throw new NanoleafException(authTokenResponse.getContentAsString());
441 } catch (JsonSyntaxException e) {
442 logger.warn("Received invalid data", e);
443 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
444 "@text/error.nanoleaf.controller.invalidData");
445 } catch (NanoleafException e) {
446 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
447 "@text/error.nanoleaf.controller.noTokenReceived");
448 } catch (InterruptedException | ExecutionException | TimeoutException e) {
449 logger.warn("Cannot send authorization request to controller: ", e);
450 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
451 "@text/error.nanoleaf.controller.authRequest");
452 } catch (RuntimeException e) {
453 logger.warn("Pairing job failed", e);
454 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, "@text/error.nanoleaf.controller.runtime");
455 } catch (Exception e) {
456 logger.warn("Cannot start http client", e);
457 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
458 "@text/error.nanoleaf.controller.noClient");
462 private void runPanelDiscovery() {
463 logger.debug("Run panel discovery job");
464 // Trigger a new discovery of connected panels
465 for (NanoleafControllerListener controllerListener : controllerListeners) {
467 controllerListener.onControllerInfoFetched(getThing().getUID(), receiveControllerInfo());
468 } catch (NanoleafUnauthorizedException nue) {
469 logger.warn("Panel discovery unauthorized: {}", nue.getMessage());
470 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
471 "@text/error.nanoleaf.controller.invalidToken");
472 String localAuthToken = getAuthToken();
473 if (localAuthToken == null || localAuthToken.isEmpty()) {
474 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_PENDING,
475 "@text/error.nanoleaf.controller.noToken");
477 } catch (NanoleafInterruptedException nie) {
478 logger.info("Panel discovery has been stopped.");
479 } catch (NanoleafException ne) {
480 logger.warn("Failed to discover panels: ", ne);
481 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
482 "@text/error.nanoleaf.controller.communication");
483 } catch (RuntimeException e) {
484 logger.warn("Panel discovery job failed", e);
485 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, "@text/error.nanoleaf.controller.runtime");
491 * This is based on the touch event detection described in https://forum.nanoleaf.me/docs/openapi#_842h3097vbgq
493 private static boolean touchJobRunning = false;
495 private void runTouchDetection() {
496 if (touchJobRunning) {
497 logger.debug("touch job already running. quitting.");
501 touchJobRunning = true;
502 URI eventUri = OpenAPIUtils.getUri(getControllerConfig(), API_EVENTS, "id=4");
503 logger.debug("touch job registered on: {}", eventUri.toString());
504 httpClient.newRequest(eventUri).send(new Response.Listener.Adapter() // request runs forever
507 public void onContent(@Nullable Response response, @Nullable ByteBuffer content) {
508 String s = StandardCharsets.UTF_8.decode(content).toString();
509 logger.trace("content {}", s);
511 Scanner eventContent = new Scanner(s);
512 while (eventContent.hasNextLine()) {
513 String line = eventContent.nextLine().trim();
514 // we don't expect anything than content id:4, so we do not check that but only care about the
516 if (line.startsWith("data:")) {
517 String json = line.substring(5).trim(); // supposed to be JSON
519 TouchEvents touchEvents = gson.fromJson(json, TouchEvents.class);
520 handleTouchEvents(Objects.requireNonNull(touchEvents));
521 } catch (JsonSyntaxException jse) {
522 logger.error("couldn't parse touch event json {}", json);
526 eventContent.close();
527 logger.debug("leaving touch onContent");
528 super.onContent(response, content);
532 public void onSuccess(@Nullable Response response) {
533 logger.trace("touch event SUCCESS: {}", response);
537 public void onFailure(@Nullable Response response, @Nullable Throwable failure) {
538 logger.trace("touch event FAILURE: {}", response);
542 public void onComplete(@Nullable Result result) {
543 logger.trace("touch event COMPLETE: {}", result);
546 } catch (RuntimeException | NanoleafException e) {
547 logger.warn("setting up TouchDetection failed", e);
549 touchJobRunning = false;
551 logger.debug("leaving run touch detection");
555 * Interate over all gathered touch events and apply them to the panel they belong to
559 private void handleTouchEvents(TouchEvents touchEvents) {
560 touchEvents.getEvents().forEach(event -> {
561 logger.info("panel: {} gesture id: {}", event.getPanelId(), event.getGesture());
563 // Iterate over all child things = all panels of that controller
564 this.getThing().getThings().forEach(child -> {
565 NanoleafPanelHandler panelHandler = (NanoleafPanelHandler) child.getHandler();
566 if (panelHandler != null) {
567 logger.trace("Checking available panel -{}- versus event panel -{}-", panelHandler.getPanelID(),
569 if (panelHandler.getPanelID().equals(event.getPanelId())) {
570 logger.debug("Panel {} found. Triggering item with gesture {}.", panelHandler.getPanelID(),
572 panelHandler.updatePanelGesture(event.getGesture());
579 private void updateFromControllerInfo() throws NanoleafException {
580 logger.debug("Update channels for controller {}", thing.getUID());
581 this.controllerInfo = receiveControllerInfo();
582 if (controllerInfo == null) {
583 logger.debug("No Controller Info has been provided");
586 final State state = controllerInfo.getState();
588 OnOffType powerState = state.getOnOff();
589 updateState(CHANNEL_POWER, powerState);
592 Ct colorTemperature = state.getColorTemperature();
594 float colorTempPercent = 0f;
595 if (colorTemperature != null) {
596 updateState(CHANNEL_COLOR_TEMPERATURE_ABS, new DecimalType(colorTemperature.getValue()));
599 Integer min = colorTemperature.getMin();
600 int colorMin = (min == null) ? 0 : min;
603 Integer max = colorTemperature.getMax();
604 int colorMax = (max == null) ? 0 : max;
606 colorTempPercent = (colorTemperature.getValue() - colorMin) / (colorMax - colorMin)
607 * PercentType.HUNDRED.intValue();
610 updateState(CHANNEL_COLOR_TEMPERATURE, new PercentType(Float.toString(colorTempPercent)));
611 updateState(CHANNEL_EFFECT, new StringType(controllerInfo.getEffects().getSelect()));
614 Hue stateHue = state.getHue();
615 int hue = (stateHue != null) ? stateHue.getValue() : 0;
617 Sat stateSaturation = state.getSaturation();
618 int saturation = (stateSaturation != null) ? stateSaturation.getValue() : 0;
620 Brightness stateBrightness = state.getBrightness();
621 int brightness = (stateBrightness != null) ? stateBrightness.getValue() : 0;
623 updateState(CHANNEL_COLOR, new HSBType(new DecimalType(hue), new PercentType(saturation),
624 new PercentType(powerState == OnOffType.ON ? brightness : 0)));
625 updateState(CHANNEL_COLOR_MODE, new StringType(state.getColorMode()));
626 updateState(CHANNEL_RHYTHM_ACTIVE, controllerInfo.getRhythm().getRhythmActive() ? OnOffType.ON : OnOffType.OFF);
627 updateState(CHANNEL_RHYTHM_MODE, new DecimalType(controllerInfo.getRhythm().getRhythmMode()));
628 updateState(CHANNEL_RHYTHM_STATE,
629 controllerInfo.getRhythm().getRhythmConnected() ? OnOffType.ON : OnOffType.OFF);
630 // update bridge properties which may have changed, or are not present during discovery
631 Map<String, String> properties = editProperties();
632 properties.put(Thing.PROPERTY_SERIAL_NUMBER, controllerInfo.getSerialNo());
633 properties.put(Thing.PROPERTY_FIRMWARE_VERSION, controllerInfo.getFirmwareVersion());
634 properties.put(Thing.PROPERTY_MODEL_ID, controllerInfo.getModel());
635 properties.put(Thing.PROPERTY_VENDOR, controllerInfo.getManufacturer());
636 updateProperties(properties);
638 Configuration config = editConfiguration();
640 if (hasTouchSupport(controllerInfo.getModel())) {
641 config.put(NanoleafControllerConfig.DEVICE_TYPE, DEVICE_TYPE_TOUCHSUPPORT);
642 logger.debug("Set to device type {}", DEVICE_TYPE_TOUCHSUPPORT);
644 config.put(NanoleafControllerConfig.DEVICE_TYPE, DEVICE_TYPE_LIGHTPANELS);
645 logger.debug("Set to device type {}", DEVICE_TYPE_LIGHTPANELS);
647 updateConfiguration(config);
649 getConfig().getProperties().forEach((key, value) -> {
650 logger.trace("Configuration property: key {} value {}", key, value);
653 getThing().getProperties().forEach((key, value) -> {
654 logger.debug("Thing property: key {} value {}", key, value);
657 // update the color channels of each panel
658 this.getThing().getThings().forEach(child -> {
659 NanoleafPanelHandler panelHandler = (NanoleafPanelHandler) child.getHandler();
660 if (panelHandler != null) {
661 logger.debug("Update color channel for panel {}", panelHandler.getThing().getUID());
662 panelHandler.updatePanelColorChannel();
667 private ControllerInfo receiveControllerInfo() throws NanoleafException, NanoleafUnauthorizedException {
668 ContentResponse controllerlInfoJSON = OpenAPIUtils.sendOpenAPIRequest(OpenAPIUtils.requestBuilder(httpClient,
669 getControllerConfig(), API_GET_CONTROLLER_INFO, HttpMethod.GET));
670 ControllerInfo controllerInfo = gson.fromJson(controllerlInfoJSON.getContentAsString(), ControllerInfo.class);
671 return Objects.requireNonNull(controllerInfo);
674 private void sendStateCommand(String channel, Command command) throws NanoleafException {
675 State stateObject = new State();
678 if (command instanceof OnOffType) {
679 // On/Off command - turns controller on/off
680 BooleanState state = new On();
681 state.setValue(OnOffType.ON.equals(command));
682 stateObject.setState(state);
684 logger.warn("Unhandled command type: {}", command.getClass().getName());
689 if (command instanceof OnOffType) {
690 // On/Off command - turns controller on/off
691 BooleanState state = new On();
692 state.setValue(OnOffType.ON.equals(command));
693 stateObject.setState(state);
694 } else if (command instanceof HSBType) {
695 // regular color HSB command
696 IntegerState h = new Hue();
697 IntegerState s = new Sat();
698 IntegerState b = new Brightness();
699 h.setValue(((HSBType) command).getHue().intValue());
700 s.setValue(((HSBType) command).getSaturation().intValue());
701 b.setValue(((HSBType) command).getBrightness().intValue());
702 stateObject.setState(h);
703 stateObject.setState(s);
704 stateObject.setState(b);
705 } else if (command instanceof PercentType) {
706 // brightness command
707 IntegerState b = new Brightness();
708 b.setValue(((PercentType) command).intValue());
709 stateObject.setState(b);
710 } else if (command instanceof IncreaseDecreaseType) {
711 // increase/decrease brightness
712 if (controllerInfo != null) {
714 Brightness brightness = controllerInfo.getState().getBrightness();
715 int brightnessMin = 0;
716 int brightnessMax = 0;
717 if (brightness != null) {
719 Integer min = brightness.getMin();
720 brightnessMin = (min == null) ? 0 : min;
722 Integer max = brightness.getMax();
723 brightnessMax = (max == null) ? 0 : max;
725 if (IncreaseDecreaseType.INCREASE.equals(command)) {
727 Math.min(brightnessMax, brightness.getValue() + BRIGHTNESS_STEP_SIZE));
730 Math.max(brightnessMin, brightness.getValue() - BRIGHTNESS_STEP_SIZE));
732 stateObject.setState(brightness);
733 logger.debug("Setting controller brightness to {}", brightness.getValue());
734 // update controller info in case new command is sent before next update job interval
735 controllerInfo.getState().setBrightness(brightness);
737 logger.debug("Couldn't set brightness as it was null!");
741 logger.warn("Unhandled command type: {}", command.getClass().getName());
745 case CHANNEL_COLOR_TEMPERATURE:
746 if (command instanceof PercentType) {
747 // Color temperature (percent)
748 IntegerState state = new Ct();
750 Ct colorTemperature = controllerInfo.getState().getColorTemperature();
754 if (colorTemperature != null) {
756 Integer min = colorTemperature.getMin();
757 colorMin = (min == null) ? 0 : min;
760 Integer max = colorTemperature.getMax();
761 colorMax = (max == null) ? 0 : max;
764 state.setValue(Math.round((colorMax - colorMin) * (100 - ((PercentType) command).intValue())
765 / PercentType.HUNDRED.floatValue() + colorMin));
766 stateObject.setState(state);
768 logger.warn("Unhandled command type: {}", command.getClass().getName());
772 case CHANNEL_COLOR_TEMPERATURE_ABS:
773 if (command instanceof DecimalType) {
774 // Color temperature (absolute)
775 IntegerState state = new Ct();
776 state.setValue(((DecimalType) command).intValue());
777 stateObject.setState(state);
779 logger.warn("Unhandled command type: {}", command.getClass().getName());
783 case CHANNEL_PANEL_LAYOUT:
785 Layout layout = controllerInfo.getPanelLayout().getLayout();
786 String layoutView = (layout != null) ? layout.getLayoutView() : "";
787 logger.info("Panel layout and ids for controller {} \n{}", thing.getUID(), layoutView);
788 updateState(CHANNEL_PANEL_LAYOUT, OnOffType.OFF);
791 logger.warn("Unhandled command type: {}", command.getClass().getName());
795 Request setNewStateRequest = OpenAPIUtils.requestBuilder(httpClient, getControllerConfig(), API_SET_VALUE,
797 setNewStateRequest.content(new StringContentProvider(gson.toJson(stateObject)), "application/json");
798 OpenAPIUtils.sendOpenAPIRequest(setNewStateRequest);
801 private void sendEffectCommand(Command command) throws NanoleafException {
802 Effects effects = new Effects();
803 if (command instanceof StringType) {
804 effects.setSelect(command.toString());
806 logger.warn("Unhandled command type: {}", command.getClass().getName());
809 Request setNewEffectRequest = OpenAPIUtils.requestBuilder(httpClient, getControllerConfig(), API_EFFECT,
811 String content = gson.toJson(effects);
812 logger.debug("sending effect command from controller {}: {}", getThing().getUID(), content);
813 setNewEffectRequest.content(new StringContentProvider(content), "application/json");
814 OpenAPIUtils.sendOpenAPIRequest(setNewEffectRequest);
817 private void sendRhythmCommand(Command command) throws NanoleafException {
818 Rhythm rhythm = new Rhythm();
819 if (command instanceof DecimalType) {
820 rhythm.setRhythmMode(((DecimalType) command).intValue());
822 logger.warn("Unhandled command type: {}", command.getClass().getName());
825 Request setNewRhythmRequest = OpenAPIUtils.requestBuilder(httpClient, getControllerConfig(), API_RHYTHM_MODE,
827 setNewRhythmRequest.content(new StringContentProvider(gson.toJson(rhythm)), "application/json");
828 OpenAPIUtils.sendOpenAPIRequest(setNewRhythmRequest);
831 private @Nullable String getAddress() {
835 private void setAddress(String address) {
836 this.address = address;
839 private int getPort() {
843 private void setPort(int port) {
847 private int getRefreshIntervall() {
848 return refreshIntervall;
851 private void setRefreshIntervall(int refreshIntervall) {
852 this.refreshIntervall = refreshIntervall;
855 private @Nullable String getAuthToken() {
859 private void setAuthToken(@Nullable String authToken) {
860 this.authToken = authToken;
863 private @Nullable String getDeviceType() {
867 private void setDeviceType(String deviceType) {
868 this.deviceType = deviceType;
871 private void stopAllJobs() {
874 stopPanelDiscoveryJob();