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