2 * Copyright (c) 2010-2023 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
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
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.hue.internal.connection;
15 import static org.openhab.binding.hue.internal.HueBindingConstants.*;
17 import java.io.FileNotFoundException;
18 import java.io.IOException;
19 import java.lang.reflect.Type;
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;
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;
41 import javax.net.ssl.SSLHandshakeException;
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;
86 import com.google.gson.Gson;
87 import com.google.gson.GsonBuilder;
88 import com.google.gson.JsonParseException;
91 * Representation of a connection with a Hue Bridge.
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
100 public class HueBridge {
102 private final Logger logger = LoggerFactory.getLogger(HueBridge.class);
104 private static final String DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ss";
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);
112 private final Gson gson = new GsonBuilder().setDateFormat(DATE_FORMAT).create();
114 private final LinkedList<AsyncPutParameters> commandsQueue = new LinkedList<>();
115 private @Nullable Future<?> job;
116 private final ScheduledExecutorService scheduler;
118 private @Nullable Config cachedConfig;
121 * Connect with a bridge as a new user.
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
129 public HueBridge(HttpClient httpClient, String ip, int port, String protocol, ScheduledExecutorService scheduler) {
130 this.httpClient = httpClient;
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";
140 this.baseUrl = baseUrl;
141 this.scheduler = scheduler;
145 * Connect with a bridge as an existing user.
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.
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
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);
166 * Set the connect and read timeout for HTTP requests.
168 * @param timeout timeout in milliseconds or 0 for indefinitely
170 public void setTimeout(long timeout) {
171 this.timeout = timeout;
175 * Returns the IP address of the bridge.
177 * @return ip address of bridge
179 public String getIPAddress() {
183 public ApiVersion getVersion() throws IOException, ApiException {
184 Config c = getCachedConfig();
185 return ApiVersion.of(c.getApiVersion());
189 * Returns a cached version of the basic {@link Config} mostly immutable configuration.
190 * This can be used to reduce load on the bridge.
192 * @return The {@link Config} of the Hue Bridge, loaded and cached lazily on the first call
193 * @throws IOException
194 * @throws ApiException
196 private Config getCachedConfig() throws IOException, ApiException {
197 if (cachedConfig == null) {
198 cachedConfig = getConfig();
201 return Objects.requireNonNull(cachedConfig);
205 * Returns the username currently authenticated with or null if there isn't one.
207 * @return username or null
209 public @Nullable String getUsername() {
214 * Returns if authentication was successful on the bridge.
216 * @return true if authenticated on the bridge, false otherwise
218 public boolean isAuthenticated() {
219 return getUsername() != null;
223 * Returns a list of lights known to the bridge.
225 * @return list of known lights as {@link FullLight}s
226 * @throws UnauthorizedException thrown if the user no longer exists
228 public List<FullLight> getFullLights() throws IOException, ApiException {
229 if (ApiVersionUtils.supportsFullLights(getVersion())) {
230 Type gsonType = FullLight.GSON_TYPE;
231 return getTypedLights(gsonType);
233 return getFullConfig().getLights();
238 * Returns a list of lights known to the bridge.
240 * @return list of known lights
241 * @throws UnauthorizedException thrown if the user no longer exists
243 public List<HueObject> getLights() throws IOException, ApiException {
244 Type gsonType = HueObject.GSON_TYPE;
245 return getTypedLights(gsonType);
248 private <T extends HueObject> List<T> getTypedLights(Type gsonType)
249 throws IOException, ApiException, ConfigurationException, CommunicationException {
250 requireAuthentication();
252 HueResult result = get(getRelativeURL("lights"));
254 handleErrors(result);
256 if (result.body.isBlank()) {
257 throw new EmptyResponseException("GET request 'lights' returned an unexpected empty reponse");
260 Map<String, T> lightMap = safeFromJson(result.body, gsonType);
261 List<T> lights = new ArrayList<>();
262 lightMap.forEach((id, light) -> {
270 * Returns a list of sensors known to the bridge
272 * @return list of sensors
273 * @throws UnauthorizedException thrown if the user no longer exists
275 public List<FullSensor> getSensors()
276 throws IOException, ApiException, ConfigurationException, CommunicationException {
277 requireAuthentication();
279 HueResult result = get(getRelativeURL("sensors"));
281 handleErrors(result);
283 if (result.body.isBlank()) {
284 throw new EmptyResponseException("GET request 'sensors' returned an unexpected empty reponse");
287 Map<String, FullSensor> sensorMap = safeFromJson(result.body, FullSensor.GSON_TYPE);
288 List<FullSensor> sensors = new ArrayList<>();
289 sensorMap.forEach((id, sensor) -> {
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.
301 * @return last search time
302 * @throws UnauthorizedException thrown if the user no longer exists
304 public @Nullable Date getLastSearch()
305 throws IOException, ApiException, ConfigurationException, CommunicationException {
306 requireAuthentication();
308 HueResult result = get(getRelativeURL("lights/new"));
310 handleErrors(result);
312 if (result.body.isBlank()) {
313 throw new EmptyResponseException("GET request 'lights/new' returned an unexpected empty reponse");
316 String lastScan = safeFromJson(result.body, NewLightsResponse.class).lastscan;
325 return new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss").parse(lastScan);
326 } catch (ParseException e) {
333 * Start searching for new lights for 1 minute.
334 * A maximum amount of 15 new lights will be added.
336 * @throws UnauthorizedException thrown if the user no longer exists
338 public void startSearch() throws IOException, ApiException, ConfigurationException, CommunicationException {
339 requireAuthentication();
341 HueResult result = post(getRelativeURL("lights"), "");
343 handleErrors(result);
347 * Start searching for new lights with given serial numbers for 1 minute.
348 * A maximum amount of 15 new lights will be added.
350 * @param serialNumbers list of serial numbers
351 * @throws UnauthorizedException thrown if the user no longer exists
353 public void startSearch(List<String> serialNumbers)
354 throws IOException, ApiException, ConfigurationException, CommunicationException {
355 requireAuthentication();
357 HueResult result = post(getRelativeURL("lights"), gson.toJson(new SearchForLightsRequest(serialNumbers)));
359 handleErrors(result);
363 * Returns detailed information for the given 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
370 public FullHueObject getLight(HueObject light)
371 throws IOException, ApiException, ConfigurationException, CommunicationException {
372 requireAuthentication();
374 HueResult result = get(getRelativeURL("lights/" + enc(light.getId())));
376 handleErrors(result);
378 if (result.body.isBlank()) {
379 throw new EmptyResponseException(
380 "GET request 'lights/" + enc(light.getId()) + "' returned an unexpected empty reponse");
383 FullHueObject fullLight = safeFromJson(result.body, FullLight.class);
384 fullLight.setId(light.getId());
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.
393 * @param name new name [0..32]
395 * @throws UnauthorizedException thrown if the user no longer exists
396 * @throws EntityNotAvailableException thrown if the specified light no longer exists
398 public String setLightName(HueObject light, String name)
399 throws IOException, ApiException, ConfigurationException, CommunicationException {
400 requireAuthentication();
402 HueResult result = put(getRelativeURL("lights/" + enc(light.getId())),
403 gson.toJson(new SetAttributesRequest(name)));
405 handleErrors(result);
407 if (result.body.isBlank()) {
408 throw new EmptyResponseException(
409 "PUT request 'lights/" + enc(light.getId()) + "' returned an unexpected empty reponse");
412 List<SuccessResponse> entries = safeFromJson(result.body, SuccessResponse.GSON_TYPE);
413 SuccessResponse response = entries.get(0);
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.");
423 * Changes the state of a 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
432 public CompletableFuture<HueResult> setLightState(FullLight light, StateUpdate update) {
433 requireAuthentication();
435 return putAsync(getRelativeURL("lights/" + enc(light.getId()) + "/state"), update.toJson(),
436 update.getMessageDelay());
440 * Changes the state of a clip sensor.
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
449 public CompletableFuture<HueResult> setSensorState(FullSensor sensor, StateUpdate update) {
450 requireAuthentication();
452 return putAsync(getRelativeURL("sensors/" + enc(sensor.getId()) + "/state"), update.toJson(),
453 update.getMessageDelay());
457 * Changes the config of a sensor.
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
465 public CompletableFuture<HueResult> updateSensorConfig(FullSensor sensor, ConfigUpdate update) {
466 requireAuthentication();
468 return putAsync(getRelativeURL("sensors/" + enc(sensor.getId()) + "/config"), update.toJson(),
469 update.getMessageDelay());
473 * Returns a group object representing all lights.
475 * @return all lights pseudo group
477 public Group getAllGroup() {
482 * Returns the list of groups, including the unmodifiable all lights group.
484 * @return list of groups
485 * @throws UnauthorizedException thrown if the user no longer exists
487 public List<FullGroup> getGroups()
488 throws IOException, ApiException, ConfigurationException, CommunicationException {
489 requireAuthentication();
491 HueResult result = get(getRelativeURL("groups"));
493 handleErrors(result);
495 if (result.body.isBlank()) {
496 throw new EmptyResponseException("GET request 'groups' returned an unexpected empty reponse");
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
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.");
513 groupMap.forEach((id, group) -> {
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.
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
532 public Group createGroup(List<HueObject> lights)
533 throws IOException, ApiException, ConfigurationException, CommunicationException {
534 requireAuthentication();
536 HueResult result = post(getRelativeURL("groups"), gson.toJson(new SetAttributesRequest(lights)));
538 handleErrors(result);
540 if (result.body.isBlank()) {
541 throw new EmptyResponseException("POST request 'groups' returned an unexpected empty reponse");
544 List<SuccessResponse> entries = safeFromJson(result.body, SuccessResponse.GSON_TYPE);
545 SuccessResponse response = entries.get(0);
547 Group group = new Group();
548 group.setName("Group");
549 group.setId(Util.quickMatch("^/groups/([0-9]+)$", (String) response.success.values().toArray()[0]));
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.
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
566 public Group createGroup(String name, List<HueObject> lights)
567 throws IOException, ApiException, ConfigurationException, CommunicationException {
568 requireAuthentication();
570 HueResult result = post(getRelativeURL("groups"), gson.toJson(new SetAttributesRequest(name, lights)));
572 handleErrors(result);
574 if (result.body.isBlank()) {
575 throw new EmptyResponseException("POST request 'groups' returned an unexpected empty reponse");
578 List<SuccessResponse> entries = safeFromJson(result.body, SuccessResponse.GSON_TYPE);
579 SuccessResponse response = entries.get(0);
581 Group group = new Group();
583 group.setId(Util.quickMatch("^/groups/([0-9]+)$", (String) response.success.values().toArray()[0]));
588 * Returns detailed information for the given 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
595 public FullGroup getGroup(Group group)
596 throws IOException, ApiException, ConfigurationException, CommunicationException {
597 requireAuthentication();
599 HueResult result = get(getRelativeURL("groups/" + enc(group.getId())));
601 handleErrors(result);
603 if (result.body.isBlank()) {
604 throw new EmptyResponseException(
605 "GET request 'groups/" + enc(group.getId()) + "' returned an unexpected empty reponse");
608 FullGroup fullGroup = safeFromJson(result.body, FullGroup.class);
609 fullGroup.setId(group.getId());
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.
618 * @param name new name [0..32]
620 * @throws UnauthorizedException thrown if the user no longer exists
621 * @throws EntityNotAvailableException thrown if the specified group no longer exists
623 public String setGroupName(Group group, String name)
624 throws IOException, ApiException, ConfigurationException, CommunicationException {
625 requireAuthentication();
627 if (!group.isModifiable()) {
628 throw new IllegalArgumentException("Group cannot be modified");
631 HueResult result = put(getRelativeURL("groups/" + enc(group.getId())),
632 gson.toJson(new SetAttributesRequest(name)));
634 handleErrors(result);
636 if (result.body.isBlank()) {
637 throw new EmptyResponseException(
638 "PUT request 'groups/" + enc(group.getId()) + "' returned an unexpected empty reponse");
641 List<SuccessResponse> entries = safeFromJson(result.body, SuccessResponse.GSON_TYPE);
642 SuccessResponse response = entries.get(0);
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.");
652 * Changes the lights in the 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
659 public void setGroupLights(Group group, List<HueObject> lights)
660 throws IOException, ApiException, ConfigurationException, CommunicationException {
661 requireAuthentication();
663 if (!group.isModifiable()) {
664 throw new IllegalArgumentException("Group cannot be modified");
667 HueResult result = put(getRelativeURL("groups/" + enc(group.getId())),
668 gson.toJson(new SetAttributesRequest(lights)));
670 handleErrors(result);
674 * Changes the name and the lights of a group and returns the new name.
677 * @param name new name [0..32]
678 * @param lights [1..16]
680 * @throws UnauthorizedException thrown if the user no longer exists
681 * @throws EntityNotAvailableException thrown if the specified group no longer exists
683 public String setGroupAttributes(Group group, String name, List<HueObject> lights)
684 throws IOException, ApiException, ConfigurationException, CommunicationException {
685 requireAuthentication();
687 if (!group.isModifiable()) {
688 throw new IllegalArgumentException("Group cannot be modified");
691 HueResult result = put(getRelativeURL("groups/" + enc(group.getId())),
692 gson.toJson(new SetAttributesRequest(name, lights)));
694 handleErrors(result);
696 if (result.body.isBlank()) {
697 throw new EmptyResponseException(
698 "PUT request 'groups/" + enc(group.getId()) + "' returned an unexpected empty reponse");
701 List<SuccessResponse> entries = safeFromJson(result.body, SuccessResponse.GSON_TYPE);
702 SuccessResponse response = entries.get(0);
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.");
712 * Changes the state of a 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
719 public CompletableFuture<HueResult> setGroupState(Group group, StateUpdate update) {
720 requireAuthentication();
722 return putAsync(getRelativeURL("groups/" + enc(group.getId()) + "/action"), update.toJson(),
723 update.getMessageDelay());
730 * @throws UnauthorizedException thrown if the user no longer exists
731 * @throws EntityNotAvailableException thrown if the specified group no longer exists
733 public void deleteGroup(Group group)
734 throws IOException, ApiException, ConfigurationException, CommunicationException {
735 requireAuthentication();
737 if (!group.isModifiable()) {
738 throw new IllegalArgumentException("Group cannot be modified");
741 HueResult result = delete(getRelativeURL("groups/" + enc(group.getId())));
743 handleErrors(result);
747 * Returns a list of schedules on the bridge.
750 * @throws UnauthorizedException thrown if the user no longer exists
752 public List<Schedule> getSchedules()
753 throws IOException, ApiException, ConfigurationException, CommunicationException {
754 requireAuthentication();
756 HueResult result = get(getRelativeURL("schedules"));
758 handleErrors(result);
760 if (result.body.isBlank()) {
761 throw new EmptyResponseException("GET request 'schedules' returned an unexpected empty reponse");
764 Map<String, Schedule> scheduleMap = safeFromJson(result.body, Schedule.GSON_TYPE);
765 List<Schedule> schedules = new ArrayList<>();
766 scheduleMap.forEach((id, schedule) -> {
768 schedules.add(schedule);
774 * Changes a schedule.
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
781 public void setSchedule(Schedule schedule, ScheduleUpdate update)
782 throws IOException, ApiException, ConfigurationException, CommunicationException {
783 requireAuthentication();
785 HueResult result = put(getRelativeURL("schedules/" + enc(schedule.getId())), update.toJson());
787 handleErrors(result);
793 * @param schedule schedule
794 * @throws UnauthorizedException thrown if the user no longer exists
795 * @throws EntityNotAvailableException thrown if the schedule no longer exists
797 public void deleteSchedule(Schedule schedule)
798 throws IOException, ApiException, ConfigurationException, CommunicationException {
799 requireAuthentication();
801 HueResult result = delete(getRelativeURL("schedules/" + enc(schedule.getId())));
803 handleErrors(result);
807 * Returns the list of scenes that are not recyclable.
809 * @return all scenes that can be activated
811 public List<Scene> getScenes() throws IOException, ApiException, ConfigurationException, CommunicationException {
812 requireAuthentication();
814 HueResult result = get(getRelativeURL("scenes"));
816 handleErrors(result);
818 if (result.body.isBlank()) {
819 throw new EmptyResponseException("GET request 'scenes' returned an unexpected empty reponse");
822 Map<String, Scene> sceneMap = safeFromJson(result.body, Scene.GSON_TYPE);
823 return sceneMap.entrySet().stream()//
825 e.getValue().setId(e.getKey());
828 .filter(scene -> !scene.isRecycle())//
829 .sorted(Comparator.comparing(Scene::extractKeyForComparator))//
830 .collect(Collectors.toList());
834 * Activate scene to all lights that belong to the scene.
836 * @param id the scene to be activated
837 * @throws IOException if the bridge cannot be reached
839 public CompletableFuture<HueResult> recallScene(String id) {
840 Group allLightsGroup = new Group();
841 return setGroupState(allLightsGroup, new StateUpdate().setScene(id));
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.
850 * @param username username to authenticate
851 * @throws ConfigurationException thrown on ssl failure
852 * @throws UnauthorizedException thrown if authentication failed
854 public void authenticate(String username)
855 throws IOException, ApiException, ConfigurationException, UnauthorizedException {
857 this.username = username;
859 } catch (ConfigurationException e) {
861 } catch (Exception e) {
862 this.username = null;
863 throw new UnauthorizedException(e.toString());
868 * Link with bridge using the specified username and device type.
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
874 public void link(String username, String devicetype)
875 throws IOException, ApiException, ConfigurationException, CommunicationException {
876 this.username = link(new CreateUserRequest(username, devicetype));
880 * Link with bridge using the specified device type. A random valid username will be generated by the bridge and
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
887 public String link(String devicetype)
888 throws IOException, ApiException, ConfigurationException, CommunicationException {
889 return (this.username = link(new CreateUserRequest(devicetype)));
892 private String link(CreateUserRequest request)
893 throws IOException, ApiException, ConfigurationException, CommunicationException {
894 if (this.username != null) {
895 throw new IllegalStateException("already linked");
898 HueResult result = post(getRelativeURL(""), gson.toJson(request));
900 handleErrors(result);
902 if (result.body.isBlank()) {
903 throw new EmptyResponseException("POST request (link) returned an unexpected empty reponse");
906 List<SuccessResponse> entries = safeFromJson(result.body, SuccessResponse.GSON_TYPE);
907 SuccessResponse response = entries.get(0);
909 String username = (String) response.success.get("username");
910 if (username == null) {
911 throw new ApiException("Response didn't contain username");
917 * Returns bridge configuration.
920 * @return bridge configuration
921 * @throws UnauthorizedException thrown if the user no longer exists
923 public Config getConfig() throws IOException, ApiException, ConfigurationException, CommunicationException {
924 requireAuthentication();
926 HueResult result = get(getRelativeURL("config"));
928 handleErrors(result);
930 if (result.body.isBlank()) {
931 throw new EmptyResponseException("GET request 'config' returned an unexpected empty reponse");
934 return safeFromJson(result.body, Config.class);
938 * Change the configuration of the bridge.
940 * @param update changes to the configuration
941 * @throws UnauthorizedException thrown if the user no longer exists
943 public void setConfig(ConfigUpdate update)
944 throws IOException, ApiException, ConfigurationException, CommunicationException {
945 requireAuthentication();
947 HueResult result = put(getRelativeURL("config"), update.toJson());
949 handleErrors(result);
953 * Unlink the current user from the bridge.
955 * @throws UnauthorizedException thrown if the user no longer exists
957 public void unlink() throws IOException, ApiException, ConfigurationException, CommunicationException {
958 requireAuthentication();
960 HueResult result = delete(getRelativeURL("config/whitelist/" + enc(username)));
962 handleErrors(result);
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.
971 * @return full bridge configuration
972 * @throws UnauthorizedException thrown if the user no longer exists
974 public FullConfig getFullConfig() throws IOException, ApiException, ConfigurationException, CommunicationException {
975 requireAuthentication();
977 HueResult result = get(getRelativeURL(""));
979 handleErrors(result);
981 if (result.body.isBlank()) {
982 throw new EmptyResponseException("GET request (getFullConfig) returned an unexpected empty reponse");
985 FullConfig fullConfig = gson.fromJson(result.body, FullConfig.class);
986 return Objects.requireNonNull(fullConfig);
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");
996 // Methods that convert gson exceptions into ApiExceptions
997 private <T> T safeFromJson(String json, Type typeOfT) throws ApiException {
1000 T safe = gson.fromJson(json, typeOfT);
1002 throw new ApiException("JSON is null or empty");
1005 } catch (JsonParseException e) {
1006 throw new ApiException("API returned unexpected result: " + e.getMessage());
1010 private <T> T safeFromJson(String json, Class<T> classOfT) throws ApiException {
1013 T safe = gson.fromJson(json, classOfT);
1015 throw new ApiException("JSON is null or empty");
1018 } catch (JsonParseException e) {
1019 throw new ApiException("API returned unexpected result: " + e.getMessage());
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();
1029 List<ErrorResponse> errors = gson.fromJson(result.body, ErrorResponse.GSON_TYPE);
1030 if (errors == null) {
1034 for (ErrorResponse error : errors) {
1035 if (error.getType() == null) {
1039 switch (error.getType()) {
1042 throw new UnauthorizedException(error.getDescription());
1044 throw new EntityNotAvailableException(error.getDescription());
1046 throw new InvalidCommandException(error.getDescription());
1048 throw new LinkButtonException(error.getDescription());
1050 throw new DeviceOffException(error.getDescription());
1052 throw new GroupTableFullException(error.getDescription());
1054 throw new ApiException(error.getDescription());
1057 } catch (JsonParseException e) {
1064 private String enc(@Nullable String str) {
1065 return str == null ? "" : URLEncoder.encode(str, StandardCharsets.UTF_8);
1068 private String getRelativeURL(String path) {
1069 String relativeUrl = baseUrl;
1070 if (username != null) {
1071 relativeUrl += "/" + enc(username);
1073 return path.isEmpty() ? relativeUrl : relativeUrl + "/" + path;
1076 public HueResult get(String address) throws ConfigurationException, CommunicationException {
1077 return doNetwork(address, HttpMethod.GET);
1080 public HueResult post(String address, String body) throws ConfigurationException, CommunicationException {
1081 return doNetwork(address, HttpMethod.POST, body);
1084 public HueResult put(String address, String body) throws ConfigurationException, CommunicationException {
1085 return doNetwork(address, HttpMethod.PUT, body);
1088 public HueResult delete(String address) throws ConfigurationException, CommunicationException {
1089 return doNetwork(address, HttpMethod.DELETE);
1092 private HueResult doNetwork(String address, HttpMethod requestMethod)
1093 throws ConfigurationException, CommunicationException {
1094 return doNetwork(address, requestMethod, null);
1097 private HueResult doNetwork(String address, HttpMethod requestMethod, @Nullable String body)
1098 throws ConfigurationException, CommunicationException {
1099 logger.trace("Hue request: {} - URL = '{}'", requestMethod, address);
1101 final Request request = httpClient.newRequest(address).method(requestMethod).timeout(timeout,
1102 TimeUnit.MILLISECONDS);
1105 logger.trace("Hue request body: '{}'", body);
1106 request.content(new StringContentProvider(body), "application/json");
1109 final ContentResponse contentResponse = request.send();
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());
1121 logger.debug("ExecutionException occurred during execution: {}", message, e);
1122 throw new CommunicationException(message == null ? TEXT_OFFLINE_COMMUNICATION_ERROR : message,
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);
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);
1147 commandsQueue.offer(asyncPutParameters);
1150 return asyncPutParameters.future;
1153 private void executeCommands() {
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);
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);
1169 delayTime = payloadCallbackPair.delay;
1174 Thread.sleep(delayTime);
1175 } catch (InterruptedException e) {
1176 logger.debug("commandExecutorThread was interrupted", e);
1181 public static class HueResult {
1182 public final String body;
1183 public final int responseCode;
1185 public HueResult(String body, int responseCode) {
1187 this.responseCode = responseCode;
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;
1197 public AsyncPutParameters(String address, String body, long delay) {
1198 this.address = address;
1200 this.future = new CompletableFuture<>();