]> git.basschouten.com Git - openhab-addons.git/blob
70a7eca096aab1dfd24926de084b1b806861e8ad
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2024 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.hue.internal.connection;
14
15 import static org.openhab.binding.hue.internal.HueBindingConstants.*;
16
17 import java.io.FileNotFoundException;
18 import java.io.IOException;
19 import java.lang.reflect.Type;
20 import java.net.URI;
21 import java.net.URISyntaxException;
22 import java.net.URLEncoder;
23 import java.nio.charset.StandardCharsets;
24 import java.text.ParseException;
25 import java.text.SimpleDateFormat;
26 import java.util.ArrayList;
27 import java.util.Comparator;
28 import java.util.Date;
29 import java.util.LinkedList;
30 import java.util.List;
31 import java.util.Map;
32 import java.util.Objects;
33 import java.util.concurrent.CompletableFuture;
34 import java.util.concurrent.ExecutionException;
35 import java.util.concurrent.Future;
36 import java.util.concurrent.ScheduledExecutorService;
37 import java.util.concurrent.TimeUnit;
38 import java.util.concurrent.TimeoutException;
39 import java.util.stream.Collectors;
40
41 import javax.net.ssl.SSLHandshakeException;
42
43 import org.eclipse.jdt.annotation.NonNullByDefault;
44 import org.eclipse.jdt.annotation.Nullable;
45 import org.eclipse.jetty.client.HttpClient;
46 import org.eclipse.jetty.client.api.ContentResponse;
47 import org.eclipse.jetty.client.api.Request;
48 import org.eclipse.jetty.client.util.StringContentProvider;
49 import org.eclipse.jetty.http.HttpMethod;
50 import org.eclipse.jetty.http.HttpStatus;
51 import org.openhab.binding.hue.internal.api.dto.clip1.ApiVersion;
52 import org.openhab.binding.hue.internal.api.dto.clip1.ApiVersionUtils;
53 import org.openhab.binding.hue.internal.api.dto.clip1.Config;
54 import org.openhab.binding.hue.internal.api.dto.clip1.ConfigUpdate;
55 import org.openhab.binding.hue.internal.api.dto.clip1.CreateUserRequest;
56 import org.openhab.binding.hue.internal.api.dto.clip1.ErrorResponse;
57 import org.openhab.binding.hue.internal.api.dto.clip1.FullConfig;
58 import org.openhab.binding.hue.internal.api.dto.clip1.FullGroup;
59 import org.openhab.binding.hue.internal.api.dto.clip1.FullHueObject;
60 import org.openhab.binding.hue.internal.api.dto.clip1.FullLight;
61 import org.openhab.binding.hue.internal.api.dto.clip1.FullSensor;
62 import org.openhab.binding.hue.internal.api.dto.clip1.Group;
63 import org.openhab.binding.hue.internal.api.dto.clip1.HueObject;
64 import org.openhab.binding.hue.internal.api.dto.clip1.NewLightsResponse;
65 import org.openhab.binding.hue.internal.api.dto.clip1.Scene;
66 import org.openhab.binding.hue.internal.api.dto.clip1.Schedule;
67 import org.openhab.binding.hue.internal.api.dto.clip1.ScheduleUpdate;
68 import org.openhab.binding.hue.internal.api.dto.clip1.SearchForLightsRequest;
69 import org.openhab.binding.hue.internal.api.dto.clip1.SetAttributesRequest;
70 import org.openhab.binding.hue.internal.api.dto.clip1.StateUpdate;
71 import org.openhab.binding.hue.internal.api.dto.clip1.SuccessResponse;
72 import org.openhab.binding.hue.internal.api.dto.clip1.Util;
73 import org.openhab.binding.hue.internal.exceptions.ApiException;
74 import org.openhab.binding.hue.internal.exceptions.DeviceOffException;
75 import org.openhab.binding.hue.internal.exceptions.EmptyResponseException;
76 import org.openhab.binding.hue.internal.exceptions.EntityNotAvailableException;
77 import org.openhab.binding.hue.internal.exceptions.GroupTableFullException;
78 import org.openhab.binding.hue.internal.exceptions.InvalidCommandException;
79 import org.openhab.binding.hue.internal.exceptions.LinkButtonException;
80 import org.openhab.binding.hue.internal.exceptions.UnauthorizedException;
81 import org.openhab.core.i18n.CommunicationException;
82 import org.openhab.core.i18n.ConfigurationException;
83 import org.slf4j.Logger;
84 import org.slf4j.LoggerFactory;
85
86 import com.google.gson.Gson;
87 import com.google.gson.GsonBuilder;
88 import com.google.gson.JsonParseException;
89
90 /**
91  * Representation of a connection with a Hue Bridge.
92  *
93  * @author Q42 - Initial contribution
94  * @author Andre Fuechsel - search for lights with given serial number added
95  * @author Denis Dudnik - moved Jue library source code inside the smarthome Hue binding, minor code cleanup
96  * @author Samuel Leisering - added cached config and API-Version
97  * @author Laurent Garnier - change the return type of getGroups
98  */
99 @NonNullByDefault
100 public class HueBridge {
101
102     private final Logger logger = LoggerFactory.getLogger(HueBridge.class);
103
104     private static final String DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ss";
105
106     private final HttpClient httpClient;
107     private final String ip;
108     private final String baseUrl;
109     private @Nullable String username;
110     private long timeout = TimeUnit.MILLISECONDS.convert(5, TimeUnit.SECONDS);
111
112     private final Gson gson = new GsonBuilder().setDateFormat(DATE_FORMAT).create();
113
114     private final LinkedList<AsyncPutParameters> commandsQueue = new LinkedList<>();
115     private @Nullable Future<?> job;
116     private final ScheduledExecutorService scheduler;
117
118     private @Nullable Config cachedConfig;
119
120     /**
121      * Connect with a bridge as a new user.
122      *
123      * @param httpClient instance of the Jetty shared client
124      * @param ip ip address of bridge
125      * @param port port of bridge
126      * @param protocol protocol to connect to the bridge
127      * @param scheduler the ExecutorService to schedule commands
128      */
129     public HueBridge(HttpClient httpClient, String ip, int port, String protocol, ScheduledExecutorService scheduler) {
130         this.httpClient = httpClient;
131         this.ip = ip;
132         String baseUrl;
133         try {
134             URI uri = new URI(protocol, null, ip, port, "/api", null, null);
135             baseUrl = uri.toString();
136         } catch (URISyntaxException e) {
137             logger.error("exception during constructing URI protocol={}, host={}, port={}", protocol, ip, port, e);
138             baseUrl = protocol + "://" + ip + ":" + port + "/api";
139         }
140         this.baseUrl = baseUrl;
141         this.scheduler = scheduler;
142     }
143
144     /**
145      * Connect with a bridge as an existing user.
146      *
147      * The username is verified by requesting the list of lights.
148      * Use the ip only constructor and authenticate() function if
149      * you don't want to connect right now.
150      *
151      * @param httpClient instance of the Jetty shared client
152      * @param ip ip address of bridge
153      * @param port port of bridge
154      * @param protocol protocol to connect to the bridge
155      * @param username username to authenticate with
156      * @param scheduler the ExecutorService to schedule commands
157      */
158     public HueBridge(HttpClient httpClient, String ip, int port, String protocol, String username,
159             ScheduledExecutorService scheduler)
160             throws IOException, ApiException, ConfigurationException, UnauthorizedException {
161         this(httpClient, ip, port, protocol, scheduler);
162         authenticate(username);
163     }
164
165     /**
166      * Set the connect and read timeout for HTTP requests.
167      *
168      * @param timeout timeout in milliseconds or 0 for indefinitely
169      */
170     public void setTimeout(long timeout) {
171         this.timeout = timeout;
172     }
173
174     /**
175      * Returns the IP address of the bridge.
176      *
177      * @return ip address of bridge
178      */
179     public String getIPAddress() {
180         return ip;
181     }
182
183     public ApiVersion getVersion() throws IOException, ApiException {
184         Config c = getCachedConfig();
185         return ApiVersion.of(c.getApiVersion());
186     }
187
188     /**
189      * Returns a cached version of the basic {@link Config} mostly immutable configuration.
190      * This can be used to reduce load on the bridge.
191      *
192      * @return The {@link Config} of the Hue Bridge, loaded and cached lazily on the first call
193      * @throws IOException
194      * @throws ApiException
195      */
196     private Config getCachedConfig() throws IOException, ApiException {
197         if (cachedConfig == null) {
198             cachedConfig = getConfig();
199         }
200
201         return Objects.requireNonNull(cachedConfig);
202     }
203
204     /**
205      * Returns the username currently authenticated with or null if there isn't one.
206      *
207      * @return username or null
208      */
209     public @Nullable String getUsername() {
210         return username;
211     }
212
213     /**
214      * Returns if authentication was successful on the bridge.
215      *
216      * @return true if authenticated on the bridge, false otherwise
217      */
218     public boolean isAuthenticated() {
219         return getUsername() != null;
220     }
221
222     /**
223      * Returns a list of lights known to the bridge.
224      *
225      * @return list of known lights as {@link FullLight}s
226      * @throws UnauthorizedException thrown if the user no longer exists
227      */
228     public List<FullLight> getFullLights() throws IOException, ApiException {
229         if (ApiVersionUtils.supportsFullLights(getVersion())) {
230             Type gsonType = FullLight.GSON_TYPE;
231             return getTypedLights(gsonType);
232         } else {
233             return getFullConfig().getLights();
234         }
235     }
236
237     /**
238      * Returns a list of lights known to the bridge.
239      *
240      * @return list of known lights
241      * @throws UnauthorizedException thrown if the user no longer exists
242      */
243     public List<HueObject> getLights() throws IOException, ApiException {
244         Type gsonType = HueObject.GSON_TYPE;
245         return getTypedLights(gsonType);
246     }
247
248     private <T extends HueObject> List<T> getTypedLights(Type gsonType)
249             throws IOException, ApiException, ConfigurationException, CommunicationException {
250         requireAuthentication();
251
252         HueResult result = get(getRelativeURL("lights"));
253
254         handleErrors(result);
255
256         if (result.body.isBlank()) {
257             throw new EmptyResponseException("GET request 'lights' returned an unexpected empty reponse");
258         }
259
260         Map<String, T> lightMap = safeFromJson(result.body, gsonType);
261         List<T> lights = new ArrayList<>();
262         lightMap.forEach((id, light) -> {
263             light.setId(id);
264             lights.add(light);
265         });
266         return lights;
267     }
268
269     /**
270      * Returns a list of sensors known to the bridge
271      *
272      * @return list of sensors
273      * @throws UnauthorizedException thrown if the user no longer exists
274      */
275     public List<FullSensor> getSensors()
276             throws IOException, ApiException, ConfigurationException, CommunicationException {
277         requireAuthentication();
278
279         HueResult result = get(getRelativeURL("sensors"));
280
281         handleErrors(result);
282
283         if (result.body.isBlank()) {
284             throw new EmptyResponseException("GET request 'sensors' returned an unexpected empty reponse");
285         }
286
287         Map<String, FullSensor> sensorMap = safeFromJson(result.body, FullSensor.GSON_TYPE);
288         List<FullSensor> sensors = new ArrayList<>();
289         sensorMap.forEach((id, sensor) -> {
290             sensor.setId(id);
291             sensors.add(sensor);
292         });
293         return sensors;
294     }
295
296     /**
297      * Returns the last time a search for new lights was started.
298      * If a search is currently running, the current time will be
299      * returned or null if a search has never been started.
300      *
301      * @return last search time
302      * @throws UnauthorizedException thrown if the user no longer exists
303      */
304     public @Nullable Date getLastSearch()
305             throws IOException, ApiException, ConfigurationException, CommunicationException {
306         requireAuthentication();
307
308         HueResult result = get(getRelativeURL("lights/new"));
309
310         handleErrors(result);
311
312         if (result.body.isBlank()) {
313             throw new EmptyResponseException("GET request 'lights/new' returned an unexpected empty reponse");
314         }
315
316         String lastScan = safeFromJson(result.body, NewLightsResponse.class).lastscan;
317
318         switch (lastScan) {
319             case "none":
320                 return null;
321             case "active":
322                 return new Date();
323             default:
324                 try {
325                     return new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss").parse(lastScan);
326                 } catch (ParseException e) {
327                     return null;
328                 }
329         }
330     }
331
332     /**
333      * Start searching for new lights for 1 minute.
334      * A maximum amount of 15 new lights will be added.
335      *
336      * @throws UnauthorizedException thrown if the user no longer exists
337      */
338     public void startSearch() throws IOException, ApiException, ConfigurationException, CommunicationException {
339         requireAuthentication();
340
341         HueResult result = post(getRelativeURL("lights"), "");
342
343         handleErrors(result);
344     }
345
346     /**
347      * Start searching for new lights with given serial numbers for 1 minute.
348      * A maximum amount of 15 new lights will be added.
349      *
350      * @param serialNumbers list of serial numbers
351      * @throws UnauthorizedException thrown if the user no longer exists
352      */
353     public void startSearch(List<String> serialNumbers)
354             throws IOException, ApiException, ConfigurationException, CommunicationException {
355         requireAuthentication();
356
357         HueResult result = post(getRelativeURL("lights"), gson.toJson(new SearchForLightsRequest(serialNumbers)));
358
359         handleErrors(result);
360     }
361
362     /**
363      * Returns detailed information for the given light.
364      *
365      * @param light light
366      * @return detailed light information
367      * @throws UnauthorizedException thrown if the user no longer exists
368      * @throws EntityNotAvailableException thrown if a light with the given id doesn't exist
369      */
370     public FullHueObject getLight(HueObject light)
371             throws IOException, ApiException, ConfigurationException, CommunicationException {
372         requireAuthentication();
373
374         HueResult result = get(getRelativeURL("lights/" + enc(light.getId())));
375
376         handleErrors(result);
377
378         if (result.body.isBlank()) {
379             throw new EmptyResponseException(
380                     "GET request 'lights/" + enc(light.getId()) + "' returned an unexpected empty reponse");
381         }
382
383         FullHueObject fullLight = safeFromJson(result.body, FullLight.class);
384         fullLight.setId(light.getId());
385         return fullLight;
386     }
387
388     /**
389      * Changes the name of the light and returns the new name.
390      * A number will be appended to duplicate names, which may result in a new name exceeding 32 characters.
391      *
392      * @param light light
393      * @param name new name [0..32]
394      * @return new name
395      * @throws UnauthorizedException thrown if the user no longer exists
396      * @throws EntityNotAvailableException thrown if the specified light no longer exists
397      */
398     public String setLightName(HueObject light, String name)
399             throws IOException, ApiException, ConfigurationException, CommunicationException {
400         requireAuthentication();
401
402         HueResult result = put(getRelativeURL("lights/" + enc(light.getId())),
403                 gson.toJson(new SetAttributesRequest(name)));
404
405         handleErrors(result);
406
407         if (result.body.isBlank()) {
408             throw new EmptyResponseException(
409                     "PUT request 'lights/" + enc(light.getId()) + "' returned an unexpected empty reponse");
410         }
411
412         List<SuccessResponse> entries = safeFromJson(result.body, SuccessResponse.GSON_TYPE);
413         SuccessResponse response = entries.get(0);
414
415         String lightName = (String) response.success.get("/lights/" + enc(light.getId()) + "/name");
416         if (lightName == null) {
417             throw new ApiException("Response didn't contain light name.");
418         }
419         return lightName;
420     }
421
422     /**
423      * Changes the state of a light.
424      *
425      * @param light light
426      * @param update changes to the state
427      * @throws UnauthorizedException thrown if the user no longer exists
428      * @throws EntityNotAvailableException thrown if the specified light no longer exists
429      * @throws DeviceOffException thrown if the specified light is turned off
430      * @throws IOException if the bridge cannot be reached
431      */
432     public CompletableFuture<HueResult> setLightState(FullLight light, StateUpdate update) {
433         requireAuthentication();
434
435         return putAsync(getRelativeURL("lights/" + enc(light.getId()) + "/state"), update.toJson(),
436                 update.getMessageDelay());
437     }
438
439     /**
440      * Changes the state of a clip sensor.
441      *
442      * @param sensor sensor
443      * @param update changes to the state
444      * @throws UnauthorizedException thrown if the user no longer exists
445      * @throws EntityNotAvailableException thrown if the specified sensor no longer exists
446      * @throws DeviceOffException thrown if the specified sensor is turned off
447      * @throws IOException if the bridge cannot be reached
448      */
449     public CompletableFuture<HueResult> setSensorState(FullSensor sensor, StateUpdate update) {
450         requireAuthentication();
451
452         return putAsync(getRelativeURL("sensors/" + enc(sensor.getId()) + "/state"), update.toJson(),
453                 update.getMessageDelay());
454     }
455
456     /**
457      * Changes the config of a sensor.
458      *
459      * @param sensor sensor
460      * @param update changes to the config
461      * @throws UnauthorizedException thrown if the user no longer exists
462      * @throws EntityNotAvailableException thrown if the specified sensor no longer exists
463      * @throws IOException if the bridge cannot be reached
464      */
465     public CompletableFuture<HueResult> updateSensorConfig(FullSensor sensor, ConfigUpdate update) {
466         requireAuthentication();
467
468         return putAsync(getRelativeURL("sensors/" + enc(sensor.getId()) + "/config"), update.toJson(),
469                 update.getMessageDelay());
470     }
471
472     /**
473      * Returns a group object representing all lights.
474      *
475      * @return all lights pseudo group
476      */
477     public Group getAllGroup() {
478         return new Group();
479     }
480
481     /**
482      * Returns the list of groups, including the unmodifiable all lights group.
483      *
484      * @return list of groups
485      * @throws UnauthorizedException thrown if the user no longer exists
486      */
487     public List<FullGroup> getGroups()
488             throws IOException, ApiException, ConfigurationException, CommunicationException {
489         requireAuthentication();
490
491         HueResult result = get(getRelativeURL("groups"));
492
493         handleErrors(result);
494
495         if (result.body.isBlank()) {
496             throw new EmptyResponseException("GET request 'groups' returned an unexpected empty reponse");
497         }
498
499         Map<String, FullGroup> groupMap = safeFromJson(result.body, FullGroup.GSON_TYPE);
500         List<FullGroup> groups = new ArrayList<>();
501         if (groupMap.get("0") == null) {
502             // Group 0 is not returned, we create it as in fact it exists
503             try {
504                 groups.add(getGroup(getAllGroup()));
505             } catch (FileNotFoundException e) {
506                 // We need a special exception handling here to further support deCONZ REST API. On deCONZ group "0" may
507                 // not exist and the APIs will return a different HTTP status code if requesting a non existing group
508                 // (Hue: 200, deCONZ: 404).
509                 // see https://github.com/openhab/openhab-addons/issues/9175
510                 logger.debug("Cannot find AllGroup with id \"0\" on Hue Bridge. Skipping it.");
511             }
512         }
513         groupMap.forEach((id, group) -> {
514             group.setId(id);
515             groups.add(group);
516         });
517         return groups;
518     }
519
520     /**
521      * Creates a new group and returns it.
522      * Due to API limitations, the name of the returned object
523      * will simply be "Group". The bridge will append a number to this
524      * name if it's a duplicate. To get the final name, call getGroup
525      * with the returned object.
526      *
527      * @param lights lights in group
528      * @return object representing new group
529      * @throws UnauthorizedException thrown if the user no longer exists
530      * @throws GroupTableFullException thrown if the group limit has been reached
531      */
532     public Group createGroup(List<HueObject> lights)
533             throws IOException, ApiException, ConfigurationException, CommunicationException {
534         requireAuthentication();
535
536         HueResult result = post(getRelativeURL("groups"), gson.toJson(new SetAttributesRequest(lights)));
537
538         handleErrors(result);
539
540         if (result.body.isBlank()) {
541             throw new EmptyResponseException("POST request 'groups' returned an unexpected empty reponse");
542         }
543
544         List<SuccessResponse> entries = safeFromJson(result.body, SuccessResponse.GSON_TYPE);
545         SuccessResponse response = entries.get(0);
546
547         Group group = new Group();
548         group.setName("Group");
549         group.setId(Util.quickMatch("^/groups/([0-9]+)$", (String) response.success.values().toArray()[0]));
550         return group;
551     }
552
553     /**
554      * Creates a new group and returns it.
555      * Due to API limitations, the name of the returned object
556      * will simply be the same as the name parameter. The bridge will
557      * append a number to the name if it's a duplicate. To get the final
558      * name, call getGroup with the returned object.
559      *
560      * @param name new group name
561      * @param lights lights in group
562      * @return object representing new group
563      * @throws UnauthorizedException thrown if the user no longer exists
564      * @throws GroupTableFullException thrown if the group limit has been reached
565      */
566     public Group createGroup(String name, List<HueObject> lights)
567             throws IOException, ApiException, ConfigurationException, CommunicationException {
568         requireAuthentication();
569
570         HueResult result = post(getRelativeURL("groups"), gson.toJson(new SetAttributesRequest(name, lights)));
571
572         handleErrors(result);
573
574         if (result.body.isBlank()) {
575             throw new EmptyResponseException("POST request 'groups' returned an unexpected empty reponse");
576         }
577
578         List<SuccessResponse> entries = safeFromJson(result.body, SuccessResponse.GSON_TYPE);
579         SuccessResponse response = entries.get(0);
580
581         Group group = new Group();
582         group.setName(name);
583         group.setId(Util.quickMatch("^/groups/([0-9]+)$", (String) response.success.values().toArray()[0]));
584         return group;
585     }
586
587     /**
588      * Returns detailed information for the given group.
589      *
590      * @param group group
591      * @return detailed group information
592      * @throws UnauthorizedException thrown if the user no longer exists
593      * @throws EntityNotAvailableException thrown if a group with the given id doesn't exist
594      */
595     public FullGroup getGroup(Group group)
596             throws IOException, ApiException, ConfigurationException, CommunicationException {
597         requireAuthentication();
598
599         HueResult result = get(getRelativeURL("groups/" + enc(group.getId())));
600
601         handleErrors(result);
602
603         if (result.body.isBlank()) {
604             throw new EmptyResponseException(
605                     "GET request 'groups/" + enc(group.getId()) + "' returned an unexpected empty reponse");
606         }
607
608         FullGroup fullGroup = safeFromJson(result.body, FullGroup.class);
609         fullGroup.setId(group.getId());
610         return fullGroup;
611     }
612
613     /**
614      * Changes the name of the group and returns the new name.
615      * A number will be appended to duplicate names, which may result in a new name exceeding 32 characters.
616      *
617      * @param group group
618      * @param name new name [0..32]
619      * @return new name
620      * @throws UnauthorizedException thrown if the user no longer exists
621      * @throws EntityNotAvailableException thrown if the specified group no longer exists
622      */
623     public String setGroupName(Group group, String name)
624             throws IOException, ApiException, ConfigurationException, CommunicationException {
625         requireAuthentication();
626
627         if (!group.isModifiable()) {
628             throw new IllegalArgumentException("Group cannot be modified");
629         }
630
631         HueResult result = put(getRelativeURL("groups/" + enc(group.getId())),
632                 gson.toJson(new SetAttributesRequest(name)));
633
634         handleErrors(result);
635
636         if (result.body.isBlank()) {
637             throw new EmptyResponseException(
638                     "PUT request 'groups/" + enc(group.getId()) + "' returned an unexpected empty reponse");
639         }
640
641         List<SuccessResponse> entries = safeFromJson(result.body, SuccessResponse.GSON_TYPE);
642         SuccessResponse response = entries.get(0);
643
644         String groupName = (String) response.success.get("/groups/" + enc(group.getId()) + "/name");
645         if (groupName == null) {
646             throw new ApiException("Response didn't contain group name.");
647         }
648         return groupName;
649     }
650
651     /**
652      * Changes the lights in the group.
653      *
654      * @param group group
655      * @param lights new lights [1..16]
656      * @throws UnauthorizedException thrown if the user no longer exists
657      * @throws EntityNotAvailableException thrown if the specified group no longer exists
658      */
659     public void setGroupLights(Group group, List<HueObject> lights)
660             throws IOException, ApiException, ConfigurationException, CommunicationException {
661         requireAuthentication();
662
663         if (!group.isModifiable()) {
664             throw new IllegalArgumentException("Group cannot be modified");
665         }
666
667         HueResult result = put(getRelativeURL("groups/" + enc(group.getId())),
668                 gson.toJson(new SetAttributesRequest(lights)));
669
670         handleErrors(result);
671     }
672
673     /**
674      * Changes the name and the lights of a group and returns the new name.
675      *
676      * @param group group
677      * @param name new name [0..32]
678      * @param lights [1..16]
679      * @return new name
680      * @throws UnauthorizedException thrown if the user no longer exists
681      * @throws EntityNotAvailableException thrown if the specified group no longer exists
682      */
683     public String setGroupAttributes(Group group, String name, List<HueObject> lights)
684             throws IOException, ApiException, ConfigurationException, CommunicationException {
685         requireAuthentication();
686
687         if (!group.isModifiable()) {
688             throw new IllegalArgumentException("Group cannot be modified");
689         }
690
691         HueResult result = put(getRelativeURL("groups/" + enc(group.getId())),
692                 gson.toJson(new SetAttributesRequest(name, lights)));
693
694         handleErrors(result);
695
696         if (result.body.isBlank()) {
697             throw new EmptyResponseException(
698                     "PUT request 'groups/" + enc(group.getId()) + "' returned an unexpected empty reponse");
699         }
700
701         List<SuccessResponse> entries = safeFromJson(result.body, SuccessResponse.GSON_TYPE);
702         SuccessResponse response = entries.get(0);
703
704         String groupName = (String) response.success.get("/groups/" + enc(group.getId()) + "/name");
705         if (groupName == null) {
706             throw new ApiException("Response didn't contain group name.");
707         }
708         return groupName;
709     }
710
711     /**
712      * Changes the state of a group.
713      *
714      * @param group group
715      * @param update changes to the state
716      * @throws UnauthorizedException thrown if the user no longer exists
717      * @throws EntityNotAvailableException thrown if the specified group no longer exists
718      */
719     public CompletableFuture<HueResult> setGroupState(Group group, StateUpdate update) {
720         requireAuthentication();
721
722         return putAsync(getRelativeURL("groups/" + enc(group.getId()) + "/action"), update.toJson(),
723                 update.getMessageDelay());
724     }
725
726     /**
727      * Delete a group.
728      *
729      * @param group group
730      * @throws UnauthorizedException thrown if the user no longer exists
731      * @throws EntityNotAvailableException thrown if the specified group no longer exists
732      */
733     public void deleteGroup(Group group)
734             throws IOException, ApiException, ConfigurationException, CommunicationException {
735         requireAuthentication();
736
737         if (!group.isModifiable()) {
738             throw new IllegalArgumentException("Group cannot be modified");
739         }
740
741         HueResult result = delete(getRelativeURL("groups/" + enc(group.getId())));
742
743         handleErrors(result);
744     }
745
746     /**
747      * Returns a list of schedules on the bridge.
748      *
749      * @return schedules
750      * @throws UnauthorizedException thrown if the user no longer exists
751      */
752     public List<Schedule> getSchedules()
753             throws IOException, ApiException, ConfigurationException, CommunicationException {
754         requireAuthentication();
755
756         HueResult result = get(getRelativeURL("schedules"));
757
758         handleErrors(result);
759
760         if (result.body.isBlank()) {
761             throw new EmptyResponseException("GET request 'schedules' returned an unexpected empty reponse");
762         }
763
764         Map<String, Schedule> scheduleMap = safeFromJson(result.body, Schedule.GSON_TYPE);
765         List<Schedule> schedules = new ArrayList<>();
766         scheduleMap.forEach((id, schedule) -> {
767             schedule.setId(id);
768             schedules.add(schedule);
769         });
770         return schedules;
771     }
772
773     /**
774      * Changes a schedule.
775      *
776      * @param schedule schedule
777      * @param update changes
778      * @throws UnauthorizedException thrown if the user no longer exists
779      * @throws EntityNotAvailableException thrown if the specified schedule no longer exists
780      */
781     public void setSchedule(Schedule schedule, ScheduleUpdate update)
782             throws IOException, ApiException, ConfigurationException, CommunicationException {
783         requireAuthentication();
784
785         HueResult result = put(getRelativeURL("schedules/" + enc(schedule.getId())), update.toJson());
786
787         handleErrors(result);
788     }
789
790     /**
791      * Delete a schedule.
792      *
793      * @param schedule schedule
794      * @throws UnauthorizedException thrown if the user no longer exists
795      * @throws EntityNotAvailableException thrown if the schedule no longer exists
796      */
797     public void deleteSchedule(Schedule schedule)
798             throws IOException, ApiException, ConfigurationException, CommunicationException {
799         requireAuthentication();
800
801         HueResult result = delete(getRelativeURL("schedules/" + enc(schedule.getId())));
802
803         handleErrors(result);
804     }
805
806     /**
807      * Returns the list of scenes that are not recyclable.
808      *
809      * @return all scenes that can be activated
810      */
811     public List<Scene> getScenes() throws IOException, ApiException, ConfigurationException, CommunicationException {
812         requireAuthentication();
813
814         HueResult result = get(getRelativeURL("scenes"));
815
816         handleErrors(result);
817
818         if (result.body.isBlank()) {
819             throw new EmptyResponseException("GET request 'scenes' returned an unexpected empty reponse");
820         }
821
822         Map<String, Scene> sceneMap = safeFromJson(result.body, Scene.GSON_TYPE);
823         return sceneMap.entrySet().stream()//
824                 .map(e -> {
825                     e.getValue().setId(e.getKey());
826                     return e.getValue();
827                 })//
828                 .filter(scene -> !scene.isRecycle())//
829                 .sorted(Comparator.comparing(Scene::extractKeyForComparator))//
830                 .collect(Collectors.toList());
831     }
832
833     /**
834      * Activate scene to all lights that belong to the scene.
835      *
836      * @param id the scene to be activated
837      * @throws IOException if the bridge cannot be reached
838      */
839     public CompletableFuture<HueResult> recallScene(String id) {
840         Group allLightsGroup = new Group();
841         return setGroupState(allLightsGroup, new StateUpdate().setScene(id));
842     }
843
844     /**
845      * Authenticate on the bridge as the specified user.
846      * This function verifies that the specified username is valid and will use
847      * it for subsequent requests if it is, otherwise an UnauthorizedException
848      * is thrown and the internal username is not changed.
849      *
850      * @param username username to authenticate
851      * @throws ConfigurationException thrown on ssl failure
852      * @throws UnauthorizedException thrown if authentication failed
853      */
854     public void authenticate(String username)
855             throws IOException, ApiException, ConfigurationException, UnauthorizedException {
856         try {
857             this.username = username;
858             getLights();
859         } catch (ConfigurationException e) {
860             throw e;
861         } catch (Exception e) {
862             this.username = null;
863             throw new UnauthorizedException(e.toString());
864         }
865     }
866
867     /**
868      * Link with bridge using the specified username and device type.
869      *
870      * @param username username for new user [10..40]
871      * @param devicetype identifier of application [0..40]
872      * @throws LinkButtonException thrown if the bridge button has not been pressed
873      */
874     public void link(String username, String devicetype)
875             throws IOException, ApiException, ConfigurationException, CommunicationException {
876         this.username = link(new CreateUserRequest(username, devicetype));
877     }
878
879     /**
880      * Link with bridge using the specified device type. A random valid username will be generated by the bridge and
881      * returned.
882      *
883      * @return new random username generated by bridge
884      * @param devicetype identifier of application [0..40]
885      * @throws LinkButtonException thrown if the bridge button has not been pressed
886      */
887     public String link(String devicetype)
888             throws IOException, ApiException, ConfigurationException, CommunicationException {
889         return (this.username = link(new CreateUserRequest(devicetype)));
890     }
891
892     private String link(CreateUserRequest request)
893             throws IOException, ApiException, ConfigurationException, CommunicationException {
894         if (this.username != null) {
895             throw new IllegalStateException("already linked");
896         }
897
898         HueResult result = post(getRelativeURL(""), gson.toJson(request));
899
900         handleErrors(result);
901
902         if (result.body.isBlank()) {
903             throw new EmptyResponseException("POST request (link) returned an unexpected empty reponse");
904         }
905
906         List<SuccessResponse> entries = safeFromJson(result.body, SuccessResponse.GSON_TYPE);
907         SuccessResponse response = entries.get(0);
908
909         String username = (String) response.success.get("username");
910         if (username == null) {
911             throw new ApiException("Response didn't contain username");
912         }
913         return username;
914     }
915
916     /**
917      * Returns bridge configuration.
918      *
919      * @see Config
920      * @return bridge configuration
921      * @throws UnauthorizedException thrown if the user no longer exists
922      */
923     public Config getConfig() throws IOException, ApiException, ConfigurationException, CommunicationException {
924         requireAuthentication();
925
926         HueResult result = get(getRelativeURL("config"));
927
928         handleErrors(result);
929
930         if (result.body.isBlank()) {
931             throw new EmptyResponseException("GET request 'config' returned an unexpected empty reponse");
932         }
933
934         return safeFromJson(result.body, Config.class);
935     }
936
937     /**
938      * Change the configuration of the bridge.
939      *
940      * @param update changes to the configuration
941      * @throws UnauthorizedException thrown if the user no longer exists
942      */
943     public void setConfig(ConfigUpdate update)
944             throws IOException, ApiException, ConfigurationException, CommunicationException {
945         requireAuthentication();
946
947         HueResult result = put(getRelativeURL("config"), update.toJson());
948
949         handleErrors(result);
950     }
951
952     /**
953      * Unlink the current user from the bridge.
954      *
955      * @throws UnauthorizedException thrown if the user no longer exists
956      */
957     public void unlink() throws IOException, ApiException, ConfigurationException, CommunicationException {
958         requireAuthentication();
959
960         HueResult result = delete(getRelativeURL("config/whitelist/" + enc(username)));
961
962         handleErrors(result);
963     }
964
965     /**
966      * Returns the entire bridge configuration.
967      * This request is rather resource intensive for the bridge,
968      * don't use it more often than necessary. Prefer using requests for
969      * specific information your app needs.
970      *
971      * @return full bridge configuration
972      * @throws UnauthorizedException thrown if the user no longer exists
973      */
974     public FullConfig getFullConfig() throws IOException, ApiException, ConfigurationException, CommunicationException {
975         requireAuthentication();
976
977         HueResult result = get(getRelativeURL(""));
978
979         handleErrors(result);
980
981         if (result.body.isBlank()) {
982             throw new EmptyResponseException("GET request (getFullConfig) returned an unexpected empty reponse");
983         }
984
985         FullConfig fullConfig = gson.fromJson(result.body, FullConfig.class);
986         return Objects.requireNonNull(fullConfig);
987     }
988
989     // Used as assert in requests that require authentication
990     private void requireAuthentication() {
991         if (this.username == null) {
992             throw new IllegalStateException("linking is required before interacting with the bridge");
993         }
994     }
995
996     // Methods that convert gson exceptions into ApiExceptions
997     private <T> T safeFromJson(String json, Type typeOfT) throws ApiException {
998         try {
999             @Nullable
1000             T safe = gson.fromJson(json, typeOfT);
1001             if (safe == null) {
1002                 throw new ApiException("JSON is null or empty");
1003             }
1004             return safe;
1005         } catch (JsonParseException e) {
1006             throw new ApiException("API returned unexpected result: " + e.getMessage());
1007         }
1008     }
1009
1010     private <T> T safeFromJson(String json, Class<T> classOfT) throws ApiException {
1011         try {
1012             @Nullable
1013             T safe = gson.fromJson(json, classOfT);
1014             if (safe == null) {
1015                 throw new ApiException("JSON is null or empty");
1016             }
1017             return safe;
1018         } catch (JsonParseException e) {
1019             throw new ApiException("API returned unexpected result: " + e.getMessage());
1020         }
1021     }
1022
1023     // Used as assert in all requests to elegantly catch common errors
1024     public void handleErrors(HueResult result) throws IOException, ApiException {
1025         if (result.responseCode != HttpStatus.OK_200) {
1026             throw new IOException();
1027         } else {
1028             try {
1029                 List<ErrorResponse> errors = gson.fromJson(result.body, ErrorResponse.GSON_TYPE);
1030                 if (errors == null) {
1031                     return;
1032                 }
1033
1034                 for (ErrorResponse error : errors) {
1035                     if (error.getType() == null) {
1036                         continue;
1037                     }
1038
1039                     switch (error.getType()) {
1040                         case 1:
1041                             username = null;
1042                             throw new UnauthorizedException(error.getDescription());
1043                         case 3:
1044                             throw new EntityNotAvailableException(error.getDescription());
1045                         case 7:
1046                             throw new InvalidCommandException(error.getDescription());
1047                         case 101:
1048                             throw new LinkButtonException(error.getDescription());
1049                         case 201:
1050                             throw new DeviceOffException(error.getDescription());
1051                         case 301:
1052                             throw new GroupTableFullException(error.getDescription());
1053                         default:
1054                             throw new ApiException(error.getDescription());
1055                     }
1056                 }
1057             } catch (JsonParseException e) {
1058                 // Not an error
1059             }
1060         }
1061     }
1062
1063     // UTF-8 URL encode
1064     private String enc(@Nullable String str) {
1065         return str == null ? "" : URLEncoder.encode(str, StandardCharsets.UTF_8);
1066     }
1067
1068     private String getRelativeURL(String path) {
1069         String relativeUrl = baseUrl;
1070         if (username != null) {
1071             relativeUrl += "/" + enc(username);
1072         }
1073         return path.isEmpty() ? relativeUrl : relativeUrl + "/" + path;
1074     }
1075
1076     public HueResult get(String address) throws ConfigurationException, CommunicationException {
1077         return doNetwork(address, HttpMethod.GET);
1078     }
1079
1080     public HueResult post(String address, String body) throws ConfigurationException, CommunicationException {
1081         return doNetwork(address, HttpMethod.POST, body);
1082     }
1083
1084     public HueResult put(String address, String body) throws ConfigurationException, CommunicationException {
1085         return doNetwork(address, HttpMethod.PUT, body);
1086     }
1087
1088     public HueResult delete(String address) throws ConfigurationException, CommunicationException {
1089         return doNetwork(address, HttpMethod.DELETE);
1090     }
1091
1092     private HueResult doNetwork(String address, HttpMethod requestMethod)
1093             throws ConfigurationException, CommunicationException {
1094         return doNetwork(address, requestMethod, null);
1095     }
1096
1097     private HueResult doNetwork(String address, HttpMethod requestMethod, @Nullable String body)
1098             throws ConfigurationException, CommunicationException {
1099         logger.trace("Hue request: {} - URL = '{}'", requestMethod, address);
1100         try {
1101             final Request request = httpClient.newRequest(address).method(requestMethod).timeout(timeout,
1102                     TimeUnit.MILLISECONDS);
1103
1104             if (body != null) {
1105                 logger.trace("Hue request body: '{}'", body);
1106                 request.content(new StringContentProvider(body), "application/json");
1107             }
1108
1109             final ContentResponse contentResponse = request.send();
1110
1111             final int httpStatus = contentResponse.getStatus();
1112             final String content = contentResponse.getContentAsString();
1113             logger.trace("Hue response: status = {}, content = '{}'", httpStatus, content);
1114             return new HueResult(content, httpStatus);
1115         } catch (ExecutionException e) {
1116             String message = e.getMessage();
1117             if (e.getCause() instanceof SSLHandshakeException) {
1118                 logger.debug("SSLHandshakeException occurred during execution: {}", message, e);
1119                 throw new ConfigurationException(TEXT_OFFLINE_CONFIGURATION_ERROR_INVALID_SSL_CERIFICATE, e.getCause());
1120             } else {
1121                 logger.debug("ExecutionException occurred during execution: {}", message, e);
1122                 throw new CommunicationException(message == null ? TEXT_OFFLINE_COMMUNICATION_ERROR : message,
1123                         e.getCause());
1124             }
1125         } catch (TimeoutException e) {
1126             String message = e.getMessage();
1127             logger.debug("TimeoutException occurred during execution: {}", message, e);
1128             throw new CommunicationException(message == null ? TEXT_OFFLINE_COMMUNICATION_ERROR : message);
1129         } catch (InterruptedException e) {
1130             Thread.currentThread().interrupt();
1131             String message = e.getMessage();
1132             logger.debug("InterruptedException occurred during execution: {}", message, e);
1133             throw new CommunicationException(message == null ? TEXT_OFFLINE_COMMUNICATION_ERROR : message);
1134         }
1135     }
1136
1137     private CompletableFuture<HueResult> putAsync(String address, String body, long delay) {
1138         AsyncPutParameters asyncPutParameters = new AsyncPutParameters(address, body, delay);
1139         synchronized (commandsQueue) {
1140             if (commandsQueue.isEmpty()) {
1141                 commandsQueue.offer(asyncPutParameters);
1142                 Future<?> localJob = job;
1143                 if (localJob == null || localJob.isDone()) {
1144                     job = scheduler.submit(this::executeCommands);
1145                 }
1146             } else {
1147                 commandsQueue.offer(asyncPutParameters);
1148             }
1149         }
1150         return asyncPutParameters.future;
1151     }
1152
1153     private void executeCommands() {
1154         while (true) {
1155             try {
1156                 long delayTime = 0;
1157                 synchronized (commandsQueue) {
1158                     AsyncPutParameters payloadCallbackPair = commandsQueue.poll();
1159                     if (payloadCallbackPair != null) {
1160                         logger.debug("Async sending put to address: {} delay: {} body: {}", payloadCallbackPair.address,
1161                                 payloadCallbackPair.delay, payloadCallbackPair.body);
1162                         try {
1163                             HueResult result = doNetwork(payloadCallbackPair.address, HttpMethod.PUT,
1164                                     payloadCallbackPair.body);
1165                             payloadCallbackPair.future.complete(result);
1166                         } catch (ConfigurationException | CommunicationException e) {
1167                             payloadCallbackPair.future.completeExceptionally(e);
1168                         }
1169                         delayTime = payloadCallbackPair.delay;
1170                     } else {
1171                         return;
1172                     }
1173                 }
1174                 Thread.sleep(delayTime);
1175             } catch (InterruptedException e) {
1176                 logger.debug("commandExecutorThread was interrupted", e);
1177             }
1178         }
1179     }
1180
1181     public static class HueResult {
1182         public final String body;
1183         public final int responseCode;
1184
1185         public HueResult(String body, int responseCode) {
1186             this.body = body;
1187             this.responseCode = responseCode;
1188         }
1189     }
1190
1191     public final class AsyncPutParameters {
1192         public final String address;
1193         public final String body;
1194         public final CompletableFuture<HueResult> future;
1195         public final long delay;
1196
1197         public AsyncPutParameters(String address, String body, long delay) {
1198             this.address = address;
1199             this.body = body;
1200             this.future = new CompletableFuture<>();
1201             this.delay = delay;
1202         }
1203     }
1204 }