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