/** * Copyright (c) 2014-2017 by the respective copyright holders. * All rights reserved. This program and the accompanying materials * are made available under the terms of the Eclipse Public License v1.0 * which accompanies this distribution, and is available at * http://www.eclipse.org/legal/epl-v10.html */ package org.eclipse.smarthome.binding.hue.internal; import java.io.IOException; import java.io.UnsupportedEncodingException; import java.lang.reflect.Type; import java.net.URLEncoder; import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Date; import java.util.List; import java.util.Map; import org.eclipse.smarthome.binding.hue.internal.HttpClient.Result; import org.eclipse.smarthome.binding.hue.internal.exceptions.ApiException; import org.eclipse.smarthome.binding.hue.internal.exceptions.DeviceOffException; import org.eclipse.smarthome.binding.hue.internal.exceptions.EntityNotAvailableException; import org.eclipse.smarthome.binding.hue.internal.exceptions.GroupTableFullException; import org.eclipse.smarthome.binding.hue.internal.exceptions.InvalidCommandException; import org.eclipse.smarthome.binding.hue.internal.exceptions.LinkButtonException; import org.eclipse.smarthome.binding.hue.internal.exceptions.UnauthorizedException; import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.google.gson.JsonElement; import com.google.gson.JsonParseException; import com.google.gson.JsonParser; /** * Representation of a connection with a Hue bridge. * * @author Q42, standalone Jue library (https://github.com/Q42/Jue) * @author Andre Fuechsel - search for lights with given serial number added * @author Denis Dudnik - moved Jue library source code inside the smarthome Hue binding, minor code cleanup */ public class HueBridge { private final static String DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ss"; private String ip; private String username; private Gson gson = new GsonBuilder().setDateFormat(DATE_FORMAT).create(); private HttpClient http = new HttpClient(); /** * Connect with a bridge as a new user. * * @param ip ip address of bridge */ public HueBridge(String ip) { this.ip = ip; } /** * Connect with a bridge as an existing user. * * The username is verified by requesting the list of lights. * Use the ip only constructor and authenticate() function if * you don't want to connect right now. * * @param ip ip address of bridge * @param username username to authenticate with */ public HueBridge(String ip, String username) throws IOException, ApiException { this.ip = ip; authenticate(username); } /** * Set the connect and read timeout for HTTP requests. * * @param timeout timeout in milliseconds or 0 for indefinitely */ public void setTimeout(int timeout) { http.setTimeout(timeout); } /** * Returns the IP address of the bridge. * * @return ip address of bridge */ public String getIPAddress() { return ip; } /** * Returns the username currently authenticated with or null if there isn't one. * * @return username or null */ public String getUsername() { return username; } /** * Returns if authentication was successful on the bridge. * * @return true if authenticated on the bridge, false otherwise */ public boolean isAuthenticated() { return getUsername() != null; } /** * Returns a list of lights known to the bridge. * * @return list of known lights * @throws UnauthorizedException thrown if the user no longer exists */ public List<Light> getLights() throws IOException, ApiException { requireAuthentication(); Result result = http.get(getRelativeURL("lights")); handleErrors(result); Map<String, Light> lightMap = safeFromJson(result.getBody(), Light.gsonType); ArrayList<Light> lightList = new ArrayList<>(); for (String id : lightMap.keySet()) { Light light = lightMap.get(id); light.setId(id); lightList.add(light); } return lightList; } /** * Returns the last time a search for new lights was started. * If a search is currently running, the current time will be * returned or null if a search has never been started. * * @return last search time * @throws UnauthorizedException thrown if the user no longer exists */ public Date getLastSearch() throws IOException, ApiException { requireAuthentication(); Result result = http.get(getRelativeURL("lights/new")); handleErrors(result); String lastScan = safeFromJson(result.getBody(), NewLightsResponse.class).lastscan; switch (lastScan) { case "none": return null; case "active": return new Date(); default: try { return new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss").parse(lastScan); } catch (ParseException e) { return null; } } } /** * Start searching for new lights for 1 minute. * A maximum amount of 15 new lights will be added. * * @throws UnauthorizedException thrown if the user no longer exists */ public void startSearch() throws IOException, ApiException { requireAuthentication(); Result result = http.post(getRelativeURL("lights"), ""); handleErrors(result); } /** * Start searching for new lights with given serial numbers for 1 minute. * A maximum amount of 15 new lights will be added. * * @param serialNumbers list of serial numbers * @throws UnauthorizedException thrown if the user no longer exists */ public void startSearch(List<String> serialNumbers) throws IOException, ApiException { requireAuthentication(); String body = gson.toJson(new SearchForLightsRequest(serialNumbers)); Result result = http.post(getRelativeURL("lights"), body); handleErrors(result); } /** * Returns detailed information for the given light. * * @param light light * @return detailed light information * @throws UnauthorizedException thrown if the user no longer exists * @throws EntityNotAvailableException thrown if a light with the given id doesn't exist */ public FullLight getLight(Light light) throws IOException, ApiException { requireAuthentication(); Result result = http.get(getRelativeURL("lights/" + enc(light.getId()))); handleErrors(result); FullLight fullLight = safeFromJson(result.getBody(), FullLight.class); fullLight.setId(light.getId()); return fullLight; } /** * Changes the name of the light and returns the new name. * A number will be appended to duplicate names, which may result in a new name exceeding 32 characters. * * @param light light * @param name new name [0..32] * @return new name * @throws UnauthorizedException thrown if the user no longer exists * @throws EntityNotAvailableException thrown if the specified light no longer exists */ public String setLightName(Light light, String name) throws IOException, ApiException { requireAuthentication(); String body = gson.toJson(new SetAttributesRequest(name)); Result result = http.put(getRelativeURL("lights/" + enc(light.getId())), body); handleErrors(result); List<SuccessResponse> entries = safeFromJson(result.getBody(), SuccessResponse.gsonType); SuccessResponse response = entries.get(0); return (String) response.success.get("/lights/" + enc(light.getId()) + "/name"); } /** * Changes the state of a light. * * @param light light * @param update changes to the state * @throws UnauthorizedException thrown if the user no longer exists * @throws EntityNotAvailableException thrown if the specified light no longer exists * @throws DeviceOffException thrown if the specified light is turned off */ public void setLightState(Light light, StateUpdate update) throws IOException, ApiException { requireAuthentication(); String body = update.toJson(); Result result = http.put(getRelativeURL("lights/" + enc(light.getId()) + "/state"), body); handleErrors(result); } /** * Returns a group object representing all lights. * * @return all lights pseudo group */ public Group getAllGroup() { requireAuthentication(); return new Group(); } /** * Returns the list of groups, including the unmodifiable all lights group. * * @return list of groups * @throws UnauthorizedException thrown if the user no longer exists */ public List<Group> getGroups() throws IOException, ApiException { requireAuthentication(); Result result = http.get(getRelativeURL("groups")); handleErrors(result); Map<String, Group> groupMap = safeFromJson(result.getBody(), Group.gsonType); ArrayList<Group> groupList = new ArrayList<>(); groupList.add(new Group()); for (String id : groupMap.keySet()) { Group group = groupMap.get(id); group.setId(id); groupList.add(group); } return groupList; } /** * Creates a new group and returns it. * Due to API limitations, the name of the returned object * will simply be "Group". The bridge will append a number to this * name if it's a duplicate. To get the final name, call getGroup * with the returned object. * * @param lights lights in group * @return object representing new group * @throws UnauthorizedException thrown if the user no longer exists * @throws GroupTableFullException thrown if the group limit has been reached */ public Group createGroup(List<Light> lights) throws IOException, ApiException { requireAuthentication(); String body = gson.toJson(new SetAttributesRequest(lights)); Result result = http.post(getRelativeURL("groups"), body); handleErrors(result); List<SuccessResponse> entries = safeFromJson(result.getBody(), SuccessResponse.gsonType); SuccessResponse response = entries.get(0); Group group = new Group(); group.setName("Group"); group.setId(Util.quickMatch("^/groups/([0-9]+)$", (String) response.success.values().toArray()[0])); return group; } /** * Creates a new group and returns it. * Due to API limitations, the name of the returned object * will simply be the same as the name parameter. The bridge will * append a number to the name if it's a duplicate. To get the final * name, call getGroup with the returned object. * * @param name new group name * @param lights lights in group * @return object representing new group * @throws UnauthorizedException thrown if the user no longer exists * @throws GroupTableFullException thrown if the group limit has been reached */ public Group createGroup(String name, List<Light> lights) throws IOException, ApiException { requireAuthentication(); String body = gson.toJson(new SetAttributesRequest(name, lights)); Result result = http.post(getRelativeURL("groups"), body); handleErrors(result); List<SuccessResponse> entries = safeFromJson(result.getBody(), SuccessResponse.gsonType); SuccessResponse response = entries.get(0); Group group = new Group(); group.setName(name); group.setId(Util.quickMatch("^/groups/([0-9]+)$", (String) response.success.values().toArray()[0])); return group; } /** * Returns detailed information for the given group. * * @param group group * @return detailed group information * @throws UnauthorizedException thrown if the user no longer exists * @throws EntityNotAvailableException thrown if a group with the given id doesn't exist */ public FullGroup getGroup(Group group) throws IOException, ApiException { requireAuthentication(); Result result = http.get(getRelativeURL("groups/" + enc(group.getId()))); handleErrors(result); FullGroup fullGroup = safeFromJson(result.getBody(), FullGroup.class); fullGroup.setId(group.getId()); return fullGroup; } /** * Changes the name of the group and returns the new name. * A number will be appended to duplicate names, which may result in a new name exceeding 32 characters. * * @param group group * @param name new name [0..32] * @return new name * @throws UnauthorizedException thrown if the user no longer exists * @throws EntityNotAvailableException thrown if the specified group no longer exists */ public String setGroupName(Group group, String name) throws IOException, ApiException { requireAuthentication(); if (!group.isModifiable()) { throw new IllegalArgumentException("Group cannot be modified"); } String body = gson.toJson(new SetAttributesRequest(name)); Result result = http.put(getRelativeURL("groups/" + enc(group.getId())), body); handleErrors(result); List<SuccessResponse> entries = safeFromJson(result.getBody(), SuccessResponse.gsonType); SuccessResponse response = entries.get(0); return (String) response.success.get("/groups/" + enc(group.getId()) + "/name"); } /** * Changes the lights in the group. * * @param group group * @param lights new lights [1..16] * @throws UnauthorizedException thrown if the user no longer exists * @throws EntityNotAvailableException thrown if the specified group no longer exists */ public void setGroupLights(Group group, List<Light> lights) throws IOException, ApiException { requireAuthentication(); if (!group.isModifiable()) { throw new IllegalArgumentException("Group cannot be modified"); } String body = gson.toJson(new SetAttributesRequest(lights)); Result result = http.put(getRelativeURL("groups/" + enc(group.getId())), body); handleErrors(result); } /** * Changes the name and the lights of a group and returns the new name. * * @param group group * @param name new name [0..32] * @param lights [1..16] * @return new name * @throws UnauthorizedException thrown if the user no longer exists * @throws EntityNotAvailableException thrown if the specified group no longer exists */ public String setGroupAttributes(Group group, String name, List<Light> lights) throws IOException, ApiException { requireAuthentication(); if (!group.isModifiable()) { throw new IllegalArgumentException("Group cannot be modified"); } String body = gson.toJson(new SetAttributesRequest(name, lights)); Result result = http.put(getRelativeURL("groups/" + enc(group.getId())), body); handleErrors(result); List<SuccessResponse> entries = safeFromJson(result.getBody(), SuccessResponse.gsonType); SuccessResponse response = entries.get(0); return (String) response.success.get("/groups/" + enc(group.getId()) + "/name"); } /** * Changes the state of a group. * * @param group group * @param update changes to the state * @throws UnauthorizedException thrown if the user no longer exists * @throws EntityNotAvailableException thrown if the specified group no longer exists */ public void setGroupState(Group group, StateUpdate update) throws IOException, ApiException { requireAuthentication(); String body = update.toJson(); Result result = http.put(getRelativeURL("groups/" + enc(group.getId()) + "/action"), body); handleErrors(result); } /** * Delete a group. * * @param group group * @throws UnauthorizedException thrown if the user no longer exists * @throws EntityNotAvailableException thrown if the specified group no longer exists */ public void deleteGroup(Group group) throws IOException, ApiException { requireAuthentication(); if (!group.isModifiable()) { throw new IllegalArgumentException("Group cannot be modified"); } Result result = http.delete(getRelativeURL("groups/" + enc(group.getId()))); handleErrors(result); } /** * Returns a list of schedules on the bridge. * * @return schedules * @throws UnauthorizedException thrown if the user no longer exists */ public List<Schedule> getSchedules() throws IOException, ApiException { requireAuthentication(); Result result = http.get(getRelativeURL("schedules")); handleErrors(result); Map<String, Schedule> scheduleMap = safeFromJson(result.getBody(), Schedule.gsonType); ArrayList<Schedule> scheduleList = new ArrayList<>(); for (String id : scheduleMap.keySet()) { Schedule schedule = scheduleMap.get(id); schedule.setId(id); scheduleList.add(schedule); } return scheduleList; } /** * Schedules a new command to be run at the specified time. * To select the command for the new schedule, simply run it * as you normally would in the callback. Instead of it running * immediately, it will be scheduled to run at the specified time. * It will automatically fail with an IOException, because there * will be no response. Note that GET methods cannot be scheduled, * so those will still run and return results immediately. * * @param time time to run command * @param callback callback in which the command is specified * @throws UnauthorizedException thrown if the user no longer exists * @throws InvalidCommandException thrown if the scheduled command is larger than 90 bytes or otherwise invalid */ public void createSchedule(Date time, ScheduleCallback callback) throws IOException, ApiException { createSchedule(null, null, time, callback); } /** * Schedules a new command to be run at the specified time. * To select the command for the new schedule, simply run it * as you normally would in the callback. Instead of it running * immediately, it will be scheduled to run at the specified time. * It will automatically fail with an IOException, because there * will be no response. Note that GET methods cannot be scheduled, * so those will still run and return results immediately. * * @param name name [0..32] * @param time time to run command * @param callback callback in which the command is specified * @throws UnauthorizedException thrown if the user no longer exists * @throws InvalidCommandException thrown if the scheduled command is larger than 90 bytes or otherwise invalid */ public void createSchedule(String name, Date time, ScheduleCallback callback) throws IOException, ApiException { createSchedule(name, null, time, callback); } /** * Schedules a new command to be run at the specified time. * To select the command for the new schedule, simply run it * as you normally would in the callback. Instead of it running * immediately, it will be scheduled to run at the specified time. * It will automatically fail with an IOException, because there * will be no response. Note that GET methods cannot be scheduled, * so those will still run and return results immediately. * * @param name name [0..32] * @param description description [0..64] * @param time time to run command * @param callback callback in which the command is specified * @throws UnauthorizedException thrown if the user no longer exists * @throws InvalidCommandException thrown if the scheduled command is larger than 90 bytes or otherwise invalid */ public void createSchedule(String name, String description, Date time, ScheduleCallback callback) throws IOException, ApiException { requireAuthentication(); handleCommandCallback(callback); String body = gson.toJson(new CreateScheduleRequest(name, description, scheduleCommand, time)); Result result = http.post(getRelativeURL("schedules"), body); handleErrors(result); } /** * Returns detailed information for the given schedule. * * @param schedule schedule * @return detailed schedule information * @throws UnauthorizedException thrown if the user no longer exists * @throws EntityNotAvailableException thrown if the specified schedule no longer exists */ public FullSchedule getSchedule(Schedule schedule) throws IOException, ApiException { requireAuthentication(); Result result = http.get(getRelativeURL("schedules/" + enc(schedule.getId()))); handleErrors(result); FullSchedule fullSchedule = safeFromJson(result.getBody(), FullSchedule.class); fullSchedule.setId(schedule.getId()); return fullSchedule; } /** * Changes a schedule. * * @param schedule schedule * @param update changes * @throws UnauthorizedException thrown if the user no longer exists * @throws EntityNotAvailableException thrown if the specified schedule no longer exists */ public void setSchedule(Schedule schedule, ScheduleUpdate update) throws IOException, ApiException { requireAuthentication(); String body = update.toJson(); Result result = http.put(getRelativeURL("schedules/" + enc(schedule.getId())), body); handleErrors(result); } /** * Changes the command of a schedule. * * @param schedule schedule * @param callback callback for new command * @see #createSchedule(String, String, Date, ScheduleCallback) * @throws UnauthorizedException thrown if the user no longer exists * @throws InvalidCommandException thrown if the scheduled command is larger than 90 bytes or otherwise invalid */ public void setScheduleCommand(Schedule schedule, ScheduleCallback callback) throws IOException, ApiException { requireAuthentication(); handleCommandCallback(callback); String body = gson.toJson(new CreateScheduleRequest(null, null, scheduleCommand, null)); Result result = http.put(getRelativeURL("schedules/" + enc(schedule.getId())), body); handleErrors(result); } /** * Callback to specify a schedule command. */ public interface ScheduleCallback { /** * Run the command you want to schedule as if you're executing * it normally. The request will automatically fail to produce * a result by throwing an IOException. Requests that only * get data (e.g. getGroups) will still execute immediately, * because those cannot be scheduled. * * @param bridge this bridge for convenience * @throws IOException always thrown right after executing a command */ public void onScheduleCommand(HueBridge bridge) throws IOException, ApiException; } private ScheduleCommand scheduleCommand = null; private ScheduleCommand handleCommandCallback(ScheduleCallback callback) throws ApiException { // Temporarily reroute requests to a fake HTTP client HttpClient realClient = http; http = new HttpClient() { @Override protected Result doNetwork(String address, String requestMethod, String body) throws IOException { // GET requests cannot be scheduled, so will continue working normally for convenience if (requestMethod.equals("GET")) { return super.doNetwork(address, requestMethod, body); } else { address = Util.quickMatch("^http://[^/]+(.+)$", address); JsonElement commandBody = new JsonParser().parse(body); scheduleCommand = new ScheduleCommand(address, requestMethod, commandBody); // Return a fake result that will cause an exception and the callback to end return new Result(null, 405); } } }; // Run command try { scheduleCommand = null; callback.onScheduleCommand(this); } catch (IOException e) { // Command will automatically fail to return a result because of deferred execution } finally { if (scheduleCommand != null && Util.stringSize(scheduleCommand.getBody()) > 90) { throw new InvalidCommandException("Commmand body is larger than 90 bytes"); } } // Restore HTTP client http = realClient; return scheduleCommand; } /** * Delete a schedule. * * @param schedule schedule * @throws UnauthorizedException thrown if the user no longer exists * @throws EntityNotAvailableException thrown if the schedule no longer exists */ public void deleteSchedule(Schedule schedule) throws IOException, ApiException { requireAuthentication(); Result result = http.delete(getRelativeURL("schedules/" + enc(schedule.getId()))); handleErrors(result); } /** * Authenticate on the bridge as the specified user. * This function verifies that the specified username is valid and will use * it for subsequent requests if it is, otherwise an UnauthorizedException * is thrown and the internal username is not changed. * * @param username username to authenticate * @throws UnauthorizedException thrown if authentication failed */ public void authenticate(String username) throws IOException, ApiException { try { this.username = username; getLights(); } catch (Exception e) { this.username = null; throw new UnauthorizedException(e.toString()); } } /** * Link with bridge using the specified username and device type. * * @param username username for new user [10..40] * @param devicetype identifier of application [0..40] * @throws LinkButtonException thrown if the bridge button has not been pressed */ public void link(String username, String devicetype) throws IOException, ApiException { this.username = link(new CreateUserRequest(username, devicetype)); } /** * Link with bridge using the specified device type. A random valid username will be generated by the bridge and * returned. * * @return new random username generated by bridge * @param devicetype identifier of application [0..40] * @throws LinkButtonException thrown if the bridge button has not been pressed */ public String link(String devicetype) throws IOException, ApiException { return (this.username = link(new CreateUserRequest(devicetype))); } private String link(CreateUserRequest request) throws IOException, ApiException { if (this.username != null) { throw new IllegalStateException("already linked"); } String body = gson.toJson(request); Result result = http.post(getRelativeURL(""), body); handleErrors(result); List<SuccessResponse> entries = safeFromJson(result.getBody(), SuccessResponse.gsonType); SuccessResponse response = entries.get(0); return (String) response.success.get("username"); } /** * Returns bridge configuration. * * @see Config * @return bridge configuration * @throws UnauthorizedException thrown if the user no longer exists */ public Config getConfig() throws IOException, ApiException { requireAuthentication(); Result result = http.get(getRelativeURL("config")); handleErrors(result); return safeFromJson(result.getBody(), Config.class); } /** * Change the configuration of the bridge. * * @param update changes to the configuration * @throws UnauthorizedException thrown if the user no longer exists */ public void setConfig(ConfigUpdate update) throws IOException, ApiException { requireAuthentication(); String body = update.toJson(); Result result = http.put(getRelativeURL("config"), body); handleErrors(result); } /** * Unlink the current user from the bridge. * * @throws UnauthorizedException thrown if the user no longer exists */ public void unlink() throws IOException, ApiException { requireAuthentication(); Result result = http.delete(getRelativeURL("config/whitelist/" + enc(username))); handleErrors(result); } /** * Returns the entire bridge configuration. * This request is rather resource intensive for the bridge, * don't use it more often than necessary. Prefer using requests for * specific information your app needs. * * @return full bridge configuration * @throws UnauthorizedException thrown if the user no longer exists */ public FullConfig getFullConfig() throws IOException, ApiException { requireAuthentication(); Result result = http.get(getRelativeURL("")); handleErrors(result); return gson.fromJson(result.getBody(), FullConfig.class); } // Used as assert in requests that require authentication private void requireAuthentication() { if (this.username == null) { throw new IllegalStateException("linking is required before interacting with the bridge"); } } // Methods that convert gson exceptions into ApiExceptions private <T> T safeFromJson(String json, Type typeOfT) throws ApiException { try { return gson.fromJson(json, typeOfT); } catch (JsonParseException e) { throw new ApiException("API returned unexpected result: " + e.getMessage()); } } private <T> T safeFromJson(String json, Class<T> classOfT) throws ApiException { try { return gson.fromJson(json, classOfT); } catch (JsonParseException e) { throw new ApiException("API returned unexpected result: " + e.getMessage()); } } // Used as assert in all requests to elegantly catch common errors private void handleErrors(Result result) throws IOException, ApiException { if (result.getResponseCode() != 200) { throw new IOException(); } else { try { List<ErrorResponse> errors = gson.fromJson(result.getBody(), ErrorResponse.gsonType); if (errors == null) { return; } for (ErrorResponse error : errors) { switch (error.getType()) { case 1: username = null; throw new UnauthorizedException(error.getDescription()); case 3: throw new EntityNotAvailableException(error.getDescription()); case 7: throw new InvalidCommandException(error.getDescription()); case 101: throw new LinkButtonException(error.getDescription()); case 201: throw new DeviceOffException(error.getDescription()); case 301: throw new GroupTableFullException(error.getDescription()); default: throw new ApiException(error.getDescription()); } } } catch (JsonParseException e) { // Not an error } catch (NullPointerException e) { // Object that looks like error } } } // UTF-8 URL encode private String enc(String str) { try { return URLEncoder.encode(str, "utf-8"); } catch (UnsupportedEncodingException e) { // throw new EndOfTheWorldException() throw new UnsupportedOperationException("UTF-8 not supported"); } } private String getRelativeURL(String path) { if (username == null) { return "http://" + ip + "/api/" + path; } else { return "http://" + ip + "/api/" + enc(username) + "/" + path; } } }