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