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