]> git.basschouten.com Git - openhab-addons.git/blob
c36ef3bcf1f181bf3aab3150e4b55fcdfcdede10
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2021 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
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
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.nanoleaf.internal.handler;
14
15 import static org.openhab.binding.nanoleaf.internal.NanoleafBindingConstants.*;
16
17 import java.net.URI;
18 import java.nio.ByteBuffer;
19 import java.nio.charset.StandardCharsets;
20 import java.util.Collection;
21 import java.util.List;
22 import java.util.Map;
23 import java.util.Objects;
24 import java.util.Scanner;
25 import java.util.concurrent.CopyOnWriteArrayList;
26 import java.util.concurrent.ExecutionException;
27 import java.util.concurrent.ScheduledFuture;
28 import java.util.concurrent.TimeUnit;
29 import java.util.concurrent.TimeoutException;
30
31 import org.eclipse.jdt.annotation.NonNullByDefault;
32 import org.eclipse.jdt.annotation.Nullable;
33 import org.eclipse.jetty.client.HttpClient;
34 import org.eclipse.jetty.client.api.ContentResponse;
35 import org.eclipse.jetty.client.api.Request;
36 import org.eclipse.jetty.client.api.Response;
37 import org.eclipse.jetty.client.api.Result;
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.NanoleafControllerListener;
42 import org.openhab.binding.nanoleaf.internal.NanoleafException;
43 import org.openhab.binding.nanoleaf.internal.NanoleafUnauthorizedException;
44 import org.openhab.binding.nanoleaf.internal.OpenAPIUtils;
45 import org.openhab.binding.nanoleaf.internal.commanddescription.NanoleafCommandDescriptionProvider;
46 import org.openhab.binding.nanoleaf.internal.config.NanoleafControllerConfig;
47 import org.openhab.binding.nanoleaf.internal.discovery.NanoleafPanelsDiscoveryService;
48 import org.openhab.binding.nanoleaf.internal.model.AuthToken;
49 import org.openhab.binding.nanoleaf.internal.model.BooleanState;
50 import org.openhab.binding.nanoleaf.internal.model.Brightness;
51 import org.openhab.binding.nanoleaf.internal.model.ControllerInfo;
52 import org.openhab.binding.nanoleaf.internal.model.Ct;
53 import org.openhab.binding.nanoleaf.internal.model.Effects;
54 import org.openhab.binding.nanoleaf.internal.model.Hue;
55 import org.openhab.binding.nanoleaf.internal.model.IntegerState;
56 import org.openhab.binding.nanoleaf.internal.model.Layout;
57 import org.openhab.binding.nanoleaf.internal.model.On;
58 import org.openhab.binding.nanoleaf.internal.model.Rhythm;
59 import org.openhab.binding.nanoleaf.internal.model.Sat;
60 import org.openhab.binding.nanoleaf.internal.model.State;
61 import org.openhab.binding.nanoleaf.internal.model.TouchEvents;
62 import org.openhab.core.config.core.Configuration;
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;
80
81 import com.google.gson.Gson;
82 import com.google.gson.JsonSyntaxException;
83
84 /**
85  * The {@link NanoleafControllerHandler} is responsible for handling commands to the controller which
86  * affect all panels connected to it (e.g. selected effect)
87  *
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
91  */
92 @NonNullByDefault
93 public class NanoleafControllerHandler extends BaseBridgeHandler {
94
95     // Pairing interval in seconds
96     private static final int PAIRING_INTERVAL = 10;
97
98     private final Logger logger = LoggerFactory.getLogger(NanoleafControllerHandler.class);
99     private HttpClient httpClient;
100     private List<NanoleafControllerListener> controllerListeners = new CopyOnWriteArrayList<>();
101
102     // Pairing, update and panel discovery jobs and touch event job
103     private @NonNullByDefault({}) ScheduledFuture<?> pairingJob;
104     private @NonNullByDefault({}) ScheduledFuture<?> updateJob;
105     private @NonNullByDefault({}) ScheduledFuture<?> touchJob;
106
107     // JSON parser for API responses
108     private final Gson gson = new Gson();
109
110     // Controller configuration settings and channel values
111     private @Nullable String address;
112     private int port;
113     private int refreshIntervall;
114     private @Nullable String authToken;
115     private @Nullable String deviceType;
116     private @NonNullByDefault({}) ControllerInfo controllerInfo;
117
118     public NanoleafControllerHandler(Bridge bridge, HttpClient httpClient) {
119         super(bridge);
120         this.httpClient = httpClient;
121     }
122
123     @Override
124     public void initialize() {
125         logger.debug("Initializing the controller (bridge)");
126         updateStatus(ThingStatus.UNKNOWN);
127         NanoleafControllerConfig config = getConfigAs(NanoleafControllerConfig.class);
128         setAddress(config.address);
129         setPort(config.port);
130         setRefreshIntervall(config.refreshInterval);
131         setAuthToken(config.authToken);
132
133         Map<String, String> properties = getThing().getProperties();
134         String propertyModelId = properties.get(Thing.PROPERTY_MODEL_ID);
135         if (hasTouchSupport(propertyModelId)) {
136             config.deviceType = DEVICE_TYPE_TOUCHSUPPORT;
137         } else {
138             config.deviceType = DEVICE_TYPE_LIGHTPANELS;
139         }
140         setDeviceType(config.deviceType);
141
142         String propertyFirmwareVersion = properties.get(Thing.PROPERTY_FIRMWARE_VERSION);
143
144         try {
145             if (config.address.isEmpty() || String.valueOf(config.port).isEmpty()) {
146                 logger.warn("No IP address and port configured for the Nanoleaf controller");
147                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_PENDING,
148                         "@text/error.nanoleaf.controller.noIp");
149                 stopAllJobs();
150             } else if (propertyFirmwareVersion != null && !propertyFirmwareVersion.isEmpty() && !OpenAPIUtils
151                     .checkRequiredFirmware(properties.get(Thing.PROPERTY_MODEL_ID), propertyFirmwareVersion)) {
152                 logger.warn("Nanoleaf controller firmware is too old: {}. Must be equal or higher than {}",
153                         propertyFirmwareVersion, API_MIN_FW_VER_LIGHTPANELS);
154                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
155                         "@text/error.nanoleaf.controller.incompatibleFirmware");
156                 stopAllJobs();
157             } else if (config.authToken == null || config.authToken.isEmpty()) {
158                 logger.debug("No token found. Start pairing background job");
159                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_PENDING,
160                         "@text/error.nanoleaf.controller.noToken");
161                 startPairingJob();
162                 stopUpdateJob();
163             } else {
164                 stopPairingJob();
165                 startUpdateJob();
166                 startTouchJob();
167             }
168         } catch (IllegalArgumentException iae) {
169             logger.warn("Nanoleaf controller firmware version not in format x.y.z: {}",
170                     getThing().getProperties().get(Thing.PROPERTY_FIRMWARE_VERSION));
171             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
172                     "@text/error.nanoleaf.controller.incompatibleFirmware");
173         }
174     }
175
176     @Override
177     public void handleCommand(ChannelUID channelUID, Command command) {
178         logger.debug("Received command {} for channel {}", command, channelUID);
179         if (!ThingStatus.ONLINE.equals(getThing().getStatusInfo().getStatus())) {
180             logger.debug("Cannot handle command. Bridge is not online.");
181             return;
182         }
183         try {
184             if (command instanceof RefreshType) {
185                 updateFromControllerInfo();
186             } else {
187                 switch (channelUID.getId()) {
188                     case CHANNEL_COLOR:
189                     case CHANNEL_COLOR_TEMPERATURE:
190                     case CHANNEL_COLOR_TEMPERATURE_ABS:
191                         sendStateCommand(channelUID.getId(), command);
192                         break;
193                     case CHANNEL_EFFECT:
194                         sendEffectCommand(command);
195                         break;
196                     case CHANNEL_RHYTHM_MODE:
197                         sendRhythmCommand(command);
198                         break;
199                     default:
200                         logger.warn("Channel with id {} not handled", channelUID.getId());
201                         break;
202                 }
203             }
204         } catch (NanoleafUnauthorizedException nae) {
205             logger.warn("Authorization for command {} to channelUID {} failed: {}", command, channelUID,
206                     nae.getMessage());
207             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
208                     "@text/error.nanoleaf.controller.invalidToken");
209         } catch (NanoleafException ne) {
210             logger.warn("Handling command {} to channelUID {} failed: {}", command, channelUID, ne.getMessage());
211             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
212                     "@text/error.nanoleaf.controller.communication");
213         }
214     }
215
216     @Override
217     public void handleRemoval() {
218         scheduler.execute(() -> {
219             // delete token for openHAB
220             ContentResponse deleteTokenResponse;
221             try {
222                 Request deleteTokenRequest = OpenAPIUtils.requestBuilder(httpClient, getControllerConfig(),
223                         API_DELETE_USER, HttpMethod.DELETE);
224                 deleteTokenResponse = OpenAPIUtils.sendOpenAPIRequest(deleteTokenRequest);
225                 if (deleteTokenResponse.getStatus() != HttpStatus.NO_CONTENT_204) {
226                     logger.warn("Failed to delete token for openHAB. Response code is {}",
227                             deleteTokenResponse.getStatus());
228                     return;
229                 }
230                 logger.debug("Successfully deleted token for openHAB from controller");
231             } catch (NanoleafUnauthorizedException e) {
232                 logger.warn("Attempt to delete token for openHAB failed. Token unauthorized.");
233             } catch (NanoleafException ne) {
234                 logger.warn("Attempt to delete token for openHAB failed : {}", ne.getMessage());
235             }
236             stopAllJobs();
237             super.handleRemoval();
238             logger.debug("Nanoleaf controller removed");
239         });
240     }
241
242     @Override
243     public void dispose() {
244         stopAllJobs();
245         super.dispose();
246         logger.debug("Disposing handler for Nanoleaf controller {}", getThing().getUID());
247     }
248
249     @Override
250     public Collection<Class<? extends ThingHandlerService>> getServices() {
251         return List.of(NanoleafPanelsDiscoveryService.class, NanoleafCommandDescriptionProvider.class);
252     }
253
254     public boolean registerControllerListener(NanoleafControllerListener controllerListener) {
255         logger.debug("Register new listener for controller {}", getThing().getUID());
256         return controllerListeners.add(controllerListener);
257     }
258
259     public boolean unregisterControllerListener(NanoleafControllerListener controllerListener) {
260         logger.debug("Unregister listener for controller {}", getThing().getUID());
261         return controllerListeners.remove(controllerListener);
262     }
263
264     public NanoleafControllerConfig getControllerConfig() {
265         NanoleafControllerConfig config = new NanoleafControllerConfig();
266         config.address = Objects.requireNonNullElse(getAddress(), "");
267         config.port = getPort();
268         config.refreshInterval = getRefreshInterval();
269         config.authToken = getAuthToken();
270         config.deviceType = Objects.requireNonNullElse(getDeviceType(), "");
271         return config;
272     }
273
274     public String getLayout() {
275         Layout layout = controllerInfo.getPanelLayout().getLayout();
276         String layoutView = (layout != null) ? layout.getLayoutView() : "";
277         return layoutView;
278     }
279
280     public synchronized void startPairingJob() {
281         if (pairingJob == null || pairingJob.isCancelled()) {
282             logger.debug("Start pairing job, interval={} sec", PAIRING_INTERVAL);
283             pairingJob = scheduler.scheduleWithFixedDelay(this::runPairing, 0, PAIRING_INTERVAL, TimeUnit.SECONDS);
284         }
285     }
286
287     private synchronized void stopPairingJob() {
288         if (pairingJob != null && !pairingJob.isCancelled()) {
289             logger.debug("Stop pairing job");
290             pairingJob.cancel(true);
291             this.pairingJob = null;
292         }
293     }
294
295     private synchronized void startUpdateJob() {
296         String localAuthToken = getAuthToken();
297         if (localAuthToken != null && !localAuthToken.isEmpty()) {
298             if (updateJob == null || updateJob.isCancelled()) {
299                 logger.debug("Start controller status job, repeat every {} sec", getRefreshInterval());
300                 updateJob = scheduler.scheduleWithFixedDelay(this::runUpdate, 0, getRefreshInterval(),
301                         TimeUnit.SECONDS);
302             }
303         } else {
304             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_PENDING,
305                     "@text/error.nanoleaf.controller.noToken");
306         }
307     }
308
309     private synchronized void stopUpdateJob() {
310         if (updateJob != null && !updateJob.isCancelled()) {
311             logger.debug("Stop status job");
312             updateJob.cancel(true);
313             this.updateJob = null;
314         }
315     }
316
317     private synchronized void startTouchJob() {
318         NanoleafControllerConfig config = getConfigAs(NanoleafControllerConfig.class);
319         if (!config.deviceType.equals(DEVICE_TYPE_TOUCHSUPPORT)) {
320             logger.debug("NOT starting TouchJob for Panel {} because it has wrong device type '{}' vs required '{}'",
321                     this.getThing().getUID(), config.deviceType, DEVICE_TYPE_TOUCHSUPPORT);
322             return;
323         } else {
324             logger.debug("Starting TouchJob for Panel {}", this.getThing().getUID());
325         }
326
327         String localAuthToken = getAuthToken();
328         if (localAuthToken != null && !localAuthToken.isEmpty()) {
329             if (touchJob == null || touchJob.isCancelled()) {
330                 logger.debug("Starting Touchjob now");
331                 touchJob = scheduler.schedule(this::runTouchDetection, 0, TimeUnit.SECONDS);
332             }
333         } else {
334             logger.error("starting TouchJob for Controller {} failed - missing token", this.getThing().getUID());
335         }
336     }
337
338     private boolean hasTouchSupport(@Nullable String deviceType) {
339         return (MODELS_WITH_TOUCHSUPPORT.contains(deviceType));
340     }
341
342     private synchronized void stopTouchJob() {
343         if (touchJob != null && !touchJob.isCancelled()) {
344             logger.debug("Stop touch job");
345             touchJob.cancel(true);
346             this.touchJob = null;
347         }
348     }
349
350     private void runUpdate() {
351         logger.debug("Run update job");
352         try {
353             updateFromControllerInfo();
354             startTouchJob(); // if device type has changed, start touch detection.
355             updateStatus(ThingStatus.ONLINE);
356         } catch (NanoleafUnauthorizedException nae) {
357             logger.warn("Status update unauthorized: {}", nae.getMessage());
358             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
359                     "@text/error.nanoleaf.controller.invalidToken");
360             String localAuthToken = getAuthToken();
361             if (localAuthToken == null || localAuthToken.isEmpty()) {
362                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_PENDING,
363                         "@text/error.nanoleaf.controller.noToken");
364             }
365         } catch (NanoleafException ne) {
366             logger.warn("Status update failed: {}", ne.getMessage());
367             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
368                     "@text/error.nanoleaf.controller.communication");
369         } catch (RuntimeException e) {
370             logger.warn("Update job failed", e);
371             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, "@text/error.nanoleaf.controller.runtime");
372         }
373     }
374
375     private void runPairing() {
376         logger.debug("Run pairing job");
377         try {
378             String localAuthToken = getAuthToken();
379             if (localAuthToken != null && !localAuthToken.isEmpty()) {
380                 if (pairingJob != null) {
381                     pairingJob.cancel(false);
382                 }
383                 logger.debug("Authentication token found. Canceling pairing job");
384                 return;
385             }
386             ContentResponse authTokenResponse = OpenAPIUtils
387                     .requestBuilder(httpClient, getControllerConfig(), API_ADD_USER, HttpMethod.POST)
388                     .timeout(20, TimeUnit.SECONDS).send();
389             if (logger.isTraceEnabled()) {
390                 logger.trace("Auth token response: {}", authTokenResponse.getContentAsString());
391             }
392
393             if (authTokenResponse.getStatus() != HttpStatus.OK_200) {
394                 logger.debug("Pairing pending for {}. Controller returns status code {}", this.getThing().getUID(),
395                         authTokenResponse.getStatus());
396             } else {
397                 // get auth token from response
398                 AuthToken authTokenObject = gson.fromJson(authTokenResponse.getContentAsString(), AuthToken.class);
399                 localAuthToken = authTokenObject.getAuthToken();
400                 if (localAuthToken != null && !localAuthToken.isEmpty()) {
401                     logger.debug("Pairing succeeded.");
402
403                     // Update and save the auth token in the thing configuration
404                     Configuration config = editConfiguration();
405                     config.put(NanoleafControllerConfig.AUTH_TOKEN, localAuthToken);
406                     updateConfiguration(config);
407
408                     updateStatus(ThingStatus.ONLINE);
409                     // Update local field
410                     setAuthToken(localAuthToken);
411
412                     stopPairingJob();
413                     startUpdateJob();
414                     startTouchJob();
415                 } else {
416                     logger.debug("No auth token found in response: {}", authTokenResponse.getContentAsString());
417                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
418                             "@text/error.nanoleaf.controller.pairingFailed");
419                     throw new NanoleafException(authTokenResponse.getContentAsString());
420                 }
421             }
422         } catch (JsonSyntaxException e) {
423             logger.warn("Received invalid data", e);
424             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
425                     "@text/error.nanoleaf.controller.invalidData");
426         } catch (NanoleafException e) {
427             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
428                     "@text/error.nanoleaf.controller.noTokenReceived");
429         } catch (InterruptedException | ExecutionException | TimeoutException e) {
430             logger.debug("Cannot send authorization request to controller: ", e);
431             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
432                     "@text/error.nanoleaf.controller.authRequest");
433         } catch (RuntimeException e) {
434             logger.warn("Pairing job failed", e);
435             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, "@text/error.nanoleaf.controller.runtime");
436         } catch (Exception e) {
437             logger.warn("Cannot start http client", e);
438             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
439                     "@text/error.nanoleaf.controller.noClient");
440         }
441     }
442
443     /**
444      * This is based on the touch event detection described in https://forum.nanoleaf.me/docs/openapi#_842h3097vbgq
445      */
446     private static boolean touchJobRunning = false;
447
448     private void runTouchDetection() {
449         if (touchJobRunning) {
450             logger.debug("touch job already running. quitting.");
451             return;
452         }
453         try {
454             touchJobRunning = true;
455             URI eventUri = OpenAPIUtils.getUri(getControllerConfig(), API_EVENTS, "id=4");
456             logger.debug("touch job registered on: {}", eventUri.toString());
457             httpClient.newRequest(eventUri).send(new Response.Listener.Adapter() // request runs forever
458             {
459                 @Override
460                 public void onContent(@Nullable Response response, @Nullable ByteBuffer content) {
461                     String s = StandardCharsets.UTF_8.decode(content).toString();
462                     logger.trace("content {}", s);
463
464                     Scanner eventContent = new Scanner(s);
465                     while (eventContent.hasNextLine()) {
466                         String line = eventContent.nextLine().trim();
467                         // we don't expect anything than content id:4, so we do not check that but only care about the
468                         // data part
469                         if (line.startsWith("data:")) {
470                             String json = line.substring(5).trim(); // supposed to be JSON
471                             try {
472                                 TouchEvents touchEvents = gson.fromJson(json, TouchEvents.class);
473                                 handleTouchEvents(Objects.requireNonNull(touchEvents));
474                             } catch (JsonSyntaxException jse) {
475                                 logger.error("couldn't parse touch event json {}", json);
476                             }
477                         }
478                     }
479                     eventContent.close();
480                     logger.debug("leaving touch onContent");
481                     super.onContent(response, content);
482                 }
483
484                 @Override
485                 public void onSuccess(@Nullable Response response) {
486                     logger.trace("touch event SUCCESS: {}", response);
487                 }
488
489                 @Override
490                 public void onFailure(@Nullable Response response, @Nullable Throwable failure) {
491                     logger.trace("touch event FAILURE: {}", response);
492                 }
493
494                 @Override
495                 public void onComplete(@Nullable Result result) {
496                     logger.trace("touch event COMPLETE: {}", result);
497                 }
498             });
499         } catch (RuntimeException | NanoleafException e) {
500             logger.warn("setting up TouchDetection failed", e);
501         } finally {
502             touchJobRunning = false;
503         }
504         logger.debug("leaving run touch detection");
505     }
506
507     /**
508      * Interate over all gathered touch events and apply them to the panel they belong to
509      *
510      * @param touchEvents
511      */
512     private void handleTouchEvents(TouchEvents touchEvents) {
513         touchEvents.getEvents().forEach(event -> {
514             logger.info("panel: {} gesture id: {}", event.getPanelId(), event.getGesture());
515
516             // Iterate over all child things = all panels of that controller
517             this.getThing().getThings().forEach(child -> {
518                 NanoleafPanelHandler panelHandler = (NanoleafPanelHandler) child.getHandler();
519                 if (panelHandler != null) {
520                     logger.trace("Checking available panel -{}- versus event panel -{}-", panelHandler.getPanelID(),
521                             event.getPanelId());
522                     if (panelHandler.getPanelID().equals(event.getPanelId())) {
523                         logger.debug("Panel {} found. Triggering item with gesture {}.", panelHandler.getPanelID(),
524                                 event.getGesture());
525                         panelHandler.updatePanelGesture(event.getGesture());
526                     }
527                 }
528             });
529         });
530     }
531
532     private void updateFromControllerInfo() throws NanoleafException {
533         logger.debug("Update channels for controller {}", thing.getUID());
534         this.controllerInfo = receiveControllerInfo();
535         final State state = controllerInfo.getState();
536
537         OnOffType powerState = state.getOnOff();
538
539         @Nullable
540         Ct colorTemperature = state.getColorTemperature();
541
542         float colorTempPercent = 0f;
543         if (colorTemperature != null) {
544             updateState(CHANNEL_COLOR_TEMPERATURE_ABS, new DecimalType(colorTemperature.getValue()));
545
546             @Nullable
547             Integer min = colorTemperature.getMin();
548             int colorMin = (min == null) ? 0 : min;
549
550             @Nullable
551             Integer max = colorTemperature.getMax();
552             int colorMax = (max == null) ? 0 : max;
553
554             colorTempPercent = (colorTemperature.getValue() - colorMin) / (colorMax - colorMin)
555                     * PercentType.HUNDRED.intValue();
556         }
557
558         updateState(CHANNEL_COLOR_TEMPERATURE, new PercentType(Float.toString(colorTempPercent)));
559         updateState(CHANNEL_EFFECT, new StringType(controllerInfo.getEffects().getSelect()));
560
561         @Nullable
562         Hue stateHue = state.getHue();
563         int hue = (stateHue != null) ? stateHue.getValue() : 0;
564         @Nullable
565         Sat stateSaturation = state.getSaturation();
566         int saturation = (stateSaturation != null) ? stateSaturation.getValue() : 0;
567         @Nullable
568         Brightness stateBrightness = state.getBrightness();
569         int brightness = (stateBrightness != null) ? stateBrightness.getValue() : 0;
570
571         updateState(CHANNEL_COLOR, new HSBType(new DecimalType(hue), new PercentType(saturation),
572                 new PercentType(powerState == OnOffType.ON ? brightness : 0)));
573         updateState(CHANNEL_COLOR_MODE, new StringType(state.getColorMode()));
574         updateState(CHANNEL_RHYTHM_ACTIVE, controllerInfo.getRhythm().getRhythmActive() ? OnOffType.ON : OnOffType.OFF);
575         updateState(CHANNEL_RHYTHM_MODE, new DecimalType(controllerInfo.getRhythm().getRhythmMode()));
576         updateState(CHANNEL_RHYTHM_STATE,
577                 controllerInfo.getRhythm().getRhythmConnected() ? OnOffType.ON : OnOffType.OFF);
578         // update bridge properties which may have changed, or are not present during discovery
579         Map<String, String> properties = editProperties();
580         properties.put(Thing.PROPERTY_SERIAL_NUMBER, controllerInfo.getSerialNo());
581         properties.put(Thing.PROPERTY_FIRMWARE_VERSION, controllerInfo.getFirmwareVersion());
582         properties.put(Thing.PROPERTY_MODEL_ID, controllerInfo.getModel());
583         properties.put(Thing.PROPERTY_VENDOR, controllerInfo.getManufacturer());
584         updateProperties(properties);
585
586         Configuration config = editConfiguration();
587
588         if (hasTouchSupport(controllerInfo.getModel())) {
589             config.put(NanoleafControllerConfig.DEVICE_TYPE, DEVICE_TYPE_TOUCHSUPPORT);
590             logger.debug("Set to device type {}", DEVICE_TYPE_TOUCHSUPPORT);
591         } else {
592             config.put(NanoleafControllerConfig.DEVICE_TYPE, DEVICE_TYPE_LIGHTPANELS);
593             logger.debug("Set to device type {}", DEVICE_TYPE_LIGHTPANELS);
594         }
595         updateConfiguration(config);
596
597         getConfig().getProperties().forEach((key, value) -> {
598             logger.trace("Configuration property: key {} value {}", key, value);
599         });
600
601         getThing().getProperties().forEach((key, value) -> {
602             logger.debug("Thing property:  key {} value {}", key, value);
603         });
604
605         // update the color channels of each panel
606         this.getThing().getThings().forEach(child -> {
607             NanoleafPanelHandler panelHandler = (NanoleafPanelHandler) child.getHandler();
608             if (panelHandler != null) {
609                 logger.debug("Update color channel for panel {}", panelHandler.getThing().getUID());
610                 panelHandler.updatePanelColorChannel();
611             }
612         });
613
614         for (NanoleafControllerListener controllerListener : controllerListeners) {
615             controllerListener.onControllerInfoFetched(getThing().getUID(), controllerInfo);
616         }
617     }
618
619     private ControllerInfo receiveControllerInfo() throws NanoleafException, NanoleafUnauthorizedException {
620         ContentResponse controllerlInfoJSON = OpenAPIUtils.sendOpenAPIRequest(OpenAPIUtils.requestBuilder(httpClient,
621                 getControllerConfig(), API_GET_CONTROLLER_INFO, HttpMethod.GET));
622         ControllerInfo controllerInfo = gson.fromJson(controllerlInfoJSON.getContentAsString(), ControllerInfo.class);
623         return Objects.requireNonNull(controllerInfo);
624     }
625
626     private void sendStateCommand(String channel, Command command) throws NanoleafException {
627         State stateObject = new State();
628         switch (channel) {
629             case CHANNEL_COLOR:
630                 if (command instanceof OnOffType) {
631                     // On/Off command - turns controller on/off
632                     BooleanState state = new On();
633                     state.setValue(OnOffType.ON.equals(command));
634                     stateObject.setState(state);
635                 } else if (command instanceof HSBType) {
636                     // regular color HSB command
637                     IntegerState h = new Hue();
638                     IntegerState s = new Sat();
639                     IntegerState b = new Brightness();
640                     h.setValue(((HSBType) command).getHue().intValue());
641                     s.setValue(((HSBType) command).getSaturation().intValue());
642                     b.setValue(((HSBType) command).getBrightness().intValue());
643                     stateObject.setState(h);
644                     stateObject.setState(s);
645                     stateObject.setState(b);
646                 } else if (command instanceof PercentType) {
647                     // brightness command
648                     IntegerState b = new Brightness();
649                     b.setValue(((PercentType) command).intValue());
650                     stateObject.setState(b);
651                 } else if (command instanceof IncreaseDecreaseType) {
652                     // increase/decrease brightness
653                     if (controllerInfo != null) {
654                         @Nullable
655                         Brightness brightness = controllerInfo.getState().getBrightness();
656                         int brightnessMin = 0;
657                         int brightnessMax = 0;
658                         if (brightness != null) {
659                             @Nullable
660                             Integer min = brightness.getMin();
661                             brightnessMin = (min == null) ? 0 : min;
662                             @Nullable
663                             Integer max = brightness.getMax();
664                             brightnessMax = (max == null) ? 0 : max;
665
666                             if (IncreaseDecreaseType.INCREASE.equals(command)) {
667                                 brightness.setValue(
668                                         Math.min(brightnessMax, brightness.getValue() + BRIGHTNESS_STEP_SIZE));
669                             } else {
670                                 brightness.setValue(
671                                         Math.max(brightnessMin, brightness.getValue() - BRIGHTNESS_STEP_SIZE));
672                             }
673                             stateObject.setState(brightness);
674                             logger.debug("Setting controller brightness to {}", brightness.getValue());
675                             // update controller info in case new command is sent before next update job interval
676                             controllerInfo.getState().setBrightness(brightness);
677                         } else {
678                             logger.debug("Couldn't set brightness as it was null!");
679                         }
680                     }
681                 } else {
682                     logger.warn("Unhandled command type: {}", command.getClass().getName());
683                     return;
684                 }
685                 break;
686             case CHANNEL_COLOR_TEMPERATURE:
687                 if (command instanceof PercentType) {
688                     // Color temperature (percent)
689                     IntegerState state = new Ct();
690                     @Nullable
691                     Ct colorTemperature = controllerInfo.getState().getColorTemperature();
692
693                     int colorMin = 0;
694                     int colorMax = 0;
695                     if (colorTemperature != null) {
696                         @Nullable
697                         Integer min = colorTemperature.getMin();
698                         colorMin = (min == null) ? 0 : min;
699
700                         @Nullable
701                         Integer max = colorTemperature.getMax();
702                         colorMax = (max == null) ? 0 : max;
703                     }
704
705                     state.setValue(Math.round((colorMax - colorMin) * (100 - ((PercentType) command).intValue())
706                             / PercentType.HUNDRED.floatValue() + colorMin));
707                     stateObject.setState(state);
708                 } else {
709                     logger.warn("Unhandled command type: {}", command.getClass().getName());
710                     return;
711                 }
712                 break;
713             case CHANNEL_COLOR_TEMPERATURE_ABS:
714                 if (command instanceof DecimalType) {
715                     // Color temperature (absolute)
716                     IntegerState state = new Ct();
717                     state.setValue(((DecimalType) command).intValue());
718                     stateObject.setState(state);
719                 } else {
720                     logger.warn("Unhandled command type: {}", command.getClass().getName());
721                     return;
722                 }
723                 break;
724             default:
725                 logger.warn("Unhandled command type: {}", command.getClass().getName());
726                 return;
727         }
728
729         Request setNewStateRequest = OpenAPIUtils.requestBuilder(httpClient, getControllerConfig(), API_SET_VALUE,
730                 HttpMethod.PUT);
731         setNewStateRequest.content(new StringContentProvider(gson.toJson(stateObject)), "application/json");
732         OpenAPIUtils.sendOpenAPIRequest(setNewStateRequest);
733     }
734
735     private void sendEffectCommand(Command command) throws NanoleafException {
736         Effects effects = new Effects();
737         if (command instanceof StringType) {
738             effects.setSelect(command.toString());
739         } else {
740             logger.warn("Unhandled command type: {}", command.getClass().getName());
741             return;
742         }
743         Request setNewEffectRequest = OpenAPIUtils.requestBuilder(httpClient, getControllerConfig(), API_EFFECT,
744                 HttpMethod.PUT);
745         String content = gson.toJson(effects);
746         logger.debug("sending effect command from controller {}: {}", getThing().getUID(), content);
747         setNewEffectRequest.content(new StringContentProvider(content), "application/json");
748         OpenAPIUtils.sendOpenAPIRequest(setNewEffectRequest);
749     }
750
751     private void sendRhythmCommand(Command command) throws NanoleafException {
752         Rhythm rhythm = new Rhythm();
753         if (command instanceof DecimalType) {
754             rhythm.setRhythmMode(((DecimalType) command).intValue());
755         } else {
756             logger.warn("Unhandled command type: {}", command.getClass().getName());
757             return;
758         }
759         Request setNewRhythmRequest = OpenAPIUtils.requestBuilder(httpClient, getControllerConfig(), API_RHYTHM_MODE,
760                 HttpMethod.PUT);
761         setNewRhythmRequest.content(new StringContentProvider(gson.toJson(rhythm)), "application/json");
762         OpenAPIUtils.sendOpenAPIRequest(setNewRhythmRequest);
763     }
764
765     private @Nullable String getAddress() {
766         return address;
767     }
768
769     private void setAddress(String address) {
770         this.address = address;
771     }
772
773     private int getPort() {
774         return port;
775     }
776
777     private void setPort(int port) {
778         this.port = port;
779     }
780
781     private int getRefreshInterval() {
782         return refreshIntervall;
783     }
784
785     private void setRefreshIntervall(int refreshIntervall) {
786         this.refreshIntervall = refreshIntervall;
787     }
788
789     private @Nullable String getAuthToken() {
790         return authToken;
791     }
792
793     private void setAuthToken(@Nullable String authToken) {
794         this.authToken = authToken;
795     }
796
797     private @Nullable String getDeviceType() {
798         return deviceType;
799     }
800
801     private void setDeviceType(String deviceType) {
802         this.deviceType = deviceType;
803     }
804
805     private void stopAllJobs() {
806         stopPairingJob();
807         stopUpdateJob();
808         stopTouchJob();
809     }
810 }