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