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