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