async def async_update_group(self, request: web.Request, request_data: dict): """Handle requests to update a group.""" group_id = request.match_info["group_id"] username = request.match_info["username"] local_group = await self.config.async_get_storage_value( "groups", group_id) if not local_group: return web.Response(status=404) update_dict(local_group, request_data) # Hue entertainment support (experimental) if "stream" in local_group: if local_group["stream"].get("active"): # Requested streaming start LOGGER.debug( "Start Entertainment mode for group %s - params: %s", group_id, request_data, ) if not self.streaming_api: user_data = await self.config.async_get_user(username) self.streaming_api = EntertainmentAPI( self.hue, local_group, user_data) local_group["stream"]["owner"] = username if not local_group["stream"].get("proxymode"): local_group["stream"]["proxymode"] = "auto" if not local_group["stream"].get("proxynode"): local_group["stream"]["proxynode"] = "/bridge" else: # Request streaming stop LOGGER.info( "Stop Entertainment mode for group %s - params: %s", group_id, request_data, ) local_group["stream"] = {"active": False} if self.streaming_api: # stop service if needed self.streaming_api.stop() self.streaming_api = None await self.config.async_set_storage_value("groups", group_id, local_group) response = await self.__async_create_hue_response( request.path, request_data, username) return web.json_response(response)
async def async_update_group(self, request: web.Request, request_data: dict): """Handle requests to update a group.""" group_id = request.match_info["group_id"] username = request.match_info["username"] group_conf = await self.config.async_get_storage_value( "groups", group_id) if not group_conf: return send_error_response(request.path, "no group config", 404) update_dict(group_conf, request_data) # Hue entertainment support (experimental) if "stream" in group_conf: if group_conf["stream"].get("active"): # Requested streaming start LOGGER.debug( "Start Entertainment mode for group %s - params: %s", group_id, request_data, ) del group_conf["stream"]["active"] if not self.streaming_api: user_data = await self.config.async_get_user(username) self.streaming_api = EntertainmentAPI( self.hue, group_conf, user_data) group_conf["stream"]["owner"] = username if not group_conf["stream"].get("proxymode"): group_conf["stream"]["proxymode"] = "auto" if not group_conf["stream"].get("proxynode"): group_conf["stream"]["proxynode"] = "/bridge" else: # Request streaming stop LOGGER.info( "Stop Entertainment mode for group %s - params: %s", group_id, request_data, ) if self.streaming_api: # stop service if needed self.streaming_api.stop() self.streaming_api = None await self.config.async_set_storage_value("groups", group_id, group_conf) return send_success_response(request.path, request_data, username)
class HueApi: """Support for a Hue API to control Home Assistant.""" runner = None def __init__(self, hue): """Initialize with Hue object.""" self.streaming_api = None self.config = hue.config self.hue = hue self.http_site = None self.https_site = None self._new_lights = {} self._timestamps = {} self._prev_data = {} with open(DESCRIPTION_FILE, encoding="utf-8") as fdesc: self._description_xml = fdesc.read() async def async_setup(self): """Async set-up of the webserver.""" app = web.Application() # add config routes app.router.add_route("GET", "/api/{username}/config", self.async_get_bridge_config) app.router.add_route("GET", "/api/config", self.async_get_bridge_config) app.router.add_route("GET", "/api/{username}/config/", self.async_get_bridge_config) app.router.add_route("GET", "/api/config/", self.async_get_bridge_config) # add all routes defined with decorator routes.add_class_routes(self) app.add_routes(routes) # Add catch-all handler for unknown requests to api app.router.add_route("*", "/api/{tail:.*}", self.async_unknown_request) # static files hosting app.router.add_static("/", STATIC_DIR, append_version=True) self.runner = web.AppRunner(app, access_log=None) await self.runner.setup() # Create and start the HTTP webserver/api self.http_site = web.TCPSite(self.runner, port=self.config.http_port) try: await self.http_site.start() LOGGER.info("Started HTTP webserver on port %s", self.config.http_port) except OSError as error: LOGGER.error( "Failed to create HTTP server at port %d: %s", self.config.http_port, error, ) # create self signed certificate for HTTPS API cert_file = self.config.get_path(".cert.pem") key_file = self.config.get_path(".cert_key.pem") if not check_certificate(cert_file, self.config): await async_generate_selfsigned_cert(cert_file, key_file, self.config) ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) ssl_context.load_cert_chain(cert_file, key_file) # Create and start the HTTPS webserver/API self.https_site = web.TCPSite(self.runner, port=self.config.https_port, ssl_context=ssl_context) try: await self.https_site.start() LOGGER.info("Started HTTPS webserver on port %s", self.config.https_port) except OSError as error: LOGGER.error( "Failed to create HTTPS server at port %d: %s", self.config.https_port, error, ) async def async_stop(self): """Stop the webserver.""" await self.http_site.stop() await self.https_site.stop() if self.streaming_api: self.streaming_api.stop() @routes.post("/api") @check_request(False) async def async_post_auth(self, request: web.Request, request_data: dict): """Handle requests to create a username for the emulated hue bridge.""" if "devicetype" not in request_data: LOGGER.warning("devicetype not specified") # custom error message return send_error_response(request.path, "devicetype not specified", 302) if not self.config.link_mode_enabled: await self.config.async_enable_link_mode_discovery() return send_error_response(request.path, "link button not pressed", 101) userdetails = await self.config.async_create_user( request_data["devicetype"]) response = [{"success": {"username": userdetails["username"]}}] if request_data.get("generateclientkey"): response[0]["success"]["clientkey"] = userdetails["clientkey"] LOGGER.info("Client %s registered", userdetails["name"]) return send_json_response(response) @routes.get("/api/{username}/lights") @check_request() async def async_get_lights(self, request: web.Request): """Handle requests to retrieve the info all lights.""" return send_json_response(await self.__async_get_all_lights()) @routes.get("/api/{username}/lights/new") @check_request() async def async_get_new_lights(self, request: web.Request): """Handle requests to retrieve new added lights to the (virtual) bridge.""" return send_json_response(self._new_lights) @routes.post("/api/{username}/lights") @check_request() async def async_search_new_lights(self, request: web.Request, request_data): """Handle requests to retrieve new added lights to the (virtual) bridge.""" username = request.match_info["username"] LOGGER.info( "Search mode activated. Any deleted/disabled lights will be reactivated." ) def auto_disable(): self._new_lights = {} self.hue.loop.call_later(60, auto_disable) # enable all disabled lights and groups for entity in self.hue.hass.lights: entity_id = entity["entity_id"] light_id = await self.config.async_entity_id_to_light_id(entity_id) light_config = await self.config.async_get_light_config(light_id) if not light_config["enabled"]: light_config["enabled"] = True await self.config.async_set_storage_value( "lights", light_id, light_config) # add to new_lights for the app to show a special badge self._new_lights[light_id] = await self.__async_entity_to_hue( entity, light_config) groups = await self.config.async_get_storage_value("groups", default={}) for group_id, group_conf in groups.items(): if "enabled" in group_conf and not group_conf["enabled"]: group_conf["enabled"] = True await self.config.async_set_storage_value( "groups", group_id, group_conf) return send_success_response(request.path, {}, username) @routes.get("/api/{username}/lights/{light_id}") @check_request() async def async_get_light(self, request: web.Request): """Handle requests to retrieve the info for a single light.""" light_id = request.match_info["light_id"] if light_id == "new": return await self.async_get_new_lights(request) entity = await self.config.async_entity_by_light_id(light_id) result = await self.__async_entity_to_hue(entity) return send_json_response(result) @routes.put("/api/{username}/lights/{light_id}/state") @check_request() async def async_put_light_state(self, request: web.Request, request_data: dict): """Handle requests to perform action on a group of lights/room.""" light_id = request.match_info["light_id"] username = request.match_info["username"] entity = await self.config.async_entity_by_light_id(light_id) await self.__async_light_action(entity, request_data) # Create success responses for all received keys return send_success_response(request.path, request_data, username) @routes.get("/api/{username}/groups") @check_request() async def async_get_groups(self, request: web.Request): """Handle requests to retrieve all rooms/groups.""" groups = await self.__async_get_all_groups() return send_json_response(groups) @routes.get("/api/{username}/groups/{group_id}") @check_request() async def async_get_group(self, request: web.Request): """Handle requests to retrieve info for a single group.""" group_id = request.match_info["group_id"] groups = await self.__async_get_all_groups() result = groups.get(group_id, {}) return send_json_response(result) @routes.put("/api/{username}/groups/{group_id}/action") @check_request() async def async_group_action(self, request: web.Request, request_data: dict): """Handle requests to perform action on a group of lights/room.""" group_id = request.match_info["group_id"] username = request.match_info["username"] # instead of directly getting groups should have a property # get groups instead so we can easily modify it group_conf = await self.config.async_get_storage_value( "groups", group_id) if group_id == "0" and "scene" in request_data: # scene request scene = await self.config.async_get_storage_value( "scenes", request_data["scene"], default={}) for light_id, light_state in scene["lightstates"].items(): entity = await self.config.async_entity_by_light_id(light_id) await self.__async_light_action(entity, light_state) else: # forward request to all group lights # may need refactor to make __async_get_group_lights not an # async generator to instead return a dict async for entity in self.__async_get_group_lights(group_id): await self.__async_light_action(entity, request_data) if group_conf and "stream" in group_conf: # Request streaming stop # Duplicate code here. Method instead? LOGGER.info( "Stop Entertainment mode for group %s - params: %s", group_id, request_data, ) if self.streaming_api: # stop service if needed self.streaming_api.stop() self.streaming_api = None # Create success responses for all received keys return send_success_response(request.path, request_data, username) @routes.post("/api/{username}/groups") @check_request() async def async_create_group(self, request: web.Request, request_data: dict): """Handle requests to create a new group.""" if "class" not in request_data: request_data["class"] = "Other" if "name" not in request_data: request_data["name"] = "" item_id = await self.__async_create_local_item(request_data, "groups") return send_json_response([{"success": {"id": item_id}}]) @routes.put("/api/{username}/groups/{group_id}") @check_request() async def async_update_group(self, request: web.Request, request_data: dict): """Handle requests to update a group.""" group_id = request.match_info["group_id"] username = request.match_info["username"] group_conf = await self.config.async_get_storage_value( "groups", group_id) if not group_conf: return send_error_response(request.path, "no group config", 404) update_dict(group_conf, request_data) # Hue entertainment support (experimental) if "stream" in group_conf: if group_conf["stream"].get("active"): # Requested streaming start LOGGER.debug( "Start Entertainment mode for group %s - params: %s", group_id, request_data, ) del group_conf["stream"]["active"] if not self.streaming_api: user_data = await self.config.async_get_user(username) self.streaming_api = EntertainmentAPI( self.hue, group_conf, user_data) group_conf["stream"]["owner"] = username if not group_conf["stream"].get("proxymode"): group_conf["stream"]["proxymode"] = "auto" if not group_conf["stream"].get("proxynode"): group_conf["stream"]["proxynode"] = "/bridge" else: # Request streaming stop LOGGER.info( "Stop Entertainment mode for group %s - params: %s", group_id, request_data, ) if self.streaming_api: # stop service if needed self.streaming_api.stop() self.streaming_api = None await self.config.async_set_storage_value("groups", group_id, group_conf) return send_success_response(request.path, request_data, username) @routes.put("/api/{username}/lights/{light_id}") @check_request() async def async_update_light(self, request: web.Request, request_data: dict): """Handle requests to update a light.""" light_id = request.match_info["light_id"] username = request.match_info["username"] light_conf = await self.config.async_get_storage_value( "lights", light_id) if not light_conf: return send_error_response(request.path, "no light config", 404) update_dict(light_conf, request_data) return send_success_response(request.path, request_data, username) @routes.get("/api/{username}/{itemtype:(?:scenes|rules|resourcelinks)}") @check_request() async def async_get_localitems(self, request: web.Request): """Handle requests to retrieve localitems (e.g. scenes).""" itemtype = request.match_info["itemtype"] result = await self.config.async_get_storage_value(itemtype, default={}) return send_json_response(result) @routes.get( "/api/{username}/{itemtype:(?:scenes|rules|resourcelinks)}/{item_id}") @check_request() async def async_get_localitem(self, request: web.Request): """Handle requests to retrieve info for a single localitem.""" item_id = request.match_info["item_id"] itemtype = request.match_info["itemtype"] items = await self.config.async_get_storage_value(itemtype) result = items.get(item_id, {}) return send_json_response(result) @routes.post("/api/{username}/{itemtype:(?:scenes|rules|resourcelinks)}") @check_request() async def async_create_localitem(self, request: web.Request, request_data: dict): """Handle requests to create a new localitem.""" itemtype = request.match_info["itemtype"] item_id = await self.__async_create_local_item(request_data, itemtype) return send_json_response([{"success": {"id": item_id}}]) @routes.put( "/api/{username}/{itemtype:(?:scenes|rules|resourcelinks)}/{item_id}") @check_request() async def async_update_localitem(self, request: web.Request, request_data: dict): """Handle requests to update an item in localstorage.""" item_id = request.match_info["item_id"] itemtype = request.match_info["itemtype"] username = request.match_info["username"] local_item = await self.config.async_get_storage_value( itemtype, item_id) if not local_item: return send_error_response(request.path, "no localitem", 404) update_dict(local_item, request_data) await self.config.async_set_storage_value(itemtype, item_id, local_item) return send_success_response(request.path, request_data, username) @routes.delete( "/api/{username}/{itemtype:(?:scenes|rules|resourcelinks|groups|lights)}/{item_id}" ) @check_request() async def async_delete_localitem(self, request: web.Request): """Handle requests to delete a item from localstorage.""" item_id = request.match_info["item_id"] itemtype = request.match_info["itemtype"] await self.config.async_delete_storage_value(itemtype, item_id) result = [{"success": f"/{itemtype}/{item_id} deleted."}] return send_json_response(result) @check_request(check_user=False) async def async_get_bridge_config(self, request: web.Request): """Process a request to get (full or partial) config of this emulated bridge.""" username = request.match_info.get("username") valid_user = True if not username or not await self.config.async_get_user(username): valid_user = False # discovery config requested, enable discovery request await self.config.async_enable_link_mode_discovery() result = await self.__async_get_bridge_config(full_details=valid_user) return send_json_response(result) @routes.put("/api/{username}/config") @check_request() async def async_change_config(self, request: web.Request, request_data: dict): """Process a request to change a config value.""" username = request.match_info["username"] # just log this request and return succes LOGGER.debug("Change config called with params: %s", request_data) for key, value in request_data.items(): if key == "linkbutton" and value: # prevent storing value in config if not self.config.link_mode_enabled: await self.config.async_enable_link_mode() else: await self.config.async_set_storage_value( "bridge_config", key, value) return send_success_response(request.path, request_data, username) async def async_scene_to_full_state(self) -> dict: """Return scene data, removing lightstates and adds group lights instead.""" groups = await self.__async_get_all_groups() scenes = await self.config.async_get_storage_value("scenes", default={}) scenes = copy.deepcopy(scenes) for scene_num, scene_data in scenes.items(): scenes_group = scene_data["group"] del scene_data["lightstates"] scene_data["lights"] = groups[scenes_group]["lights"] return scenes @routes.get("/api/{username}") @check_request() async def get_full_state(self, request: web.Request): """Return full state view of emulated hue.""" json_response = { "config": await self.__async_get_bridge_config(True), "schedules": await self.config.async_get_storage_value("schedules", default={}), "rules": await self.config.async_get_storage_value("rules", default={}), "scenes": await self.async_scene_to_full_state(), "resourcelinks": await self.config.async_get_storage_value("resourcelinks", default={}), "lights": await self.__async_get_all_lights(), "groups": await self.__async_get_all_groups(), "sensors": { "1": { "state": { "daylight": None, "lastupdated": "none" }, "config": { "on": True, "configured": False, "sunriseoffset": 30, "sunsetoffset": -30, }, "name": "Daylight", "type": "Daylight", "modelid": "PHDL00", "manufacturername": "Signify Netherlands B.V.", "swversion": "1.0", } }, } return send_json_response(json_response) @routes.get("/api/{username}/sensors") @check_request() async def async_get_sensors(self, request: web.Request): """Return sensors on the (virtual) bridge.""" # not supported yet but prevent errors return send_json_response({}) @routes.get("/api/{username}/sensors/new") @check_request() async def async_get_new_sensors(self, request: web.Request): """Return all new discovered sensors on the (virtual) bridge.""" # not supported yet but prevent errors return send_json_response({}) @routes.get("/description.xml") @check_request(False) async def async_get_description(self, request: web.Request): """Serve the service description file.""" resp_text = self._description_xml.format( self.config.ip_addr, self.config.http_port, f"{self.config.bridge_name} ({self.config.ip_addr})", self.config.bridge_serial, self.config.bridge_uid, ) return web.Response(text=resp_text, content_type="text/xml") @routes.get("/link/{token}") @check_request(False) async def async_link(self, request: web.Request): """Enable link mode on the bridge.""" token = request.match_info["token"] # token needs to match the discovery token if (not token or not self.config.link_mode_discovery_key or token != self.config.link_mode_discovery_key): return web.Response(body="Invalid token supplied!", status=302) html_template = """ <html> <body> <h2>Link mode is enabled for 5 minutes.</h2> </body> </html>""" await self.config.async_enable_link_mode() return web.Response(text=html_template, content_type="text/html") @routes.get("/api/{username}/capabilities") @check_request() async def async_get_capabilities(self, request: web.Request): """Return an overview of the capabilities.""" json_response = { "lights": { "available": 50 }, "sensors": { "available": 60, "clip": { "available": 60 }, "zll": { "available": 60 }, "zgp": { "available": 60 }, }, "groups": { "available": 60 }, "scenes": { "available": 100, "lightstates": { "available": 1500 } }, "rules": { "available": 100, "lightstates": { "available": 1500 } }, "schedules": { "available": 100 }, "resourcelinks": { "available": 100 }, "whitelists": { "available": 100 }, "timezones": { "value": self.config.definitions["timezones"] }, "streaming": { "available": 1, "total": 10, "channels": 10 }, } return send_json_response(json_response) @routes.get("/api/{username}/info/timezones") @check_request() async def async_get_timezones(self, request: web.Request): """Return all timezones.""" return send_json_response(self.config.definitions["timezones"]) async def async_unknown_request(self, request: web.Request): """Handle unknown requests (catch-all).""" if request.method in ["PUT", "POST"]: try: request_data = await request.json() except json.decoder.JSONDecodeError: request_data = await request.text() LOGGER.warning("Invalid/unknown request: %s --> %s", request, request_data) else: LOGGER.warning("Invalid/unknown request: %s", request) return send_error_response(request.path, "unknown request", 404) async def __async_light_action(self, entity: dict, request_data: dict) -> None: """Translate the Hue api request data to actions on a light entity.""" light_id = await self.config.async_entity_id_to_light_id( entity["entity_id"]) light_conf = await self.config.async_get_light_config(light_id) throttle_ms = light_conf.get("throttle", const.DEFAULT_THROTTLE_MS) # Construct what we need to send to the service data = {const.HASS_ATTR_ENTITY_ID: entity["entity_id"]} power_on = request_data.get(const.HASS_STATE_ON, True) # throttle command to light data_with_power = request_data.copy() data_with_power[const.HASS_STATE_ON] = power_on if not self.__update_allowed(entity, data_with_power, throttle_ms): return service = (const.HASS_SERVICE_TURN_ON if power_on else const.HASS_SERVICE_TURN_OFF) if power_on: # set the brightness, hue, saturation and color temp if const.HUE_ATTR_BRI in request_data: # Prevent 0 brightness from turning light off request_bri = request_data[const.HUE_ATTR_BRI] if request_bri < const.HASS_ATTR_BRI_MIN: request_bri = const.HASS_ATTR_BRI_MIN data[const.HASS_ATTR_BRIGHTNESS] = request_bri if const.HUE_ATTR_HUE in request_data or const.HUE_ATTR_SAT in request_data: hue = request_data.get(const.HUE_ATTR_HUE, 0) sat = request_data.get(const.HUE_ATTR_SAT, 0) # Convert hs values to hass hs values hue = int((hue / const.HUE_ATTR_HUE_MAX) * 360) sat = int((sat / const.HUE_ATTR_SAT_MAX) * 100) data[const.HASS_ATTR_HS_COLOR] = (hue, sat) if const.HUE_ATTR_CT in request_data: data[const.HASS_ATTR_COLOR_TEMP] = request_data[ const.HUE_ATTR_CT] if const.HUE_ATTR_XY in request_data: data[const.HASS_ATTR_XY_COLOR] = request_data[ const.HUE_ATTR_XY] if const.HUE_ATTR_EFFECT in request_data: data[const.HASS_ATTR_EFFECT] = request_data[ const.HUE_ATTR_EFFECT] if const.HUE_ATTR_ALERT in request_data: if request_data[const.HUE_ATTR_ALERT] == "select": data[const.HASS_ATTR_FLASH] = "short" elif request_data[const.HUE_ATTR_ALERT] == "lselect": data[const.HASS_ATTR_FLASH] = "long" if const.HUE_ATTR_TRANSITION in request_data: # Duration of the transition from the light to the new state # is given as a multiple of 100ms and defaults to 4 (400ms). if request_data[const.HUE_ATTR_TRANSITION] * 100 <= throttle_ms: transitiontime = throttle_ms / 1000 else: transitiontime = request_data[const.HUE_ATTR_TRANSITION] / 10 data[const.HASS_ATTR_TRANSITION] = transitiontime else: data[const.HASS_ATTR_TRANSITION] = (0.4 if throttle_ms <= 400 else throttle_ms / 1000) # execute service await self.hue.hass.call_service(const.HASS_DOMAIN_LIGHT, service, data) def __update_allowed(self, entity: dict, light_data: dict, throttle_ms: int) -> bool: """Minimalistic form of throttling, only allow updates to a light within a timespan.""" if not throttle_ms: return True prev_data = self._prev_data.get(entity["entity_id"], {}) # pass initial request to light if not prev_data: self._prev_data[entity["entity_id"]] = light_data.copy() return True # force to update if power state changed if (entity["state"] == const.HASS_STATE_ON) != light_data.get( const.HASS_STATE_ON, True): self._prev_data[entity["entity_id"]].update(light_data) return True # check if data changed # when not using udp no need to send same light command again if (prev_data.get(const.HUE_ATTR_BRI, 0) == light_data.get( const.HUE_ATTR_BRI, 0) and prev_data.get(const.HUE_ATTR_HUE, 0) == light_data.get( const.HUE_ATTR_HUE, 0) and prev_data.get(const.HUE_ATTR_SAT, 0) == light_data.get( const.HUE_ATTR_SAT, 0) and prev_data.get(const.HUE_ATTR_CT, 0) == light_data.get( const.HUE_ATTR_CT, 0) and prev_data.get(const.HUE_ATTR_XY, [0, 0]) == light_data.get( const.HUE_ATTR_XY, [0, 0]) and prev_data.get(const.HUE_ATTR_EFFECT, "none") == light_data.get(const.HUE_ATTR_EFFECT, "none") and prev_data.get(const.HUE_ATTR_ALERT, "none") == light_data.get(const.HUE_ATTR_ALERT, "none")): return False self._prev_data[entity["entity_id"]].update(light_data) # check throttle timestamp so light commands are only sent once every X milliseconds # this is to not overload a light implementation in Home Assistant prev_timestamp = self._timestamps.get(entity["entity_id"], 0) cur_timestamp = int(time.time() * 1000) time_diff = abs(cur_timestamp - prev_timestamp) if time_diff >= throttle_ms: # change allowed only if within throttle limit self._timestamps[entity["entity_id"]] = cur_timestamp return True return False async def __async_entity_to_hue(self, entity: dict, light_config: Optional[dict] = None ) -> dict: """Convert an entity to its Hue bridge JSON representation.""" entity_attr = entity_attributes_to_int(entity["attributes"]) entity_features = entity["attributes"].get( const.HASS_ATTR_SUPPORTED_FEATURES, 0) if not light_config: light_id = await self.config.async_entity_id_to_light_id( entity["entity_id"]) light_config = await self.config.async_get_light_config(light_id) retval = { "state": { const.HUE_ATTR_ON: entity["state"] == const.HASS_STATE_ON, "reachable": entity["state"] != const.HASS_STATE_UNAVAILABLE, "mode": "homeautomation", }, "name": light_config["name"] or entity["attributes"].get("friendly_name", ""), "uniqueid": light_config["uniqueid"], "swupdate": { "state": "noupdates", "lastinstall": datetime.datetime.utcnow().isoformat().split(".")[0], }, "config": light_config["config"], } # Determine correct Hue type from HA supported features if ((entity_features & const.HASS_SUPPORT_BRIGHTNESS) and (entity_features & const.HASS_SUPPORT_COLOR) and (entity_features & const.HASS_SUPPORT_COLOR_TEMP)): # Extended Color light (Zigbee Device ID: 0x0210) # Same as Color light, but which supports additional setting of color temperature retval.update( self.hue.config.definitions["lights"]["Extended color light"]) # get color temperature min/max values from HA attributes ct_min = entity_attr.get("min_mireds", 153) retval["capabilities"]["control"]["ct"]["min"] = ct_min ct_max = entity_attr.get("max_mireds", 500) retval["capabilities"]["control"]["ct"]["max"] = ct_max retval["state"].update({ const.HUE_ATTR_BRI: entity_attr.get(const.HASS_ATTR_BRIGHTNESS, 0), # TODO: remember last command to set colormode const.HUE_ATTR_COLORMODE: const.HUE_ATTR_XY, # TODO: add hue/sat const.HUE_ATTR_XY: entity_attr.get(const.HASS_ATTR_XY_COLOR, [0, 0]), const.HUE_ATTR_HUE: entity_attr.get(const.HASS_ATTR_HS_COLOR, [0, 0])[0], const.HUE_ATTR_SAT: entity_attr.get(const.HASS_ATTR_HS_COLOR, [0, 0])[1], const.HUE_ATTR_CT: entity_attr.get(const.HASS_ATTR_COLOR_TEMP, 0), const.HUE_ATTR_EFFECT: entity_attr.get(const.HASS_ATTR_EFFECT, "none"), const.HUE_ATTR_ALERT: "none", }) elif (entity_features & const.HASS_SUPPORT_BRIGHTNESS) and ( entity_features & const.HASS_SUPPORT_COLOR): # Color light (Zigbee Device ID: 0x0200) # Supports on/off, dimming and color control (hue/saturation, enhanced hue, color loop and XY) retval.update(self.hue.config.definitions["lights"]["Color light"]) retval["state"].update({ const.HUE_ATTR_BRI: entity_attr.get(const.HASS_ATTR_BRIGHTNESS, 0), const.HUE_ATTR_COLORMODE: "xy", # TODO: remember last command to set colormode const.HUE_ATTR_XY: entity_attr.get(const.HASS_ATTR_XY_COLOR, [0, 0]), const.HUE_ATTR_HUE: entity_attr.get(const.HASS_ATTR_HS_COLOR, [0, 0])[0], const.HUE_ATTR_SAT: entity_attr.get(const.HASS_ATTR_HS_COLOR, [0, 0])[1], const.HUE_ATTR_EFFECT: "none", }) elif (entity_features & const.HASS_SUPPORT_BRIGHTNESS) and ( entity_features & const.HASS_SUPPORT_COLOR_TEMP): # Color temperature light (Zigbee Device ID: 0x0220) # Supports groups, scenes, on/off, dimming, and setting of a color temperature retval.update(self.hue.config.definitions["lights"] ["Color temperature light"]) # get color temperature min/max values from HA attributes ct_min = entity_attr.get("min_mireds", 153) retval["capabilities"]["control"]["ct"]["min"] = ct_min ct_max = entity_attr.get("max_mireds", 500) retval["capabilities"]["control"]["ct"]["max"] = ct_max retval["state"].update({ const.HUE_ATTR_BRI: entity_attr.get(const.HASS_ATTR_BRIGHTNESS, 0), const.HUE_ATTR_COLORMODE: "ct", const.HUE_ATTR_CT: entity_attr.get(const.HASS_ATTR_COLOR_TEMP, 0), }) elif entity_features & const.HASS_SUPPORT_BRIGHTNESS: # Dimmable light (Zigbee Device ID: 0x0100) # Supports groups, scenes, on/off and dimming brightness = entity_attr.get(const.HASS_ATTR_BRIGHTNESS, 0) retval["type"] = "Dimmable light" retval.update( self.hue.config.definitions["lights"]["Dimmable light"]) retval["state"].update({const.HUE_ATTR_BRI: brightness}) else: # On/off light (Zigbee Device ID: 0x0000) # Supports groups, scenes, on/off control retval.update( self.hue.config.definitions["lights"]["On/off light"]) # Get device type, model etc. from the Hass device registry entity_attr = entity["attributes"] reg_entity = self.hue.hass.entity_registry.get(entity["entity_id"]) if reg_entity and reg_entity["device_id"] is not None: device = self.hue.hass.device_registry.get(reg_entity["device_id"]) if device: retval["manufacturername"] = device["manufacturer"] retval["modelid"] = device["model"] retval["productname"] = device["name"] if device["sw_version"]: retval["swversion"] = device["sw_version"] if device["identifiers"]: identifiers = device["identifiers"] if isinstance(identifiers, dict): # prefer real zigbee address if we have that # might come in handy later when we want to # send entertainment packets to the zigbee mesh for key, value in device["identifiers"]: if key == "zha": retval["uniqueid"] = value elif isinstance(identifiers, list): # simply grab the first available identifier for now # may inprove this in the future for identifier in identifiers: if isinstance(identifier, list): retval["uniqueid"] = identifier[-1] break elif isinstance(identifier, str): retval["uniqueid"] = identifier break return retval async def __async_get_all_lights(self) -> dict: """Create a dict of all lights.""" result = {} for entity in self.hue.hass.lights: entity_id = entity["entity_id"] light_id = await self.config.async_entity_id_to_light_id(entity_id) light_config = await self.config.async_get_light_config(light_id) if not light_config["enabled"]: continue result[light_id] = await self.__async_entity_to_hue( entity, light_config) return result async def __async_create_local_item(self, data: Any, itemtype: str = "scenes") -> str: """Create item in storage of given type (scenes etc.).""" local_items = await self.config.async_get_storage_value(itemtype, default={}) # get first available id for i in range(1, 1000): item_id = str(i) if item_id not in local_items: break if (itemtype == "groups" and data["type"] in ["LightGroup", "Room", "Zone"] and "class" not in data): data["class"] = "Other" await self.config.async_set_storage_value(itemtype, item_id, data) return item_id async def __async_get_all_groups(self) -> dict: """Create a dict of all groups.""" result = {} # local groups first groups = await self.config.async_get_storage_value("groups", default={}) for group_id, group_conf in groups.items(): # no area_id = not hass area if "area_id" not in group_conf: if "stream" in group_conf: group_conf = copy.deepcopy(group_conf) if self.streaming_api: group_conf["stream"]["active"] = True else: group_conf["stream"]["active"] = False result[group_id] = group_conf # Hass areas/rooms for area in self.hue.hass.area_registry.values(): area_id = area["area_id"] group_id = await self.config.async_area_id_to_group_id(area_id) group_conf = await self.config.async_get_group_config(group_id) if not group_conf["enabled"]: continue result[group_id] = group_conf.copy() result[group_id]["lights"] = [] result[group_id]["name"] = group_conf["name"] or area["name"] lights_on = 0 # get all entities for this device async for entity in self.__async_get_group_lights(group_id): entity = self.hue.hass.get_state(entity["entity_id"], attribute=None) light_id = await self.config.async_entity_id_to_light_id( entity["entity_id"]) result[group_id]["lights"].append(light_id) if entity["state"] == const.HASS_STATE_ON: lights_on += 1 if lights_on == 1: # set state of first light as group state entity_obj = await self.__async_entity_to_hue(entity) result[group_id]["action"] = entity_obj["state"] if lights_on > 0: result[group_id]["state"]["any_on"] = True if lights_on == len(result[group_id]["lights"]): result[group_id]["state"]["all_on"] = True # do not return empty areas/rooms if len(result[group_id]["lights"]) == 0: result.pop(group_id, None) return result async def __async_get_group_lights( self, group_id: str) -> AsyncGenerator[dict, None]: """Get all light entities for a group.""" if group_id == "0": all_lights = await self.__async_get_all_lights() group_conf = {} group_conf["lights"] = [] for light_id in all_lights: group_conf["lights"].append(light_id) else: group_conf = await self.config.async_get_storage_value( "groups", group_id) if not group_conf: raise RuntimeError("Invalid group id: %s" % group_id) # Hass group (area) if "area_id" in group_conf: for entity in self.hue.hass.entity_registry.values(): if entity["disabled_by"]: # do not include disabled devices continue if not entity["entity_id"].startswith("light."): # for now only include lights # TODO: include switches, sensors ? continue device = self.hue.hass.device_registry.get(entity["device_id"]) # first check if area is defined on entity itself if entity["area_id"] and entity["area_id"] != group_conf[ "area_id"]: # different area id defined on entity so skip this entity continue elif entity["area_id"] == group_conf["area_id"]: # our area_id is configured on the entity, use it pass elif device and device["area_id"] == group_conf["area_id"]: # our area_id is configured on the entity's device, use it pass else: continue # process the light entity light_id = await self.config.async_entity_id_to_light_id( entity["entity_id"]) light_conf = await self.config.async_get_light_config(light_id) if not light_conf["enabled"]: continue entity = self.hue.hass.get_state(entity["entity_id"], attribute=None) yield entity # Local group else: for light_id in group_conf["lights"]: entity = await self.config.async_entity_by_light_id(light_id) yield entity async def __async_whitelist_to_bridge_config(self) -> dict: whitelist = await self.config.async_get_storage_value("users", default={}) whitelist = copy.deepcopy(whitelist) for username, data in whitelist.items(): del data["username"] del data["clientkey"] return whitelist async def __async_get_bridge_config(self, full_details: bool = False) -> dict: """Return the (virtual) bridge configuration.""" result = self.hue.config.definitions.get("bridge").get("basic").copy() result.update({ "name": self.config.bridge_name, "mac": self.config.mac_addr, "bridgeid": self.config.bridge_id, }) if full_details: result.update( self.hue.config.definitions.get("bridge").get("full")) result.update({ "linkbutton": self.config.link_mode_enabled, "ipaddress": self.config.ip_addr, "gateway": self.config.ip_addr, "UTC": datetime.datetime.utcnow().isoformat().split(".")[0], "localtime": datetime.datetime.now().isoformat().split(".")[0], "timezone": self.config.get_storage_value("bridge_config", "timezone", tzlocal.get_localzone().zone), "whitelist": await self.__async_whitelist_to_bridge_config(), "zigbeechannel": self.config.get_storage_value("bridge_config", "zigbeechannel", 25), }) return result
class HueApi: """Support for a Hue API to control Home Assistant.""" runner = None def __init__(self, hue): """Initialize with Hue object.""" self.streaming_api = None self.config = hue.config self.hass = hue.hass self.hue = hue self.http_site = None self.https_site = None routes.add_class_routes(self) async def async_setup(self): """Async set-up of the webserver.""" app = web.Application() # Add route for discovery info app.router.add_get("/api/nouser/config", self.async_get_discovery_config) # add all routes defined with decorator app.add_routes(routes) # Add catch-all handler for unkown requests app.router.add_route("*", "/{tail:.*}", self.async_unknown_request) self.runner = web.AppRunner(app, access_log=None) await self.runner.setup() # Create and start the HTTP API on port 80 # Port MUST be 80 to maintain compatability with Hue apps self.http_site = web.TCPSite(self.runner, self.config.host_ip_addr, self.config.http_port) try: await self.http_site.start() LOGGER.info("Started HTTP webserver on port %s", self.config.http_port) except OSError as error: LOGGER.error( "Failed to create HTTP server at port %d: %s", self.config.http_port, error, ) # create self signed certificate for HTTPS API cert_file = self.config.get_path(".cert.pem") key_file = self.config.get_path(".cert_key.pem") if not os.path.isfile(cert_file) or not os.path.isfile(key_file): await async_generate_selfsigned_cert(cert_file, key_file, self.config) ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) ssl_context.load_cert_chain(cert_file, key_file) # Create and start the HTTPS API on port 443 # Port MUST be 443 to maintain compatability with Hue apps self.https_site = web.TCPSite( self.runner, self.config.host_ip_addr, self.config.https_port, ssl_context=ssl_context, ) try: await self.https_site.start() LOGGER.info("Started HTTPS webserver on port %s", self.config.https_port) except OSError as error: LOGGER.error( "Failed to create HTTPS server at port %d: %s", self.config.https_port, error, ) async def async_stop(self): """Stop the webserver.""" await self.http_site.stop() await self.https_site.stop() if self.streaming_api: self.streaming_api.stop() @routes.get("/api{tail:/?}") @check_request async def async_get_auth(self, request: web.Request): """Handle requests to find the emulated hue bridge.""" return web.json_response(const.HUE_UNAUTHORIZED_USER) @routes.post("/api{tail:/?}") @check_request async def async_post_auth(self, request: web.Request, request_data: dict): """Handle requests to create a username for the emulated hue bridge.""" if "devicetype" not in request_data: LOGGER.warning("devicetype not specified") return web.json_response(("Devicetype not specified", 302)) if not self.config.link_mode_enabled: LOGGER.warning("Link mode is not enabled!") await self.config.async_enable_link_mode_discovery() return web.json_response(("Link mode is not enabled!", 302)) userdetails = await self.config.async_create_user( request_data["devicetype"]) response = [{"success": {"username": userdetails["username"]}}] if request_data.get("generateclientkey"): response[0]["success"]["clientkey"] = userdetails["clientkey"] LOGGER.info("Client %s registered", userdetails["name"]) return web.json_response(response) @routes.get("/api/{username}/lights") @check_request async def async_get_lights(self, request: web.Request): """Handle requests to retrieve the info all lights.""" return web.json_response(await self.__async_get_all_lights()) @routes.get("/api/{username}/lights/new") @check_request async def async_get_new_lights(self, request: web.Request): """Handle requests to retrieve new added lights to the (virtual) bridge.""" return web.json_response({}) @routes.get("/api/{username}/lights/{light_id}") @check_request async def async_get_light(self, request: web.Request): """Handle requests to retrieve the info for a single light.""" light_id = request.match_info["light_id"] entity = await self.config.async_entity_by_light_id(light_id) result = await self.__async_entity_to_hue(entity) return web.json_response(result) @routes.put("/api/{username}/lights/{light_id}/state") @check_request async def async_put_light_state(self, request: web.Request, request_data: dict): """Handle requests to perform action on a group of lights/room.""" light_id = request.match_info["light_id"] username = request.match_info["username"] entity = await self.config.async_entity_by_light_id(light_id) await self.__async_light_action(entity, request_data) # Create success responses for all received keys response = await self.__async_create_hue_response( request.path, request_data, username) return web.json_response(response) @routes.get("/api/{username}/groups") @check_request async def async_get_groups(self, request: web.Request): """Handle requests to retrieve all rooms/groups.""" groups = await self.__async_get_all_groups() return web.json_response(groups) @routes.get("/api/{username}/groups/{group_id}") @check_request async def async_get_group(self, request: web.Request): """Handle requests to retrieve info for a single group.""" group_id = request.match_info["group_id"] groups = await self.__async_get_all_groups() result = groups.get(group_id, {}) return web.json_response(result) @routes.put("/api/{username}/groups/{group_id}/action") @check_request async def async_group_action(self, request: web.Request, request_data: dict): """Handle requests to perform action on a group of lights/room.""" group_id = request.match_info["group_id"] username = request.match_info["username"] # forward request to all group lights async for entity in self.__async_get_group_lights(group_id): await self.__async_light_action(entity, request_data) # Create success responses for all received keys response = await self.__async_create_hue_response( request.path, request_data, username) return web.json_response(response) @routes.post("/api/{username}/groups") @check_request async def async_create_group(self, request: web.Request, request_data: dict): """Handle requests to create a new group.""" item_id = await self.__async_create_local_item(request_data, "groups") return web.json_response([{"success": {"id": item_id}}]) @routes.put("/api/{username}/groups/{group_id}") @check_request async def async_update_group(self, request: web.Request, request_data: dict): """Handle requests to update a group.""" group_id = request.match_info["group_id"] username = request.match_info["username"] local_group = await self.config.async_get_storage_value( "groups", group_id) if not local_group: return web.Response(status=404) update_dict(local_group, request_data) # Hue entertainment support (experimental) if "stream" in local_group: if local_group["stream"].get("active"): # Requested streaming start LOGGER.debug( "Start Entertainment mode for group %s - params: %s", group_id, request_data, ) if not self.streaming_api: user_data = await self.config.async_get_user(username) self.streaming_api = EntertainmentAPI( self.hue, local_group, user_data) local_group["stream"]["owner"] = username if not local_group["stream"].get("proxymode"): local_group["stream"]["proxymode"] = "auto" if not local_group["stream"].get("proxynode"): local_group["stream"]["proxynode"] = "/bridge" else: # Request streaming stop LOGGER.info( "Stop Entertainment mode for group %s - params: %s", group_id, request_data, ) local_group["stream"] = {"active": False} if self.streaming_api: # stop service if needed self.streaming_api.stop() self.streaming_api = None await self.config.async_set_storage_value("groups", group_id, local_group) response = await self.__async_create_hue_response( request.path, request_data, username) return web.json_response(response) @routes.get("/api/{username}/{itemtype:(?:scenes|rules|resourcelinks)}") @check_request async def async_get_localitems(self, request: web.Request): """Handle requests to retrieve localitems (e.g. scenes).""" itemtype = request.match_info["itemtype"] result = await self.config.async_get_storage_value(itemtype) return web.json_response(result) @routes.get( "/api/{username}/{itemtype:(?:scenes|rules|resourcelinks)}/{item_id}") @check_request async def async_get_localitem(self, request: web.Request): """Handle requests to retrieve info for a single localitem.""" item_id = request.match_info["item_id"] itemtype = request.match_info["itemtype"] items = await self.config.async_get_storage_value(itemtype) result = items.get(item_id, {}) return web.json_response(result) @routes.post("/api/{username}/{itemtype:(?:scenes|rules|resourcelinks)}") @check_request async def async_create_localitem(self, request: web.Request, request_data: dict): """Handle requests to create a new localitem.""" itemtype = request.match_info["itemtype"] item_id = await self.__async_create_local_item(request_data, itemtype) return web.json_response([{"success": {"id": item_id}}]) @routes.put( "/api/{username}/{itemtype:(?:scenes|rules|resourcelinks)}/{item_id}") @check_request async def async_update_localitem(self, request: web.Request, request_data: dict): """Handle requests to update an item in localstorage.""" item_id = request.match_info["item_id"] itemtype = request.match_info["itemtype"] username = request.match_info["username"] local_item = await self.config.async_get_storage_value( itemtype, item_id) if not local_item: return web.Response(status=404) update_dict(local_item, request_data) await self.config.async_set_storage_value(itemtype, item_id, local_item) response = await self.__async_create_hue_response( request.path, request_data, username) return web.json_response(response) @routes.delete( "/api/{username}/{itemtype:(?:scenes|rules|resourcelinks|groups)}/{item_id}" ) @check_request async def async_delete_localitem(self, request: web.Request): """Handle requests to delete a item from localstorage.""" item_id = request.match_info["item_id"] itemtype = request.match_info["itemtype"] await self.config.async_delete_storage_value(itemtype, item_id) result = [{"success": f"/{itemtype}/{item_id} deleted."}] return web.json_response(result) @check_request async def async_get_discovery_config(self, request: web.Request): """Process a request to get the (basic) config of this emulated bridge.""" await self.config.async_enable_link_mode_discovery() result = await self.__async_get_bridge_config(False) return web.json_response(result) @routes.get("/api/{username}/config") @check_request async def async_get_config(self, request: web.Request): """Process a request to get the (full) config of this emulated bridge.""" result = await self.__async_get_bridge_config(True) return web.json_response(result) @routes.put("/api/{username}/config") @check_request async def async_change_config(self, request: web.Request, request_data: dict): """Process a request to change a config value.""" username = request.match_info["username"] # just log this request and return succes LOGGER.debug("Change config called with params: %s", request_data) response = await self.__async_create_hue_response( request.path, request_data, username) return web.json_response(response) @routes.get("/api/{username}{tail:/?}") @check_request async def get_full_state(self, request: web.Request): """Return full state view of emulated hue.""" json_response = { "config": await self.__async_get_bridge_config(False), "schedules": await self.config.async_get_storage_value("schedules"), "rules": await self.config.async_get_storage_value("rules"), "scenes": await self.config.async_get_storage_value("scenes"), "resourcelinks": await self.config.async_get_storage_value("resourcelinks"), "lights": await self.__async_get_all_lights(), "groups": await self.__async_get_all_groups(), "sensors": { "1": { "state": { "daylight": None, "lastupdated": "none" }, "config": { "on": True, "configured": False, "sunriseoffset": 30, "sunsetoffset": -30, }, "name": "Daylight", "type": "Daylight", "modelid": "PHDL00", "manufacturername": "Philips", "swversion": "1.0", } }, } return web.json_response(json_response) @routes.get("/api/{username}/sensors") @check_request async def async_get_sensors(self, request: web.Request): """Return sensors on the (virtual) bridge.""" # not supported yet but prevent errors return web.json_response({}) @routes.get("/api/{username}/sensors/new") @check_request async def async_get_new_sensors(self, request: web.Request): """Return all new discovered sensors on the (virtual) bridge.""" # not supported yet but prevent errors return web.json_response({}) @routes.get("/description.xml") @check_request async def async_get_description(self, request: web.Request): """Serve the service description file.""" xml_template = """ <?xml version="1.0" encoding="UTF-8" ?> <root xmlns="urn:schemas-upnp-org:device-1-0"> <specVersion> <major>1</major> <minor>0</minor> </specVersion> <URLBase>http://{0}:{1}/</URLBase> <device> <deviceType>urn:schemas-upnp-org:device:Basic:1</deviceType> <friendlyName>Home Assistant Bridge ({0})</friendlyName> <manufacturer>Royal Philips Electronics</manufacturer> <manufacturerURL>http://www.philips.com</manufacturerURL> <modelDescription>Philips hue Personal Wireless Lighting</modelDescription> <modelName>Philips hue bridge 2015</modelName> <modelNumber>BSB002</modelNumber> <modelURL>http://www.meethue.com</modelURL> <serialNumber>{0}</serialNumber> <UDN>uuid:{0}</UDN> </device> </root>""" resp_text = xml_template.format( self.config.host_ip_addr, self.config.http_port, self.config.bridge_id, self.config.bridge_uid, ) return web.Response(text=resp_text, content_type="text/xml") @routes.get("/link") @check_request async def async_link(self, request: web.Request): """Enable link mode on the bridge.""" token = request.rel_url.query.get("token") # token needs to match the discovery token if (not token or not self.config.link_mode_discovery_key or token != self.config.link_mode_discovery_key): return web.Response(body="Invalid token supplied!", status=302) html_template = """ <html> <body> <h2>Link mode is enabled for 30 seconds.</h2> </body> </html>""" await self.config.async_enable_link_mode() return web.Response(text=html_template, content_type="text/html") @routes.get("/api/{username}/capabilities") @check_request async def async_get_capabilities(self, request: web.Request): """Return an overview of the capabilities.""" json_response = { "lights": { "available": 50 }, "sensors": { "available": 60, "clip": { "available": 60 }, "zll": { "available": 60 }, "zgp": { "available": 60 }, }, "groups": { "available": 60 }, "scenes": { "available": 100, "lightstates": { "available": 1500 } }, "rules": { "available": 100, "lightstates": { "available": 1500 } }, "schedules": { "available": 100 }, "resourcelinks": { "available": 100 }, "whitelists": { "available": 100 }, "timezones": { "values": [] }, "streaming": { "available": 1, "total": 10, "channels": 10 }, } return web.json_response(json_response) async def async_unknown_request(self, request: web.Request): """Handle unknown requests (catch-all).""" if request.method in ["PUT", "POST"]: request_data = await request.json() LOGGER.warning("Invalid request: %s --> %s", request, request_data) else: LOGGER.warning("Invalid request: %s", request) return web.Response(status=404) async def __async_light_action(self, entity: dict, request_data: dict) -> None: """Translate the Hue api request data to actions on a light entity.""" # Construct what we need to send to the service data = {const.HASS_ATTR_ENTITY_ID: entity["entity_id"]} power_on = request_data.get(const.HASS_STATE_ON, True) service = (const.HASS_SERVICE_TURN_ON if power_on else const.HASS_SERVICE_TURN_OFF) if power_on: # set the brightness, hue, saturation and color temp if const.HUE_ATTR_BRI in request_data: data[const.HASS_ATTR_BRIGHTNESS] = request_data[ const.HUE_ATTR_BRI] if const.HUE_ATTR_HUE in request_data or const.HUE_ATTR_SAT in request_data: hue = request_data.get(const.HUE_ATTR_HUE, 0) sat = request_data.get(const.HUE_ATTR_SAT, 0) # Convert hs values to hass hs values hue = int((hue / const.HUE_ATTR_HUE_MAX) * 360) sat = int((sat / const.HUE_ATTR_SAT_MAX) * 100) data[const.HASS_ATTR_HS_COLOR] = (hue, sat) if const.HUE_ATTR_CT in request_data: data[const.HASS_ATTR_COLOR_TEMP] = request_data[ const.HUE_ATTR_CT] if const.HUE_ATTR_XY in request_data: data[const.HASS_ATTR_XY_COLOR] = request_data[ const.HUE_ATTR_XY] if const.HUE_ATTR_EFFECT in request_data: data[const.HASS_ATTR_EFFECT] = request_data[ const.HUE_ATTR_EFFECT] if const.HUE_ATTR_ALERT in request_data: if request_data[const.HUE_ATTR_ALERT] == "select": data[const.HASS_ATTR_FLASH] = "short" elif request_data[const.HUE_ATTR_ALERT] == "lselect": data[const.HASS_ATTR_FLASH] = "long" if const.HUE_ATTR_TRANSITION in request_data: # Duration of the transition from the light to the new state # is given as a multiple of 100ms and defaults to 4 (400ms). transitiontime = request_data[const.HUE_ATTR_TRANSITION] / 100 data[const.HASS_ATTR_TRANSITION] = transitiontime # execute service await self.hass.async_call_service(const.HASS_DOMAIN_LIGHT, service, data) async def __async_entity_to_hue(self, entity: dict) -> dict: """Convert an entity to its Hue bridge JSON representation.""" entity_features = entity["attributes"].get( const.HASS_ATTR_SUPPORTED_FEATURES, 0) unique_id = hashlib.md5(entity["entity_id"].encode()).hexdigest() unique_id = "00:{}:{}:{}:{}:{}:{}:{}-{}".format( unique_id[0:2], unique_id[2:4], unique_id[4:6], unique_id[6:8], unique_id[8:10], unique_id[10:12], unique_id[12:14], unique_id[14:16], ) retval = { "state": { const.HUE_ATTR_ON: entity["state"] == const.HASS_STATE_ON, "reachable": entity["state"] != const.HASS_STATE_UNAVAILABLE, "mode": "homeautomation", }, "name": entity["attributes"].get("friendly_name", ""), "uniqueid": unique_id, "manufacturername": "Home Assistant", "productname": "Emulated Hue", "modelid": entity["entity_id"], "swversion": "5.127.1.26581", } # get device type, model etc. from the Hass device registry entity_attr = entity["attributes"] reg_entity = self.hass.entity_registry.get(entity["entity_id"]) if reg_entity and reg_entity["device_id"] is not None: device = self.hass.device_registry.get(reg_entity["device_id"]) if device: retval["manufacturername"] = device["manufacturer"] retval["modelid"] = device["model"] retval["productname"] = device["name"] if device["sw_version"]: retval["swversion"] = device["sw_version"] if ((entity_features & const.HASS_SUPPORT_BRIGHTNESS) and (entity_features & const.HASS_SUPPORT_COLOR) and (entity_features & const.HASS_SUPPORT_COLOR_TEMP)): # Extended Color light (Zigbee Device ID: 0x0210) # Same as Color light, but which supports additional setting of color temperature retval["type"] = "Extended color light" retval["state"].update({ const.HUE_ATTR_BRI: entity_attr.get(const.HASS_ATTR_BRIGHTNESS, 0), # TODO: remember last command to set colormode const.HUE_ATTR_COLORMODE: const.HUE_ATTR_XY, const.HUE_ATTR_XY: entity_attr.get(const.HASS_ATTR_XY_COLOR, [0, 0]), const.HUE_ATTR_CT: entity_attr.get(const.HASS_ATTR_COLOR_TEMP, 0), const.HUE_ATTR_EFFECT: entity_attr.get(const.HASS_ATTR_EFFECT, "none"), }) elif (entity_features & const.HASS_SUPPORT_BRIGHTNESS) and ( entity_features & const.HASS_SUPPORT_COLOR): # Color light (Zigbee Device ID: 0x0200) # Supports on/off, dimming and color control (hue/saturation, enhanced hue, color loop and XY) retval["type"] = "Color light" retval["state"].update({ const.HUE_ATTR_BRI: entity_attr.get(const.HASS_ATTR_BRIGHTNESS, 0), const.HUE_ATTR_COLORMODE: "xy", # TODO: remember last command to set colormode const.HUE_ATTR_XY: entity_attr.get(const.HASS_ATTR_XY_COLOR, [0, 0]), const.HUE_ATTR_EFFECT: "none", }) elif (entity_features & const.HASS_SUPPORT_BRIGHTNESS) and ( entity_features & const.HASS_SUPPORT_COLOR_TEMP): # Color temperature light (Zigbee Device ID: 0x0220) # Supports groups, scenes, on/off, dimming, and setting of a color temperature retval["type"] = "Color temperature light" retval["state"].update({ const.HUE_ATTR_COLORMODE: "ct", const.HUE_ATTR_CT: entity_attr.get(const.HASS_ATTR_COLOR_TEMP, 0), }) elif entity_features & const.HASS_SUPPORT_BRIGHTNESS: # Dimmable light (Zigbee Device ID: 0x0100) # Supports groups, scenes, on/off and dimming brightness = entity_attr.get(const.HASS_ATTR_BRIGHTNESS, 0) retval["type"] = "Dimmable light" retval["state"].update({const.HUE_ATTR_BRI: brightness}) else: # On/off light (Zigbee Device ID: 0x0000) # Supports groups, scenes, on/off control retval["type"] = "On/off light" # append advanced model info adv_info = self.hue.config.definitions["lights"].get(retval["type"]) if adv_info: retval.update(adv_info) return retval async def __async_create_hue_response(self, request_path: str, request_data: dict, username: str) -> dict: """Create success responses for all received keys.""" request_path = request_path.replace(f"/api/{username}", "") json_response = [] for key, val in request_data.items(): obj_path = f"{request_path}/{key}" if "/groups" in obj_path: item = {"success": {"address": obj_path, "value": val}} else: item = {"success": {obj_path: val}} json_response.append(item) return json_response async def __async_get_all_lights(self) -> dict: """Create a dict of all lights.""" result = {} for entity in self.hass.lights: entity_id = entity["entity_id"] light_id = await self.config.async_entity_id_to_light_id(entity_id) result[light_id] = await self.__async_entity_to_hue(entity) return result async def __async_create_local_item(self, data: Any, itemtype: str = "scenes") -> str: """Create item in storage of given type (scenes etc.).""" local_items = await self.config.async_get_storage_value(itemtype) # get first available id for i in range(1, 1000): item_id = str(i) if item_id not in local_items: break await self.config.async_set_storage_value(itemtype, item_id, data) return item_id async def __async_get_all_groups(self) -> dict: """Create a dict of all groups.""" result = {} # local groups first local_groups = await self.config.async_get_storage_value("groups") result.update(local_groups) # Hass areas/rooms for area in self.hass.area_registry.values(): area_id = area["area_id"] group_id = await self.config.async_entity_id_to_light_id(area_id) group_conf = result[group_id] = { "class": "Other", "type": "Room", "name": area["name"], "lights": [], "sensors": [], "action": { "on": False }, "state": { "any_on": False, "all_on": False }, } lights_on = 0 # get all entities for this device async for entity in self.__async_get_group_lights(group_id): entity = self.hass.get_state(entity["entity_id"], attribute=None) light_id = await self.config.async_entity_id_to_light_id( entity["entity_id"]) group_conf["lights"].append(light_id) if entity["state"] == const.HASS_STATE_ON: lights_on += 1 if lights_on == 1: # set state of first light as group state entity_obj = await self.__async_entity_to_hue(entity) group_conf["action"] = entity_obj["state"] if lights_on > 0: group_conf["state"]["any_on"] = True if lights_on == len(group_conf["lights"]): group_conf["state"]["all_on"] = True return result async def __async_get_group_lights( self, group_id: str) -> AsyncGenerator[dict, None]: """Get all light entities for a group.""" # try local groups first local_groups = await self.config.async_get_storage_value("groups") if group_id in local_groups: local_group = local_groups[group_id] for light_id in local_group["lights"]: entity = await self.config.async_entity_by_light_id(light_id) yield entity # fall back to hass groups (areas) else: area_id = await self.config.async_light_id_to_entity_id(group_id) for device in self.hass.device_registry.values(): if device["area_id"] != area_id: continue # get all entities for this device for entity in self.hass.entity_registry.values(): if entity["device_id"] != device["id"] or entity[ "disabled_by"]: continue if not entity["entity_id"].startswith("light."): continue entity = self.hass.get_state(entity["entity_id"], attribute=None) yield entity async def __async_get_bridge_config(self, full_details: bool = False) -> dict: """Return the (virtual) bridge configuration.""" result = { "name": "Home Assistant", "datastoreversion": self.hue.config.definitions["bridge"]["datastoreversion"], "swversion": self.hue.config.definitions["bridge"]["swversion"], "apiversion": self.hue.config.definitions["bridge"]["apiversion"], "mac": self.config.mac_addr, "bridgeid": self.config.bridge_id, "factorynew": False, "replacesbridgeid": None, "modelid": "BSB002", "starterkitid": "", } if full_details: result.update({ "backup": { "errorcode": 0, "status": "idle" }, "datastoreversion": self.hue.config.definitions["bridge"]["datastoreversion"], "dhcp": True, "internetservices": { "internet": "connected", "remoteaccess": "connected", "swupdate": "connected", "time": "connected", }, "netmask": "255.255.255.0", "gateway": self.config.host_ip_addr, "proxyport": 0, "UTC": datetime.datetime.now().isoformat().split(".")[0], "timezone": "Europe/Amsterdam", "portalconnection": "connected", "portalservices": True, "portalstate": { "communication": "disconnected", "incoming": False, "outgoing": False, "signedon": True, }, "swupdate": { "checkforupdate": False, "devicetypes": { "bridge": False, "lights": [], "sensors": [] }, "notify": True, "text": "", "updatestate": 0, "url": "", }, "swupdate2": { "checkforupdate": False, "lastchange": "2018-06-09T10:11:08", "bridge": { "state": "noupdates", "lastinstall": "2018-06-08T19:09:45", }, "state": "noupdates", "autoinstall": { "updatetime": "T14:00:00", "on": False }, }, "whitelist": await self.config.async_get_storage_value("users"), "zigbeechannel": 25, "linkbutton": self.config.link_mode_enabled, }) return result
class HueApi: """Support for a Hue API to control Home Assistant.""" runner = None def __init__(self, hue): """Initialize with Hue object.""" self.streaming_api = None self.config = hue.config self.hass = hue.hass self.hue = hue self.http_site = None self.https_site = None self._new_lights = {} with open(DESCRIPTION_FILE, encoding="utf-8") as fdesc: self._description_xml = fdesc.read() with open(CLIP_FILE, encoding="utf-8") as fdesc: self._clip_html = fdesc.read() async def async_setup(self): """Async set-up of the webserver.""" app = web.Application() # add config routes app.router.add_route("GET", "/api/{username}/config", self.async_get_bridge_config) app.router.add_route("GET", "/api/config", self.async_get_bridge_config) app.router.add_route("GET", "/api/{username}/config/", self.async_get_bridge_config) app.router.add_route("GET", "/api/config/", self.async_get_bridge_config) # add all routes defined with decorator routes.add_class_routes(self) app.add_routes(routes) # Add catch-all handler for unknown requests app.router.add_route("*", "/{tail:.*}", self.async_unknown_request) self.runner = web.AppRunner(app, access_log=None) await self.runner.setup() # Create and start the HTTP API on port 80 # Port MUST be 80 to maintain compatability with Hue apps self.http_site = web.TCPSite(self.runner, port=self.config.http_port) try: await self.http_site.start() LOGGER.info("Started HTTP webserver on port %s", self.config.http_port) except OSError as error: LOGGER.error( "Failed to create HTTP server at port %d: %s", self.config.http_port, error, ) # create self signed certificate for HTTPS API cert_file = self.config.get_path(".cert.pem") key_file = self.config.get_path(".cert_key.pem") if not os.path.isfile(cert_file) or not os.path.isfile(key_file): await async_generate_selfsigned_cert(cert_file, key_file, self.config) ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) ssl_context.load_cert_chain(cert_file, key_file) # Create and start the HTTPS API on port 443 # Port MUST be 443 to maintain compatability with Hue apps self.https_site = web.TCPSite(self.runner, port=self.config.https_port, ssl_context=ssl_context) try: await self.https_site.start() LOGGER.info("Started HTTPS webserver on port %s", self.config.https_port) except OSError as error: LOGGER.error( "Failed to create HTTPS server at port %d: %s", self.config.https_port, error, ) async def async_stop(self): """Stop the webserver.""" await self.http_site.stop() await self.https_site.stop() if self.streaming_api: self.streaming_api.stop() @routes.post("/api") @check_request(False) async def async_post_auth(self, request: web.Request, request_data: dict): """Handle requests to create a username for the emulated hue bridge.""" if "devicetype" not in request_data: LOGGER.warning("devicetype not specified") return send_json_response(("Devicetype not specified", 302)) if not self.config.link_mode_enabled: await self.config.async_enable_link_mode_discovery() return send_error_response(request.path, "link button not pressed", 101) userdetails = await self.config.async_create_user( request_data["devicetype"]) response = [{"success": {"username": userdetails["username"]}}] if request_data.get("generateclientkey"): response[0]["success"]["clientkey"] = userdetails["clientkey"] LOGGER.info("Client %s registered", userdetails["name"]) return send_json_response(response) @routes.get("/api/{username}/lights") @check_request(log_request=False) async def async_get_lights(self, request: web.Request): """Handle requests to retrieve the info all lights.""" return send_json_response(await self.__async_get_all_lights()) @routes.get("/api/{username}/lights/new") @check_request(log_request=False) async def async_get_new_lights(self, request: web.Request): """Handle requests to retrieve new added lights to the (virtual) bridge.""" return send_json_response(self._new_lights) @routes.post("/api/{username}/lights") @check_request() async def async_search_new_lights(self, request: web.Request, request_data): """Handle requests to retrieve new added lights to the (virtual) bridge.""" username = request.match_info["username"] self._search_enabled = True LOGGER.info( "Search mode activated. Any deleted/disabled lights will be reactivated." ) def auto_disable(): self._new_lights = {} self.hue.loop.call_later(60, auto_disable) # enable all disabled lights and groups for entity in self.hass.lights: entity_id = entity["entity_id"] light_id = await self.config.async_entity_id_to_light_id(entity_id) light_config = await self.config.async_get_light_config(light_id) if not light_config["enabled"]: light_config["enabled"] = True await self.config.async_set_storage_value( "lights", light_id, light_config) # add to new_lights for the app to show a special badge self._new_lights[light_id] = await self.__async_entity_to_hue( entity, light_config) groups = await self.config.async_get_storage_value("groups", default={}) for group_id, group_conf in groups.items(): if "enabled" in group_conf and not group_conf["enabled"]: group_conf["enabled"] = True await self.config.async_set_storage_value( "groups", group_id, group_conf) response = await self.__async_create_hue_response( request.path, {}, username) return send_json_response(response) @routes.get("/api/{username}/lights/{light_id}") @check_request(log_request=False) async def async_get_light(self, request: web.Request): """Handle requests to retrieve the info for a single light.""" light_id = request.match_info["light_id"] if light_id == "new": return await self.async_get_new_lights(request) entity = await self.config.async_entity_by_light_id(light_id) result = await self.__async_entity_to_hue(entity) return send_json_response(result) @routes.put("/api/{username}/lights/{light_id}/state") @check_request() async def async_put_light_state(self, request: web.Request, request_data: dict): """Handle requests to perform action on a group of lights/room.""" light_id = request.match_info["light_id"] username = request.match_info["username"] entity = await self.config.async_entity_by_light_id(light_id) await self.__async_light_action(entity, request_data) # Create success responses for all received keys response = await self.__async_create_hue_response( request.path, request_data, username) return send_json_response(response) @routes.get("/api/{username}/groups") @check_request(log_request=False) async def async_get_groups(self, request: web.Request): """Handle requests to retrieve all rooms/groups.""" groups = await self.__async_get_all_groups() return send_json_response(groups) @routes.get("/api/{username}/groups/{group_id}") @check_request() async def async_get_group(self, request: web.Request): """Handle requests to retrieve info for a single group.""" group_id = request.match_info["group_id"] groups = await self.__async_get_all_groups() result = groups.get(group_id, {}) return send_json_response(result) @routes.put("/api/{username}/groups/{group_id}/action") @check_request() async def async_group_action(self, request: web.Request, request_data: dict): """Handle requests to perform action on a group of lights/room.""" group_id = request.match_info["group_id"] username = request.match_info["username"] if group_id == "0" and "scene" in request_data: # scene request scene = await self.config.async_get_storage_value( "scenes", request_data["scene"], default={}) for light_id, light_state in scene["lightstates"].items(): entity = await self.config.async_entity_by_light_id(light_id) await self.__async_light_action(entity, light_state) else: # forward request to all group lights async for entity in self.__async_get_group_lights(group_id): await self.__async_light_action(entity, request_data) # Create success responses for all received keys response = await self.__async_create_hue_response( request.path, request_data, username) return send_json_response(response) @routes.post("/api/{username}/groups") @check_request() async def async_create_group(self, request: web.Request, request_data: dict): """Handle requests to create a new group.""" item_id = await self.__async_create_local_item(request_data, "groups") return send_json_response([{"success": {"id": item_id}}]) @routes.put("/api/{username}/groups/{group_id}") @check_request() async def async_update_group(self, request: web.Request, request_data: dict): """Handle requests to update a group.""" group_id = request.match_info["group_id"] username = request.match_info["username"] group_conf = await self.config.async_get_storage_value( "groups", group_id) if not group_conf: return web.Response(status=404) update_dict(group_conf, request_data) # Hue entertainment support (experimental) if "stream" in group_conf: if group_conf["stream"].get("active"): # Requested streaming start LOGGER.debug( "Start Entertainment mode for group %s - params: %s", group_id, request_data, ) if not self.streaming_api: user_data = await self.config.async_get_user(username) self.streaming_api = EntertainmentAPI( self.hue, group_conf, user_data) group_conf["stream"]["owner"] = username if not group_conf["stream"].get("proxymode"): group_conf["stream"]["proxymode"] = "auto" if not group_conf["stream"].get("proxynode"): group_conf["stream"]["proxynode"] = "/bridge" else: # Request streaming stop LOGGER.info( "Stop Entertainment mode for group %s - params: %s", group_id, request_data, ) group_conf["stream"] = {"active": False} if self.streaming_api: # stop service if needed self.streaming_api.stop() self.streaming_api = None await self.config.async_set_storage_value("groups", group_id, group_conf) response = await self.__async_create_hue_response( request.path, request_data, username) return send_json_response(response) @routes.put("/api/{username}/lights/{light_id}") @check_request() async def async_update_light(self, request: web.Request, request_data: dict): """Handle requests to update a light.""" light_id = request.match_info["light_id"] username = request.match_info["username"] light_conf = await self.config.async_get_storage_value( "lights", light_id) if not light_conf: return web.Response(status=404) update_dict(light_conf, request_data) response = await self.__async_create_hue_response( request.path, request_data, username) return send_json_response(response) @routes.get("/api/{username}/{itemtype:(?:scenes|rules|resourcelinks)}") @check_request() async def async_get_localitems(self, request: web.Request): """Handle requests to retrieve localitems (e.g. scenes).""" itemtype = request.match_info["itemtype"] result = await self.config.async_get_storage_value(itemtype, default={}) return send_json_response(result) @routes.get( "/api/{username}/{itemtype:(?:scenes|rules|resourcelinks)}/{item_id}") @check_request() async def async_get_localitem(self, request: web.Request): """Handle requests to retrieve info for a single localitem.""" item_id = request.match_info["item_id"] itemtype = request.match_info["itemtype"] items = await self.config.async_get_storage_value(itemtype) result = items.get(item_id, {}) return send_json_response(result) @routes.post("/api/{username}/{itemtype:(?:scenes|rules|resourcelinks)}") @check_request() async def async_create_localitem(self, request: web.Request, request_data: dict): """Handle requests to create a new localitem.""" itemtype = request.match_info["itemtype"] item_id = await self.__async_create_local_item(request_data, itemtype) return send_json_response([{"success": {"id": item_id}}]) @routes.put( "/api/{username}/{itemtype:(?:scenes|rules|resourcelinks)}/{item_id}") @check_request() async def async_update_localitem(self, request: web.Request, request_data: dict): """Handle requests to update an item in localstorage.""" item_id = request.match_info["item_id"] itemtype = request.match_info["itemtype"] username = request.match_info["username"] local_item = await self.config.async_get_storage_value( itemtype, item_id) if not local_item: return web.Response(status=404) update_dict(local_item, request_data) await self.config.async_set_storage_value(itemtype, item_id, local_item) response = await self.__async_create_hue_response( request.path, request_data, username) return send_json_response(response) @routes.delete( "/api/{username}/{itemtype:(?:scenes|rules|resourcelinks|groups|lights)}/{item_id}" ) @check_request() async def async_delete_localitem(self, request: web.Request): """Handle requests to delete a item from localstorage.""" item_id = request.match_info["item_id"] itemtype = request.match_info["itemtype"] await self.config.async_delete_storage_value(itemtype, item_id) result = [{"success": f"/{itemtype}/{item_id} deleted."}] return send_json_response(result) @check_request(False) async def async_get_bridge_config(self, request: web.Request): """Process a request to get (full or partial) config of this emulated bridge.""" username = request.match_info.get("username") valid_user = True if not username or not await self.config.async_get_user(username): valid_user = False # discovery config requested, enable discovery request await self.config.async_enable_link_mode_discovery() result = await self.__async_get_bridge_config(full_details=valid_user) return send_json_response(result) @routes.put("/api/{username}/config") @check_request() async def async_change_config(self, request: web.Request, request_data: dict): """Process a request to change a config value.""" username = request.match_info["username"] # just log this request and return succes LOGGER.debug("Change config called with params: %s", request_data) for key, value in request_data.items(): await self.config.async_set_storage_value("bridge_config", key, value) response = await self.__async_create_hue_response( request.path, request_data, username) return send_json_response(response) @routes.get("/api/{username}") @check_request() async def get_full_state(self, request: web.Request): """Return full state view of emulated hue.""" json_response = { "config": await self.__async_get_bridge_config(True), "schedules": await self.config.async_get_storage_value("schedules", default={}), "rules": await self.config.async_get_storage_value("rules", default={}), "scenes": await self.config.async_get_storage_value("scenes", default={}), "resourcelinks": await self.config.async_get_storage_value("resourcelinks", default={}), "lights": await self.__async_get_all_lights(), "groups": await self.__async_get_all_groups(), "sensors": { "1": { "state": { "daylight": None, "lastupdated": "none" }, "config": { "on": True, "configured": False, "sunriseoffset": 30, "sunsetoffset": -30, }, "name": "Daylight", "type": "Daylight", "modelid": "PHDL00", "manufacturername": "Philips", "swversion": "1.0", } }, } return send_json_response(json_response) @routes.get("/api/{username}/sensors") @check_request(log_request=False) async def async_get_sensors(self, request: web.Request): """Return sensors on the (virtual) bridge.""" # not supported yet but prevent errors return send_json_response({}) @routes.get("/api/{username}/sensors/new") @check_request(log_request=False) async def async_get_new_sensors(self, request: web.Request): """Return all new discovered sensors on the (virtual) bridge.""" # not supported yet but prevent errors return send_json_response({}) @routes.get("/description.xml") @check_request(False) async def async_get_description(self, request: web.Request): """Serve the service description file.""" resp_text = self._description_xml.format( self.config.ip_addr, self.config.http_port, self.config.bridge_name, self.config.bridge_serial, self.config.bridge_uid, ) return web.Response(text=resp_text, content_type="text/xml") @routes.get("/link/{token}") @check_request(False) async def async_link(self, request: web.Request): """Enable link mode on the bridge.""" token = request.match_info["token"] # token needs to match the discovery token if (not token or not self.config.link_mode_discovery_key or token != self.config.link_mode_discovery_key): return web.Response(body="Invalid token supplied!", status=302) html_template = """ <html> <body> <h2>Link mode is enabled for 5 minutes.</h2> </body> </html>""" await self.config.async_enable_link_mode() return web.Response(text=html_template, content_type="text/html") @routes.get("/api/{username}/capabilities") @check_request() async def async_get_capabilities(self, request: web.Request): """Return an overview of the capabilities.""" json_response = { "lights": { "available": 50 }, "sensors": { "available": 60, "clip": { "available": 60 }, "zll": { "available": 60 }, "zgp": { "available": 60 }, }, "groups": { "available": 60 }, "scenes": { "available": 100, "lightstates": { "available": 1500 } }, "rules": { "available": 100, "lightstates": { "available": 1500 } }, "schedules": { "available": 100 }, "resourcelinks": { "available": 100 }, "whitelists": { "available": 100 }, "timezones": { "value": self.config.definitions["timezones"] }, "streaming": { "available": 1, "total": 10, "channels": 10 }, } return send_json_response(json_response) @routes.get("/api/{username}/info/timezones") @check_request() async def async_get_timezones(self, request: web.Request): """Return all timezones.""" return send_json_response(self.config.definitions["timezones"]) # Static Content Begin @routes.get("/clip.html") @check_request(False) async def async_get_clip_debugger(self, request: web.Request): """Serve the CLIP Debugger.""" return web.Response(text=self._clip_html, content_type="text/html") @routes.get("/robots.txt") @check_request(False) async def async_get_robots_txt(self, request: web.Request): """Serve robots.txt.""" return web.Response(text=const.ROBOTS_TXT, content_type="text/plain") async def async_unknown_request(self, request: web.Request): """Handle unknown requests (catch-all).""" if request.method in ["PUT", "POST"]: try: request_data = await request.json() except json.decoder.JSONDecodeError: request_data = await request.text() LOGGER.warning("Invalid/unknown request: %s --> %s", request, request_data) else: LOGGER.warning("Invalid/unknown request: %s", request) return web.Response(status=404) async def __async_light_action(self, entity: dict, request_data: dict) -> None: """Translate the Hue api request data to actions on a light entity.""" # Construct what we need to send to the service data = {const.HASS_ATTR_ENTITY_ID: entity["entity_id"]} power_on = request_data.get(const.HASS_STATE_ON, True) service = (const.HASS_SERVICE_TURN_ON if power_on else const.HASS_SERVICE_TURN_OFF) if power_on: # set the brightness, hue, saturation and color temp if const.HUE_ATTR_BRI in request_data: data[const.HASS_ATTR_BRIGHTNESS] = request_data[ const.HUE_ATTR_BRI] if const.HUE_ATTR_HUE in request_data or const.HUE_ATTR_SAT in request_data: hue = request_data.get(const.HUE_ATTR_HUE, 0) sat = request_data.get(const.HUE_ATTR_SAT, 0) # Convert hs values to hass hs values hue = int((hue / const.HUE_ATTR_HUE_MAX) * 360) sat = int((sat / const.HUE_ATTR_SAT_MAX) * 100) data[const.HASS_ATTR_HS_COLOR] = (hue, sat) if const.HUE_ATTR_CT in request_data: data[const.HASS_ATTR_COLOR_TEMP] = request_data[ const.HUE_ATTR_CT] if const.HUE_ATTR_XY in request_data: data[const.HASS_ATTR_XY_COLOR] = request_data[ const.HUE_ATTR_XY] if const.HUE_ATTR_EFFECT in request_data: data[const.HASS_ATTR_EFFECT] = request_data[ const.HUE_ATTR_EFFECT] if const.HUE_ATTR_ALERT in request_data: if request_data[const.HUE_ATTR_ALERT] == "select": data[const.HASS_ATTR_FLASH] = "short" elif request_data[const.HUE_ATTR_ALERT] == "lselect": data[const.HASS_ATTR_FLASH] = "long" if const.HUE_ATTR_TRANSITION in request_data: # Duration of the transition from the light to the new state # is given as a multiple of 100ms and defaults to 4 (400ms). transitiontime = request_data[const.HUE_ATTR_TRANSITION] / 100 data[const.HASS_ATTR_TRANSITION] = transitiontime else: data[const.HASS_ATTR_TRANSITION] = 0.4 # execute service await self.hass.async_call_service(const.HASS_DOMAIN_LIGHT, service, data) async def __async_entity_to_hue(self, entity: dict, light_config: Optional[dict] = None ) -> dict: """Convert an entity to its Hue bridge JSON representation.""" entity_features = entity["attributes"].get( const.HASS_ATTR_SUPPORTED_FEATURES, 0) if not light_config: light_id = await self.config.async_entity_id_to_light_id( entity["entity_id"]) light_config = await self.config.async_get_light_config(light_id) retval = { "state": { const.HUE_ATTR_ON: entity["state"] == const.HASS_STATE_ON, "reachable": entity["state"] != const.HASS_STATE_UNAVAILABLE, "mode": "homeautomation", }, "name": light_config["name"] or entity["attributes"].get("friendly_name", ""), "uniqueid": light_config["uniqueid"], "manufacturername": "Home Assistant", "productname": "Emulated Hue", "modelid": entity["entity_id"], "swversion": "5.127.1.26581", } # get device type, model etc. from the Hass device registry entity_attr = entity["attributes"] reg_entity = self.hass.entity_registry.get(entity["entity_id"]) if reg_entity and reg_entity["device_id"] is not None: device = self.hass.device_registry.get(reg_entity["device_id"]) if device: retval["manufacturername"] = device["manufacturer"] retval["modelid"] = device["model"] retval["productname"] = device["name"] if device["sw_version"]: retval["swversion"] = device["sw_version"] if ((entity_features & const.HASS_SUPPORT_BRIGHTNESS) and (entity_features & const.HASS_SUPPORT_COLOR) and (entity_features & const.HASS_SUPPORT_COLOR_TEMP)): # Extended Color light (Zigbee Device ID: 0x0210) # Same as Color light, but which supports additional setting of color temperature retval["type"] = "Extended color light" retval["state"].update({ const.HUE_ATTR_BRI: entity_attr.get(const.HASS_ATTR_BRIGHTNESS, 0), # TODO: remember last command to set colormode const.HUE_ATTR_COLORMODE: const.HUE_ATTR_XY, const.HUE_ATTR_XY: entity_attr.get(const.HASS_ATTR_XY_COLOR, [0, 0]), const.HUE_ATTR_CT: entity_attr.get(const.HASS_ATTR_COLOR_TEMP, 0), const.HUE_ATTR_EFFECT: entity_attr.get(const.HASS_ATTR_EFFECT, "none"), }) elif (entity_features & const.HASS_SUPPORT_BRIGHTNESS) and ( entity_features & const.HASS_SUPPORT_COLOR): # Color light (Zigbee Device ID: 0x0200) # Supports on/off, dimming and color control (hue/saturation, enhanced hue, color loop and XY) retval["type"] = "Color light" retval["state"].update({ const.HUE_ATTR_BRI: entity_attr.get(const.HASS_ATTR_BRIGHTNESS, 0), const.HUE_ATTR_COLORMODE: "xy", # TODO: remember last command to set colormode const.HUE_ATTR_XY: entity_attr.get(const.HASS_ATTR_XY_COLOR, [0, 0]), const.HUE_ATTR_EFFECT: "none", }) elif (entity_features & const.HASS_SUPPORT_BRIGHTNESS) and ( entity_features & const.HASS_SUPPORT_COLOR_TEMP): # Color temperature light (Zigbee Device ID: 0x0220) # Supports groups, scenes, on/off, dimming, and setting of a color temperature retval["type"] = "Color temperature light" retval["state"].update({ const.HUE_ATTR_COLORMODE: "ct", const.HUE_ATTR_CT: entity_attr.get(const.HASS_ATTR_COLOR_TEMP, 0), }) elif entity_features & const.HASS_SUPPORT_BRIGHTNESS: # Dimmable light (Zigbee Device ID: 0x0100) # Supports groups, scenes, on/off and dimming brightness = entity_attr.get(const.HASS_ATTR_BRIGHTNESS, 0) retval["type"] = "Dimmable light" retval["state"].update({const.HUE_ATTR_BRI: brightness}) else: # On/off light (Zigbee Device ID: 0x0000) # Supports groups, scenes, on/off control retval["type"] = "On/off light" # append advanced model info adv_info = self.hue.config.definitions["lights"].get(retval["type"]) if adv_info: retval.update(adv_info) return retval async def __async_create_hue_response(self, request_path: str, request_data: dict, username: str) -> dict: """Create success responses for all received keys.""" request_path = request_path.replace(f"/api/{username}", "") json_response = [] for key, val in request_data.items(): obj_path = f"{request_path}/{key}" if "/groups" in obj_path: item = {"success": {"address": obj_path, "value": val}} else: item = {"success": {obj_path: val}} json_response.append(item) return json_response async def __async_get_all_lights(self) -> dict: """Create a dict of all lights.""" result = {} for entity in self.hass.lights: entity_id = entity["entity_id"] light_id = await self.config.async_entity_id_to_light_id(entity_id) light_config = await self.config.async_get_light_config(light_id) if not light_config["enabled"]: continue result[light_id] = await self.__async_entity_to_hue( entity, light_config) return result async def __async_create_local_item(self, data: Any, itemtype: str = "scenes") -> str: """Create item in storage of given type (scenes etc.).""" local_items = await self.config.async_get_storage_value(itemtype, default={}) # get first available id for i in range(1, 1000): item_id = str(i) if item_id not in local_items: break await self.config.async_set_storage_value(itemtype, item_id, data) return item_id async def __async_get_all_groups(self) -> dict: """Create a dict of all groups.""" result = {} # local groups first groups = await self.config.async_get_storage_value("groups", default={}) for group_id, group_conf in groups.items(): if "area_id" not in group_conf: result[group_id] = group_conf # Hass areas/rooms for area in self.hass.area_registry.values(): area_id = area["area_id"] group_id = await self.config.async_area_id_to_group_id(area_id) group_conf = await self.config.async_get_group_config(group_id) if not group_conf["enabled"]: continue result[group_id] = group_conf.copy() result[group_id]["lights"] = [] result[group_id]["name"] = group_conf["name"] or area["name"] lights_on = 0 # get all entities for this device async for entity in self.__async_get_group_lights(group_id): entity = self.hass.get_state(entity["entity_id"], attribute=None) light_id = await self.config.async_entity_id_to_light_id( entity["entity_id"]) result[group_id]["lights"].append(light_id) if entity["state"] == const.HASS_STATE_ON: lights_on += 1 if lights_on == 1: # set state of first light as group state entity_obj = await self.__async_entity_to_hue(entity) result[group_id]["action"] = entity_obj["state"] if lights_on > 0: result[group_id]["state"]["any_on"] = True if lights_on == len(result[group_id]["lights"]): result[group_id]["state"]["all_on"] = True return result async def __async_get_group_lights( self, group_id: str) -> AsyncGenerator[dict, None]: """Get all light entities for a group.""" group_conf = await self.config.async_get_storage_value( "groups", group_id) if not group_conf: raise RuntimeError("Invalid group id: %s" % group_id) # Hass group (area) if "area_id" in group_conf: for device in self.hass.device_registry.values(): if device["area_id"] != group_conf["area_id"]: continue # get all entities for this device for entity in self.hass.entity_registry.values(): if entity["device_id"] != device["id"] or entity[ "disabled_by"]: continue if not entity["entity_id"].startswith("light."): continue light_id = await self.config.async_entity_id_to_light_id( entity["entity_id"]) light_conf = await self.config.async_get_light_config( light_id) if not light_conf["enabled"]: continue entity = self.hass.get_state(entity["entity_id"], attribute=None) yield entity # Local group else: for light_id in group_conf["lights"]: entity = await self.config.async_entity_by_light_id(light_id) yield entity async def __async_get_bridge_config(self, full_details: bool = False) -> dict: """Return the (virtual) bridge configuration.""" result = self.hue.config.definitions["bridge"].copy() result.update({ "name": self.config.bridge_name, "mac": self.config.mac_addr, "bridgeid": self.config.bridge_id, "linkbutton": self.config.link_mode_enabled, }) if full_details: result.update({ "backup": { "errorcode": 0, "status": "idle" }, "dhcp": True, "internetservices": { "internet": "connected", "remoteaccess": "connected", "swupdate": "connected", "time": "connected", }, "netmask": "255.255.255.0", "gateway": self.config.ip_addr, "proxyport": 0, "UTC": datetime.datetime.utcnow().isoformat().split(".")[0], "timezone": self.config.get_storage_value("bridge_config", "timezone", "Europe/Amsterdam"), "portalconnection": "connected", "portalservices": True, "portalstate": { "communication": "disconnected", "incoming": False, "outgoing": False, "signedon": True, }, "swupdate": { "checkforupdate": False, "devicetypes": { "bridge": False, "lights": [], "sensors": [] }, "notify": True, "text": "", "updatestate": 0, "url": "", }, "swupdate2": { "checkforupdate": False, "lastchange": "2018-06-09T10:11:08", "bridge": { "state": "noupdates", "lastinstall": "2018-06-08T19:09:45", }, "state": "noupdates", "autoinstall": { "updatetime": "T14:00:00", "on": False }, }, "whitelist": await self.config.async_get_storage_value("users", default={}), "zigbeechannel": self.config.get_storage_value("bridge_config", "zigbeechannel", 25), }) return result