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