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