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.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;
85 import com.google.gson.Gson;
86 import com.google.gson.GsonBuilder;
87 import com.google.gson.JsonParseException;
90 * Representation of a connection with a Hue Bridge.
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
99 public class HueBridge {
101 private final Logger logger = LoggerFactory.getLogger(HueBridge.class);
103 private static final String DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ss";
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);
111 private final Gson gson = new GsonBuilder().setDateFormat(DATE_FORMAT).create();
113 private final LinkedList<AsyncPutParameters> commandsQueue = new LinkedList<>();
114 private @Nullable Future<?> job;
115 private final ScheduledExecutorService scheduler;
117 private @Nullable Config cachedConfig;
120 * Connect with a bridge as a new user.
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
128 public HueBridge(HttpClient httpClient, String ip, int port, String protocol, ScheduledExecutorService scheduler) {
129 this.httpClient = httpClient;
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";
139 this.baseUrl = baseUrl;
140 this.scheduler = scheduler;
144 * Connect with a bridge as an existing user.
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.
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
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);
165 * Set the connect and read timeout for HTTP requests.
167 * @param timeout timeout in milliseconds or 0 for indefinitely
169 public void setTimeout(long timeout) {
170 this.timeout = timeout;
174 * Returns the IP address of the bridge.
176 * @return ip address of bridge
178 public String getIPAddress() {
182 public ApiVersion getVersion() throws IOException, ApiException {
183 Config c = getCachedConfig();
184 return ApiVersion.of(c.getApiVersion());
188 * Returns a cached version of the basic {@link Config} mostly immutable configuration.
189 * This can be used to reduce load on the bridge.
191 * @return The {@link Config} of the Hue Bridge, loaded and cached lazily on the first call
192 * @throws IOException
193 * @throws ApiException
195 private Config getCachedConfig() throws IOException, ApiException {
196 if (cachedConfig == null) {
197 cachedConfig = getConfig();
200 return Objects.requireNonNull(cachedConfig);
204 * Returns the username currently authenticated with or null if there isn't one.
206 * @return username or null
208 public @Nullable String getUsername() {
213 * Returns if authentication was successful on the bridge.
215 * @return true if authenticated on the bridge, false otherwise
217 public boolean isAuthenticated() {
218 return getUsername() != null;
222 * Returns a list of lights known to the bridge.
224 * @return list of known lights as {@link FullLight}s
225 * @throws UnauthorizedException thrown if the user no longer exists
227 public List<FullLight> getFullLights() throws IOException, ApiException {
228 if (ApiVersionUtils.supportsFullLights(getVersion())) {
229 Type gsonType = FullLight.GSON_TYPE;
230 return getTypedLights(gsonType);
232 return getFullConfig().getLights();
237 * Returns a list of lights known to the bridge.
239 * @return list of known lights
240 * @throws UnauthorizedException thrown if the user no longer exists
242 public List<HueObject> getLights() throws IOException, ApiException {
243 Type gsonType = HueObject.GSON_TYPE;
244 return getTypedLights(gsonType);
247 private <T extends HueObject> List<T> getTypedLights(Type gsonType)
248 throws IOException, ApiException, ConfigurationException, CommunicationException {
249 requireAuthentication();
251 HueResult result = get(getRelativeURL("lights"));
253 handleErrors(result);
255 Map<String, T> lightMap = safeFromJson(result.body, gsonType);
256 List<T> lights = new ArrayList<>();
257 lightMap.forEach((id, light) -> {
265 * Returns a list of sensors known to the bridge
267 * @return list of sensors
268 * @throws UnauthorizedException thrown if the user no longer exists
270 public List<FullSensor> getSensors()
271 throws IOException, ApiException, ConfigurationException, CommunicationException {
272 requireAuthentication();
274 HueResult result = get(getRelativeURL("sensors"));
276 handleErrors(result);
278 Map<String, FullSensor> sensorMap = safeFromJson(result.body, FullSensor.GSON_TYPE);
279 List<FullSensor> sensors = new ArrayList<>();
280 sensorMap.forEach((id, sensor) -> {
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.
292 * @return last search time
293 * @throws UnauthorizedException thrown if the user no longer exists
295 public @Nullable Date getLastSearch()
296 throws IOException, ApiException, ConfigurationException, CommunicationException {
297 requireAuthentication();
299 HueResult result = get(getRelativeURL("lights/new"));
301 handleErrors(result);
303 String lastScan = safeFromJson(result.body, NewLightsResponse.class).lastscan;
312 return new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss").parse(lastScan);
313 } catch (ParseException e) {
320 * Start searching for new lights for 1 minute.
321 * A maximum amount of 15 new lights will be added.
323 * @throws UnauthorizedException thrown if the user no longer exists
325 public void startSearch() throws IOException, ApiException, ConfigurationException, CommunicationException {
326 requireAuthentication();
328 HueResult result = post(getRelativeURL("lights"), "");
330 handleErrors(result);
334 * Start searching for new lights with given serial numbers for 1 minute.
335 * A maximum amount of 15 new lights will be added.
337 * @param serialNumbers list of serial numbers
338 * @throws UnauthorizedException thrown if the user no longer exists
340 public void startSearch(List<String> serialNumbers)
341 throws IOException, ApiException, ConfigurationException, CommunicationException {
342 requireAuthentication();
344 HueResult result = post(getRelativeURL("lights"), gson.toJson(new SearchForLightsRequest(serialNumbers)));
346 handleErrors(result);
350 * Returns detailed information for the given 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
357 public FullHueObject getLight(HueObject light)
358 throws IOException, ApiException, ConfigurationException, CommunicationException {
359 requireAuthentication();
361 HueResult result = get(getRelativeURL("lights/" + enc(light.getId())));
363 handleErrors(result);
365 FullHueObject fullLight = safeFromJson(result.body, FullLight.class);
366 fullLight.setId(light.getId());
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.
375 * @param name new name [0..32]
377 * @throws UnauthorizedException thrown if the user no longer exists
378 * @throws EntityNotAvailableException thrown if the specified light no longer exists
380 public String setLightName(HueObject light, String name)
381 throws IOException, ApiException, ConfigurationException, CommunicationException {
382 requireAuthentication();
384 HueResult result = put(getRelativeURL("lights/" + enc(light.getId())),
385 gson.toJson(new SetAttributesRequest(name)));
387 handleErrors(result);
389 List<SuccessResponse> entries = safeFromJson(result.body, SuccessResponse.GSON_TYPE);
390 SuccessResponse response = entries.get(0);
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.");
400 * Changes the state of a 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
409 public CompletableFuture<HueResult> setLightState(FullLight light, StateUpdate update) {
410 requireAuthentication();
412 return putAsync(getRelativeURL("lights/" + enc(light.getId()) + "/state"), update.toJson(),
413 update.getMessageDelay());
417 * Changes the state of a clip sensor.
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
426 public CompletableFuture<HueResult> setSensorState(FullSensor sensor, StateUpdate update) {
427 requireAuthentication();
429 return putAsync(getRelativeURL("sensors/" + enc(sensor.getId()) + "/state"), update.toJson(),
430 update.getMessageDelay());
434 * Changes the config of a sensor.
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
442 public CompletableFuture<HueResult> updateSensorConfig(FullSensor sensor, ConfigUpdate update) {
443 requireAuthentication();
445 return putAsync(getRelativeURL("sensors/" + enc(sensor.getId()) + "/config"), update.toJson(),
446 update.getMessageDelay());
450 * Returns a group object representing all lights.
452 * @return all lights pseudo group
454 public Group getAllGroup() {
459 * Returns the list of groups, including the unmodifiable all lights group.
461 * @return list of groups
462 * @throws UnauthorizedException thrown if the user no longer exists
464 public List<FullGroup> getGroups()
465 throws IOException, ApiException, ConfigurationException, CommunicationException {
466 requireAuthentication();
468 HueResult result = get(getRelativeURL("groups"));
470 handleErrors(result);
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
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.");
486 groupMap.forEach((id, group) -> {
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.
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
505 public Group createGroup(List<HueObject> lights)
506 throws IOException, ApiException, ConfigurationException, CommunicationException {
507 requireAuthentication();
509 HueResult result = post(getRelativeURL("groups"), gson.toJson(new SetAttributesRequest(lights)));
511 handleErrors(result);
513 List<SuccessResponse> entries = safeFromJson(result.body, SuccessResponse.GSON_TYPE);
514 SuccessResponse response = entries.get(0);
516 Group group = new Group();
517 group.setName("Group");
518 group.setId(Util.quickMatch("^/groups/([0-9]+)$", (String) response.success.values().toArray()[0]));
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.
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
535 public Group createGroup(String name, List<HueObject> lights)
536 throws IOException, ApiException, ConfigurationException, CommunicationException {
537 requireAuthentication();
539 HueResult result = post(getRelativeURL("groups"), gson.toJson(new SetAttributesRequest(name, lights)));
541 handleErrors(result);
543 List<SuccessResponse> entries = safeFromJson(result.body, SuccessResponse.GSON_TYPE);
544 SuccessResponse response = entries.get(0);
546 Group group = new Group();
548 group.setId(Util.quickMatch("^/groups/([0-9]+)$", (String) response.success.values().toArray()[0]));
553 * Returns detailed information for the given 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
560 public FullGroup getGroup(Group group)
561 throws IOException, ApiException, ConfigurationException, CommunicationException {
562 requireAuthentication();
564 HueResult result = get(getRelativeURL("groups/" + enc(group.getId())));
566 handleErrors(result);
568 FullGroup fullGroup = safeFromJson(result.body, FullGroup.class);
569 fullGroup.setId(group.getId());
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.
578 * @param name new name [0..32]
580 * @throws UnauthorizedException thrown if the user no longer exists
581 * @throws EntityNotAvailableException thrown if the specified group no longer exists
583 public String setGroupName(Group group, String name)
584 throws IOException, ApiException, ConfigurationException, CommunicationException {
585 requireAuthentication();
587 if (!group.isModifiable()) {
588 throw new IllegalArgumentException("Group cannot be modified");
591 HueResult result = put(getRelativeURL("groups/" + enc(group.getId())),
592 gson.toJson(new SetAttributesRequest(name)));
594 handleErrors(result);
596 List<SuccessResponse> entries = safeFromJson(result.body, SuccessResponse.GSON_TYPE);
597 SuccessResponse response = entries.get(0);
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.");
607 * Changes the lights in the 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
614 public void setGroupLights(Group group, List<HueObject> lights)
615 throws IOException, ApiException, ConfigurationException, CommunicationException {
616 requireAuthentication();
618 if (!group.isModifiable()) {
619 throw new IllegalArgumentException("Group cannot be modified");
622 HueResult result = put(getRelativeURL("groups/" + enc(group.getId())),
623 gson.toJson(new SetAttributesRequest(lights)));
625 handleErrors(result);
629 * Changes the name and the lights of a group and returns the new name.
632 * @param name new name [0..32]
633 * @param lights [1..16]
635 * @throws UnauthorizedException thrown if the user no longer exists
636 * @throws EntityNotAvailableException thrown if the specified group no longer exists
638 public String setGroupAttributes(Group group, String name, List<HueObject> lights)
639 throws IOException, ApiException, ConfigurationException, CommunicationException {
640 requireAuthentication();
642 if (!group.isModifiable()) {
643 throw new IllegalArgumentException("Group cannot be modified");
646 HueResult result = put(getRelativeURL("groups/" + enc(group.getId())),
647 gson.toJson(new SetAttributesRequest(name, lights)));
649 handleErrors(result);
651 List<SuccessResponse> entries = safeFromJson(result.body, SuccessResponse.GSON_TYPE);
652 SuccessResponse response = entries.get(0);
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.");
662 * Changes the state of a 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
669 public CompletableFuture<HueResult> setGroupState(Group group, StateUpdate update) {
670 requireAuthentication();
672 return putAsync(getRelativeURL("groups/" + enc(group.getId()) + "/action"), update.toJson(),
673 update.getMessageDelay());
680 * @throws UnauthorizedException thrown if the user no longer exists
681 * @throws EntityNotAvailableException thrown if the specified group no longer exists
683 public void deleteGroup(Group group)
684 throws IOException, ApiException, ConfigurationException, CommunicationException {
685 requireAuthentication();
687 if (!group.isModifiable()) {
688 throw new IllegalArgumentException("Group cannot be modified");
691 HueResult result = delete(getRelativeURL("groups/" + enc(group.getId())));
693 handleErrors(result);
697 * Returns a list of schedules on the bridge.
700 * @throws UnauthorizedException thrown if the user no longer exists
702 public List<Schedule> getSchedules()
703 throws IOException, ApiException, ConfigurationException, CommunicationException {
704 requireAuthentication();
706 HueResult result = get(getRelativeURL("schedules"));
708 handleErrors(result);
710 Map<String, Schedule> scheduleMap = safeFromJson(result.body, Schedule.GSON_TYPE);
711 List<Schedule> schedules = new ArrayList<>();
712 scheduleMap.forEach((id, schedule) -> {
714 schedules.add(schedule);
720 * Changes a schedule.
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
727 public void setSchedule(Schedule schedule, ScheduleUpdate update)
728 throws IOException, ApiException, ConfigurationException, CommunicationException {
729 requireAuthentication();
731 HueResult result = put(getRelativeURL("schedules/" + enc(schedule.getId())), update.toJson());
733 handleErrors(result);
739 * @param schedule schedule
740 * @throws UnauthorizedException thrown if the user no longer exists
741 * @throws EntityNotAvailableException thrown if the schedule no longer exists
743 public void deleteSchedule(Schedule schedule)
744 throws IOException, ApiException, ConfigurationException, CommunicationException {
745 requireAuthentication();
747 HueResult result = delete(getRelativeURL("schedules/" + enc(schedule.getId())));
749 handleErrors(result);
753 * Returns the list of scenes that are not recyclable.
755 * @return all scenes that can be activated
757 public List<Scene> getScenes() throws IOException, ApiException, ConfigurationException, CommunicationException {
758 requireAuthentication();
760 HueResult result = get(getRelativeURL("scenes"));
762 handleErrors(result);
764 Map<String, Scene> sceneMap = safeFromJson(result.body, Scene.GSON_TYPE);
765 return sceneMap.entrySet().stream()//
767 e.getValue().setId(e.getKey());
770 .filter(scene -> !scene.isRecycle())//
771 .sorted(Comparator.comparing(Scene::extractKeyForComparator))//
772 .collect(Collectors.toList());
776 * Activate scene to all lights that belong to the scene.
778 * @param id the scene to be activated
779 * @throws IOException if the bridge cannot be reached
781 public CompletableFuture<HueResult> recallScene(String id) {
782 Group allLightsGroup = new Group();
783 return setGroupState(allLightsGroup, new StateUpdate().setScene(id));
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.
792 * @param username username to authenticate
793 * @throws ConfigurationException thrown on ssl failure
794 * @throws UnauthorizedException thrown if authentication failed
796 public void authenticate(String username)
797 throws IOException, ApiException, ConfigurationException, UnauthorizedException {
799 this.username = username;
801 } catch (ConfigurationException e) {
803 } catch (Exception e) {
804 this.username = null;
805 throw new UnauthorizedException(e.toString());
810 * Link with bridge using the specified username and device type.
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
816 public void link(String username, String devicetype)
817 throws IOException, ApiException, ConfigurationException, CommunicationException {
818 this.username = link(new CreateUserRequest(username, devicetype));
822 * Link with bridge using the specified device type. A random valid username will be generated by the bridge and
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
829 public String link(String devicetype)
830 throws IOException, ApiException, ConfigurationException, CommunicationException {
831 return (this.username = link(new CreateUserRequest(devicetype)));
834 private String link(CreateUserRequest request)
835 throws IOException, ApiException, ConfigurationException, CommunicationException {
836 if (this.username != null) {
837 throw new IllegalStateException("already linked");
840 HueResult result = post(getRelativeURL(""), gson.toJson(request));
842 handleErrors(result);
844 List<SuccessResponse> entries = safeFromJson(result.body, SuccessResponse.GSON_TYPE);
845 SuccessResponse response = entries.get(0);
847 String username = (String) response.success.get("username");
848 if (username == null) {
849 throw new ApiException("Response didn't contain username");
855 * Returns bridge configuration.
858 * @return bridge configuration
859 * @throws UnauthorizedException thrown if the user no longer exists
861 public Config getConfig() throws IOException, ApiException, ConfigurationException, CommunicationException {
862 requireAuthentication();
864 HueResult result = get(getRelativeURL("config"));
866 handleErrors(result);
868 return safeFromJson(result.body, Config.class);
872 * Change the configuration of the bridge.
874 * @param update changes to the configuration
875 * @throws UnauthorizedException thrown if the user no longer exists
877 public void setConfig(ConfigUpdate update)
878 throws IOException, ApiException, ConfigurationException, CommunicationException {
879 requireAuthentication();
881 HueResult result = put(getRelativeURL("config"), update.toJson());
883 handleErrors(result);
887 * Unlink the current user from the bridge.
889 * @throws UnauthorizedException thrown if the user no longer exists
891 public void unlink() throws IOException, ApiException, ConfigurationException, CommunicationException {
892 requireAuthentication();
894 HueResult result = delete(getRelativeURL("config/whitelist/" + enc(username)));
896 handleErrors(result);
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.
905 * @return full bridge configuration
906 * @throws UnauthorizedException thrown if the user no longer exists
908 public FullConfig getFullConfig() throws IOException, ApiException, ConfigurationException, CommunicationException {
909 requireAuthentication();
911 HueResult result = get(getRelativeURL(""));
913 handleErrors(result);
915 FullConfig fullConfig = gson.fromJson(result.body, FullConfig.class);
916 return Objects.requireNonNull(fullConfig);
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");
926 // Methods that convert gson exceptions into ApiExceptions
927 private <T> T safeFromJson(String json, Type typeOfT) throws ApiException {
929 return gson.fromJson(json, typeOfT);
930 } catch (JsonParseException e) {
931 throw new ApiException("API returned unexpected result: " + e.getMessage());
935 private <T> T safeFromJson(String json, Class<T> classOfT) throws ApiException {
937 return gson.fromJson(json, classOfT);
938 } catch (JsonParseException e) {
939 throw new ApiException("API returned unexpected result: " + e.getMessage());
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();
949 List<ErrorResponse> errors = gson.fromJson(result.body, ErrorResponse.GSON_TYPE);
950 if (errors == null) {
954 for (ErrorResponse error : errors) {
955 if (error.getType() == null) {
959 switch (error.getType()) {
962 throw new UnauthorizedException(error.getDescription());
964 throw new EntityNotAvailableException(error.getDescription());
966 throw new InvalidCommandException(error.getDescription());
968 throw new LinkButtonException(error.getDescription());
970 throw new DeviceOffException(error.getDescription());
972 throw new GroupTableFullException(error.getDescription());
974 throw new ApiException(error.getDescription());
977 } catch (JsonParseException e) {
984 private String enc(@Nullable String str) {
985 return str == null ? "" : URLEncoder.encode(str, StandardCharsets.UTF_8);
988 private String getRelativeURL(String path) {
989 String relativeUrl = baseUrl;
990 if (username != null) {
991 relativeUrl += "/" + enc(username);
993 return path.isEmpty() ? relativeUrl : relativeUrl + "/" + path;
996 public HueResult get(String address) throws ConfigurationException, CommunicationException {
997 return doNetwork(address, HttpMethod.GET);
1000 public HueResult post(String address, String body) throws ConfigurationException, CommunicationException {
1001 return doNetwork(address, HttpMethod.POST, body);
1004 public HueResult put(String address, String body) throws ConfigurationException, CommunicationException {
1005 return doNetwork(address, HttpMethod.PUT, body);
1008 public HueResult delete(String address) throws ConfigurationException, CommunicationException {
1009 return doNetwork(address, HttpMethod.DELETE);
1012 private HueResult doNetwork(String address, HttpMethod requestMethod)
1013 throws ConfigurationException, CommunicationException {
1014 return doNetwork(address, requestMethod, null);
1017 private HueResult doNetwork(String address, HttpMethod requestMethod, @Nullable String body)
1018 throws ConfigurationException, CommunicationException {
1019 logger.trace("Hue request: {} - URL = '{}'", requestMethod, address);
1021 final Request request = httpClient.newRequest(address).method(requestMethod).timeout(timeout,
1022 TimeUnit.MILLISECONDS);
1025 logger.trace("Hue request body: '{}'", body);
1026 request.content(new StringContentProvider(body), "application/json");
1029 final ContentResponse contentResponse = request.send();
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());
1041 logger.debug("ExecutionException occurred during execution: {}", message, e);
1042 throw new CommunicationException(message == null ? TEXT_OFFLINE_COMMUNICATION_ERROR : message,
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);
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);
1067 commandsQueue.offer(asyncPutParameters);
1070 return asyncPutParameters.future;
1073 private void executeCommands() {
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);
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);
1089 delayTime = payloadCallbackPair.delay;
1094 Thread.sleep(delayTime);
1095 } catch (InterruptedException e) {
1096 logger.debug("commandExecutorThread was interrupted", e);
1101 public static class HueResult {
1102 public final String body;
1103 public final int responseCode;
1105 public HueResult(String body, int responseCode) {
1107 this.responseCode = responseCode;
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;
1117 public AsyncPutParameters(String address, String body, long delay) {
1118 this.address = address;
1120 this.future = new CompletableFuture<>();