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