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