diff --git a/emulated_hue/api.py b/emulated_hue/api.py new file mode 100644 index 0000000..c23db19 --- /dev/null +++ b/emulated_hue/api.py @@ -0,0 +1,34 @@ +import logging + +from aiohttp import web +from emulated_hue.controllers import Controller +from emulated_hue.utils import send_error_response + +LOGGER = logging.getLogger(__name__) + + +class HueApiEndpoints: + """Base class for Hue API endpoints.""" + + def __init__(self, ctl: Controller): + """Initialize the v1 api.""" + self.ctl = ctl + + async def async_unknown_request(self, request: web.Request): + """Handle unknown requests (catch-all).""" + request_data = await request.text() + if request_data: + LOGGER.warning("Invalid/unknown request: %s --> %s", request, request_data) + else: + LOGGER.warning("Invalid/unknown request: %s", request) + if request.method == "GET": + address = request.path.lstrip("/").split("/") + # Ensure a resource is requested + if len(address) > 2: + username = address[1] + if not await self.ctl.config_instance.async_get_user(username): + return send_error_response(request.path, "unauthorized user", 1) + return send_error_response( + request.path, "method, GET, not available for resource, {path}", 4 + ) + return send_error_response(request.path, "unknown request", 404) diff --git a/emulated_hue/apiv1.py b/emulated_hue/apiv1.py index ef5a42c..da3d077 100644 --- a/emulated_hue/apiv1.py +++ b/emulated_hue/apiv1.py @@ -12,7 +12,8 @@ import tzlocal from aiohttp import web -from emulated_hue import const, controllers +from emulated_hue import const, controllers, api +from emulated_hue.api import HueApiEndpoints, LOGGER from emulated_hue.controllers import Controller from emulated_hue.controllers.devices import async_get_device from emulated_hue.utils import ( @@ -37,7 +38,7 @@ DESCRIPTION_FILE = os.path.join(STATIC_DIR, "description.xml") -def check_request(check_user=True, log_request=True): +def authorize(check_user=True, log_request=True): """Run some common logic to log and validate all requests (used as a decorator).""" def func_wrapper(func): @@ -49,7 +50,7 @@ async def wrapped_func(cls: "HueApiV1Endpoints", request: web.Request): if check_user: username = request.match_info.get("username") if not username or not await cls.ctl.config_instance.async_get_user( - username + username ): path = request.path.replace(username, "") LOGGER.debug("[%s] Invalid username (api key)", request.remote) @@ -81,12 +82,12 @@ async def wrapped_func(cls: "HueApiV1Endpoints", request: web.Request): # pylint: enable=invalid-name -class HueApiV1Endpoints: +class HueApiV1Endpoints(api.HueApiEndpoints): """Hue API v1 endpoints.""" def __init__(self, ctl: Controller): """Initialize the v1 api.""" - self.ctl = ctl + super().__init__(ctl) self._new_lights = {} self._timestamps = {} self._prev_data = {} @@ -109,7 +110,7 @@ async def async_stop(self): pass @routes.post("/api") - @check_request(False) + @authorize(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: @@ -139,19 +140,19 @@ async def async_post_auth(self, request: web.Request, request_data: dict): return send_json_response(response) @routes.get("/api/{username}/lights") - @check_request() + @authorize() 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() + @authorize() 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() + @authorize() 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"] @@ -166,74 +167,74 @@ def auto_disable(): # enable all disabled lights and groups for entity_id in self.ctl.controller_hass.get_entities(): - light_id = await self.ctl.config_instance.async_entity_id_to_light_id( + light_id_v1 = await self.ctl.config_instance.async_entity_id_to_light_id_v1( entity_id ) light_config = await self.ctl.config_instance.async_get_light_config( - light_id + entity_id ) if not light_config["enabled"]: light_config["enabled"] = True await self.ctl.config_instance.async_set_storage_value( - "lights", light_id, light_config + "lights", light_id_v1, 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_id) + self._new_lights[light_id_v1] = await self.__async_entity_to_hue(entity_id) groups = await self.ctl.config_instance.async_get_storage_value( "groups", default={} ) - for group_id, group_conf in groups.items(): + for group_id_v1, group_conf in groups.items(): if "enabled" in group_conf and not group_conf["enabled"]: group_conf["enabled"] = True await self.ctl.config_instance.async_set_storage_value( - "groups", group_id, group_conf + "groups", group_id_v1, group_conf ) return send_success_response(request.path, {}, username) - @routes.get("/api/{username}/lights/{light_id}") - @check_request() + @routes.get("/api/{username}/lights/{light_id_v1}") + @authorize() 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": + light_id_v1 = request.match_info["light_id_v1"] + if light_id_v1 == "new": return await self.async_get_new_lights(request) - entity_id = await self.ctl.config_instance.async_entity_id_from_light_id( - light_id + entity_id = await self.ctl.config_instance.async_entity_id_from_light_id_v1( + light_id_v1 ) result = await self.__async_entity_to_hue(entity_id) return send_json_response(result) - @routes.put("/api/{username}/lights/{light_id}/state") - @check_request() + @routes.put("/api/{username}/lights/{light_id_v1}/state") + @authorize() 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"] + light_id_v1 = request.match_info["light_id_v1"] username = request.match_info["username"] - entity_id = await self.ctl.config_instance.async_entity_id_from_light_id( - light_id + entity_id = await self.ctl.config_instance.async_entity_id_from_light_id_v1( + light_id_v1 ) await self.__async_light_action(entity_id, 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() + @authorize() 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() + @routes.get("/api/{username}/groups/{group_id_v1}") + @authorize() async def async_get_group(self, request: web.Request): """Handle requests to retrieve info for a single group.""" - group_id: str = request.match_info["group_id"] + group_id_v1: str = request.match_info["group_id_v1"] result: dict | None = None - if group_id.isdigit(): + if group_id_v1.isdigit(): groups = await self.__async_get_all_groups() - result = groups.get(group_id) + result = groups.get(group_id_v1) # else: - # TODO: Return group 0 if group_id is not found + # TODO: Return group 0 if group_id_v1 is not found if result: return send_json_response(result) else: @@ -241,26 +242,26 @@ async def async_get_group(self, request: web.Request): request.path, "resource, {path}, not available", 3 ) - @routes.put("/api/{username}/groups/{group_id}/action") - @check_request() + @routes.put("/api/{username}/groups/{group_id_v1}/action") + @authorize() 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"] + group_id_v1 = request.match_info["group_id_v1"] 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.ctl.config_instance.async_get_storage_value( - "groups", group_id + "groups", group_id_v1 ) - if group_id == "0" and "scene" in request_data: + if group_id_v1 == "0" and "scene" in request_data: # scene request scene = await self.ctl.config_instance.async_get_storage_value( "scenes", request_data["scene"], default={} ) - for light_id, light_state in scene["lightstates"].items(): + for light_id_v1, light_state in scene["lightstates"].items(): entity_id = ( - await self.ctl.config_instance.async_entity_id_from_light_id( - light_id + await self.ctl.config_instance.async_entity_id_from_light_id_v1( + light_id_v1 ) ) await self.__async_light_action(entity_id, light_state) @@ -268,14 +269,14 @@ async def async_group_action(self, request: web.Request, request_data: dict): # 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_id in self.__async_get_group_lights(group_id): + async for entity_id in self.__async_get_group_lights(group_id_v1): await self.__async_light_action(entity_id, 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, + group_id_v1, request_data, ) self.ctl.config_instance.stop_entertainment() @@ -283,7 +284,7 @@ async def async_group_action(self, request: web.Request, request_data: dict): return send_success_response(request.path, request_data, username) @routes.post("/api/{username}/groups") - @check_request() + @authorize() 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: @@ -293,14 +294,14 @@ async def async_create_group(self, request: web.Request, request_data: dict): 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() + @routes.put("/api/{username}/groups/{group_id_v1}") + @authorize() 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"] + group_id_v1 = request.match_info["group_id_v1"] username = request.match_info["username"] group_conf = await self.ctl.config_instance.async_get_storage_value( - "groups", group_id + "groups", group_id_v1 ) if not group_conf: return send_error_response(request.path, "no group config", 404) @@ -312,7 +313,7 @@ async def async_update_group(self, request: web.Request, request_data: dict): # Requested streaming start LOGGER.debug( "Start Entertainment mode for group %s - params: %s", - group_id, + group_id_v1, request_data, ) del group_conf["stream"]["active"] @@ -328,36 +329,37 @@ async def async_update_group(self, request: web.Request, request_data: dict): # Request streaming stop LOGGER.info( "Stop Entertainment mode for group %s - params: %s", - group_id, + group_id_v1, request_data, ) self.ctl.config_instance.stop_entertainment() await self.ctl.config_instance.async_set_storage_value( - "groups", group_id, group_conf + "groups", group_id_v1, group_conf ) return send_success_response(request.path, request_data, username) - @routes.put("/api/{username}/lights/{light_id}") - @check_request() + @routes.put("/api/{username}/lights/{light_id_v1}") + @authorize() 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"] + light_id_v1 = request.match_info["light_id_v1"] + entity_id = await self.ctl.config_instance.async_entity_id_from_light_id_v1( + light_id_v1 + ) username = request.match_info["username"] light_conf = await self.ctl.config_instance.async_get_storage_value( - "lights", light_id + "lights", entity_id ) if not light_conf: return send_error_response(request.path, "no light config", 404) if "name" in request_data: - light_conf = await self.ctl.config_instance.async_get_light_config(light_id) - entity_id = light_conf["entity_id"] device = await async_get_device(self.ctl, entity_id) device.name = request_data["name"] return send_success_response(request.path, request_data, username) @routes.get("/api/{username}/{itemtype:(?:scenes|rules|resourcelinks)}") - @check_request() + @authorize() async def async_get_localitems(self, request: web.Request): """Handle requests to retrieve localitems (e.g. scenes).""" itemtype = request.match_info["itemtype"] @@ -367,7 +369,7 @@ async def async_get_localitems(self, request: web.Request): return send_json_response(result) @routes.get("/api/{username}/{itemtype:(?:scenes|rules|resourcelinks)}/{item_id}") - @check_request() + @authorize() 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"] @@ -377,7 +379,7 @@ async def async_get_localitem(self, request: web.Request): return send_json_response(result) @routes.post("/api/{username}/{itemtype:(?:scenes|rules|resourcelinks)}") - @check_request() + @authorize() async def async_create_localitem(self, request: web.Request, request_data: dict): """Handle requests to create a new localitem.""" itemtype = request.match_info["itemtype"] @@ -385,7 +387,7 @@ async def async_create_localitem(self, request: web.Request, request_data: dict) return send_json_response([{"success": {"id": item_id}}]) @routes.put("/api/{username}/{itemtype:(?:scenes|rules|resourcelinks)}/{item_id}") - @check_request() + @authorize() 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"] @@ -405,7 +407,7 @@ async def async_update_localitem(self, request: web.Request, request_data: dict) @routes.delete( "/api/{username}/{itemtype:(?:scenes|rules|resourcelinks|groups|lights)}/{item_id}" ) - @check_request() + @authorize() async def async_delete_localitem(self, request: web.Request): """Handle requests to delete a item from localstorage.""" item_id = request.match_info["item_id"] @@ -415,10 +417,10 @@ async def async_delete_localitem(self, request: web.Request): return send_json_response(result) @routes.get("/api/{username:[^/]+/{0,1}|}config{tail:.*}") - @check_request(check_user=False) + @authorize(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") + username = request.match_info.get("username").strip('/') valid_user = True if not username or not await self.ctl.config_instance.async_get_user(username): valid_user = False @@ -426,7 +428,7 @@ async def async_get_bridge_config(self, request: web.Request): return send_json_response(result) @routes.put("/api/{username}/config") - @check_request() + @authorize() 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"] @@ -453,11 +455,11 @@ async def async_scene_to_full_state(self) -> dict: scenes_group = scene_data["group"] # Remove lightstates only if existing scene_data.pop("lightstates", None) - scene_data["lights"] = await self.__async_get_group_id(scenes_group) + scene_data["lights"] = await self.__async_get_group_id_v1(scenes_group) return scenes @routes.get("/api/{username}") - @check_request() + @authorize() async def get_full_state(self, request: web.Request): """Return full state view of emulated hue.""" json_response = { @@ -495,21 +497,21 @@ async def get_full_state(self, request: web.Request): return send_json_response(json_response) @routes.get("/api/{username}/sensors") - @check_request() + @authorize() 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() + @authorize() 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) + @authorize(False) async def async_get_description(self, request: web.Request): """Serve the service description file.""" resp_text = self._description_xml.format( @@ -522,7 +524,7 @@ async def async_get_description(self, request: web.Request): return web.Response(text=resp_text, content_type="text/xml") @routes.get("/link/{token}") - @check_request(False) + @authorize(False) async def async_link(self, request: web.Request): """Enable link mode on the bridge.""" token = request.match_info["token"] @@ -549,54 +551,39 @@ async def async_link(self, request: web.Request): return web.Response(text=html_template, content_type="text/html") @routes.get("/api/{username}/capabilities") - @check_request() + @authorize() async def async_get_capabilities(self, request: web.Request): """Return an overview of the capabilities.""" json_response = { - "lights": {"available": 50}, + "lights": {"available": 50, "total": 63}, "sensors": { "available": 60, - "clip": {"available": 60}, - "zll": {"available": 60}, - "zgp": {"available": 60}, + "total": 250, + "clip": {"available": 60, "total": 250}, + "zll": {"available": 60, "total": 64}, + "zgp": {"available": 60, "total": 64}, }, - "groups": {"available": 60}, - "scenes": {"available": 100, "lightstates": {"available": 1500}}, - "rules": {"available": 100, "lightstates": {"available": 1500}}, - "schedules": {"available": 100}, - "resourcelinks": {"available": 100}, - "whitelists": {"available": 100}, + "groups": {"available": 60, "total": 64}, + "scenes": {"available": 100, "total": 200, "lightstates": {"available": 1500, "total": 12600}}, + "rules": { + "available": 100, + "total": 250, + "conditions": {"available": 1500, "total": 1500}, + "actions": {"available": 1500, "total": 1500}}, + "schedules": {"available": 100, "total": 100}, + "resourcelinks": {"available": 100, "total": 64}, + "streaming": {"available": 1, "total": 1, "channels": 10}, "timezones": {"value": self.ctl.config_instance.definitions["timezones"]}, - "streaming": {"available": 1, "total": 10, "channels": 10}, } return send_json_response(json_response) @routes.get("/api/{username}/info/timezones") - @check_request() + @authorize() async def async_get_timezones(self, request: web.Request): """Return all timezones.""" return send_json_response(self.ctl.config_instance.definitions["timezones"]) - async def async_unknown_request(self, request: web.Request): - """Handle unknown requests (catch-all).""" - request_data = await request.text() - if request_data: - LOGGER.warning("Invalid/unknown request: %s --> %s", request, request_data) - else: - LOGGER.warning("Invalid/unknown request: %s", request) - if request.method == "GET": - address = request.path.lstrip("/").split("/") - # Ensure a resource is requested - if len(address) > 2: - username = address[1] - if not await self.ctl.config_instance.async_get_user(username): - return send_error_response(request.path, "unauthorized user", 1) - return send_error_response( - request.path, "method, GET, not available for resource, {path}", 4 - ) - return send_error_response(request.path, "unknown request", 404) - async def __async_light_action(self, entity_id: str, request_data: dict) -> None: """Translate the Hue api request data to actions on a light entity.""" @@ -676,13 +663,11 @@ async def __async_entity_to_hue( "lastinstall": datetime.datetime.now().isoformat().split(".")[0], }, "config": { - "config": { - "archetype": "sultanbulb", - "direction": "omnidirectional", - "function": "mixed", - "startup": {"configured": True, "mode": "safety"}, - } - }, + "archetype": "sultanbulb", + "direction": "omnidirectional", + "function": "mixed", + "startup": {"configured": True, "mode": "safety"}, + } } current_state = {} @@ -784,7 +769,7 @@ async def __async_get_all_lights(self) -> dict: device = await async_get_device(self.ctl, entity_id) if not device.enabled: continue - result[device.light_id] = await self.__async_entity_to_hue(entity_id) + result[device.light_id_v1] = await self.__async_entity_to_hue(entity_id) return result async def __async_create_local_item( @@ -816,7 +801,7 @@ async def __async_get_all_groups(self) -> dict: groups = await self.ctl.config_instance.async_get_storage_value( "groups", default={} ) - for group_id, group_conf in groups.items(): + for group_id_v1, group_conf in groups.items(): # no area_id = not hass area if "area_id" not in group_conf: if "stream" in group_conf: @@ -825,63 +810,63 @@ async def __async_get_all_groups(self) -> dict: group_conf["stream"]["active"] = True else: group_conf["stream"]["active"] = False - result[group_id] = group_conf + result[group_id_v1] = group_conf # Hass areas/rooms areas = await self.ctl.controller_hass.async_get_area_entities() for area in areas.values(): area_id = area["area_id"] - group_id = await self.ctl.config_instance.async_area_id_to_group_id(area_id) - group_conf = await self.ctl.config_instance.async_get_group_config(group_id) + group_conf = await self.ctl.config_instance.async_get_group_config(area_id) + group_id_v1 = group_conf["group_id_v1"] 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"] + result[group_id_v1] = group_conf.copy() + result[group_id_v1]["lights"] = [] + result[group_id_v1]["name"] = group_conf["name"] or area["name"] lights_on = 0 # get all entities for this device for entity_id in area["entities"]: - light_id = await self.ctl.config_instance.async_entity_id_to_light_id( + light_id_v1 = await self.ctl.config_instance.async_entity_id_to_light_id_v1( entity_id ) - result[group_id]["lights"].append(light_id) + result[group_id_v1]["lights"].append(light_id_v1) device = await async_get_device(self.ctl, entity_id) if device.power_state: lights_on += 1 if lights_on == 1: # set state of first light as group state entity_obj = await self.__async_entity_to_hue(entity_id) - result[group_id]["action"] = entity_obj["state"] - result[group_id]["state"]["any_on"] = lights_on > 0 - result[group_id]["state"]["all_on"] = lights_on == len( - result[group_id]["lights"] + result[group_id_v1]["action"] = entity_obj["state"] + result[group_id_v1]["state"]["any_on"] = lights_on > 0 + result[group_id_v1]["state"]["all_on"] = lights_on == len( + result[group_id_v1]["lights"] ) # do not return empty areas/rooms - if len(result[group_id]["lights"]) == 0: - result.pop(group_id, None) + if len(result[group_id_v1]["lights"]) == 0: + result.pop(group_id_v1, None) return result - async def __async_get_group_id(self, group_id: str) -> dict: + async def __async_get_group_id_v1(self, group_id_v1: str) -> dict: """Get group data for a group.""" - if group_id == "0": + if group_id_v1 == "0": all_lights = await self.__async_get_all_lights() group_conf = {"lights": []} - for light_id in all_lights: - group_conf["lights"].append(light_id) + for light_id_v1 in all_lights: + group_conf["lights"].append(light_id_v1) else: group_conf = await self.ctl.config_instance.async_get_storage_value( - "groups", group_id + "groups", group_id_v1 ) if not group_conf: - raise RuntimeError("Invalid group id: %s" % group_id) + raise RuntimeError("Invalid group id: %s" % group_id_v1) return group_conf async def __async_get_group_lights( - self, group_id: str + self, group_id_v1: str ) -> AsyncGenerator[str, None]: """Get all light entities for a group.""" - group_conf = await self.__async_get_group_id(group_id) + group_conf = await self.__async_get_group_id_v1(group_id_v1) # Hass group (area) if group_area_id := group_conf.get("area_id"): @@ -894,10 +879,10 @@ async def __async_get_group_lights( # Local group else: - for light_id in group_conf["lights"]: + for light_id_v1 in group_conf["lights"]: entity_id = ( - await self.ctl.config_instance.async_entity_id_from_light_id( - light_id + await self.ctl.config_instance.async_entity_id_from_light_id_v1( + light_id_v1 ) ) yield entity_id diff --git a/emulated_hue/apiv2.py b/emulated_hue/apiv2.py new file mode 100644 index 0000000..9ec5c80 --- /dev/null +++ b/emulated_hue/apiv2.py @@ -0,0 +1,775 @@ +"""Support for a Hue API to control Home Assistant.""" +import functools +import logging +import uuid +import tzlocal +import asyncio +import json + +from aiohttp import web +from aiohttp_sse import sse_response +from emulated_hue import api, controllers, const +from emulated_hue.const import UUID_NAMESPACES +from emulated_hue.controllers import Controller +from emulated_hue.controllers.devices import async_get_device +from emulated_hue.utils import ( + ClassRouteTableDef, + send_error_response, send_json_response_v2, +) +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + pass +else: + HueEmulator = "HueEmulator" + +LOGGER = logging.getLogger(__name__) + + +def authorizev2(check_user=True, log_request=True): + """Run some common logic to log and validate all requests (used as a decorator).""" + + def func_wrapper(func): + @functools.wraps(func) + async def wrapped_func(cls: "HueApiV2Endpoints", request: web.Request): + if log_request: + LOGGER.debug("[%s] %s %s", request.remote, request.method, request.path) + # check username + + if check_user: + username = request.headers.get("hue-application-key") + if not username or not await cls.ctl.config_instance.async_get_user( + username + ): + path = request.path.replace(username, "") + LOGGER.debug("[%s] Invalid username (api key)", request.remote) + return send_error_response(path, "unauthorized user", 1) + + # check and unpack (json) body if needed + if request.can_read_body: + try: + request_data = await request.text() + # clean request_data for weird apps like f.lux + request_data = request_data.rstrip("\x00") + request_data = json.loads(request_data) + except ValueError: + LOGGER.warning("Invalid json in request: %s --> %s", request) + return send_error_response("", "body contains invalid json", 2) + return await func(cls, request, request_data) + return await func(cls, request) + + return wrapped_func + + return func_wrapper + + +# pylint: disable=invalid-name +routes = ClassRouteTableDef() + + +# pylint: enable=invalid-name + + +class HueApiV2Endpoints(api.HueApiEndpoints): + """Hue API v2 endpoints.""" + + def __init__(self, ctl: Controller): + """Initialize the v1 api.""" + super().__init__(ctl) + + @property + def route(self): + """Return routes for external access.""" + if not len(routes): + routes.add_manual_route("GET", "/clip/v2", self.async_unknown_request) + routes.add_manual_route("GET", "/eventstream/clip/v2", self.async_eventstream) + routes.add_class_routes(self) + # Add catch-all handler for unknown requests to api + routes.add_manual_route("*", "/clip/v2/{tail:.*}", self.async_unknown_request) + return routes + + async def async_stop(self): + """Stop the v2 api.""" + pass + + async def async_eventstream(self, request): + async with sse_response(request) as response: + app = request.app + queue = asyncio.Queue() + print("Someone joined.") + app["event_streams"].add(queue) + await response.send(f": hi\n\n") + try: + while not response.task.done(): + payload = await queue.get() + await response.send(payload) + queue.task_done() + finally: + app["channels"].remove(queue) + print("Someone left.") + + #while counter > 0: # ensure we stop at some point + # if len(HueObjects.eventstream) > 0: + # for index, messages in enumerate(HueObjects.eventstream): + # yield f"id: {int(time()) }:{index}\ndata: {json.dumps([messages], separators=(',', ':'))}\n\n" + # sleep(0.2) + # sleep(0.2) + # counter -= 1 + + return response + + @routes.get("/clip/v2/resource") + @authorizev2(check_user=True) + async def async_get_all_resources(self, request: web.Request): + data = [ + await self.__async_get_homekit(), + await self.__async_get_matter(), + await self.__async_get_bridge_home(), + ] + data.extend(await self.__async_get_grouped_light()) + data.extend(await self.__async_get_room()) + data.extend(await self.__async_get_device()) + data.extend(await self.__async_get_light()) + data.extend(await self.__async_get_zigbee_connectivity()) + data.extend(await self.__async_get_entertainment()) + # TODO sensors + data.append(await self.__async_get_bridge()) + data.append(await self.__async_get_zigbee_discovery()) + # TODO entertainment_configuration + # TODO behavior_scripts + # TODO smart scene + # TODO geofence client + data.append(await self.__async_get_geolocation()) + return send_json_response_v2(data) + + @routes.get("/clip/v2/resource/homekit") + @authorizev2(check_user=True) + async def async_get_homekit(self, request: web.Request): + return send_json_response_v2([await self.__async_get_homekit()]) + + @routes.get("/clip/v2/resource/matter") + @authorizev2(check_user=True) + async def async_get_matter(self, request: web.Request): + return send_json_response_v2([await self.__async_get_matter()]) + + @routes.get("/clip/v2/resource/bridge_home") + @authorizev2(check_user=True) + async def async_get_bridge_home(self, request: web.Request): + return send_json_response_v2([await self.__async_get_bridge_home()]) + + @routes.get("/clip/v2/resource/grouped_light") + @authorizev2(check_user=True) + async def async_get_grouped_light(self, request: web.Request): + return send_json_response_v2(await self.__async_get_grouped_light()) + + @routes.get("/clip/v2/resource/room") + @authorizev2(check_user=True) + async def async_get_room(self, request: web.Request): + return send_json_response_v2(await self.__async_get_room()) + + @routes.get("/clip/v2/resource/device") + @authorizev2(check_user=True) + async def async_get_device(self, request: web.Request): + return send_json_response_v2(await self.__async_get_device()) + + @routes.get("/clip/v2/resource/light") + @authorizev2(check_user=True) + async def async_get_light(self, request: web.Request): + return send_json_response_v2(await self.__async_get_light()) + + @routes.put("/clip/v2/resource/light/{id}") + @authorizev2(check_user=True) + async def async_put_light(self, request: web.Request, request_data: dict): + """Handle requests to perform action on a group of lights/room.""" + light_id = request.match_info["id"] + entity_id = await self.ctl.config_instance.async_entity_id_from_light_id( + light_id + ) + await self.__async_light_action(entity_id, request_data) + # Create success responses for all received keys + return send_json_response_v2([]) + + @routes.get("/clip/v2/resource/zigbee_connectivity") + @authorizev2(check_user=True) + async def async_get_zigbee_connectivity(self, request: web.Request): + return send_json_response_v2(await self.__async_get_zigbee_connectivity()) + + @routes.get("/clip/v2/resource/entertainment") + @authorizev2(check_user=True) + async def async_get_entertainment(self, request: web.Request): + return send_json_response_v2(await self.__async_get_entertainment()) + + @routes.get("/clip/v2/resource/bridge") + @authorizev2(check_user=True) + async def async_get_bridge(self, request: web.Request): + return send_json_response_v2([await self.__async_get_bridge()]) + + @routes.get("/clip/v2/resource/geolocation") + @authorizev2(check_user=True) + async def async_get_geolocation(self, request: web.Request): + return send_json_response_v2([await self.__async_get_geolocation()]) + + async def __async_get_homekit(self) -> dict: + bridge_id = self.ctl.config_instance.bridge_id + result = { + "id": str(uuid.uuid5(UUID_NAMESPACES["homekit"], bridge_id)), + "status": "unpaired", + "status_values": [ + "pairing", + "paired", + "unpaired" + ], + "type": "homekit" + } + return result + + async def __async_get_matter(self) -> dict: + bridge_id = self.ctl.config_instance.bridge_id + result = { + "has_qr_code": True, + "id": str(uuid.uuid5(UUID_NAMESPACES["matter"], bridge_id)), + "max_fabrics": 16, + "type": "matter" + } + return result + + async def __async_get_bridge_home(self) -> dict: + bridge_id = self.ctl.config_instance.bridge_id + result = { + "id": str(uuid.uuid5(UUID_NAMESPACES["bridge_home"], bridge_id)), + "id_v1": "/groups/0", + "children": [ + { + "rid": str(uuid.uuid5(UUID_NAMESPACES["device"], bridge_id)), + "rtype": "device" + } + ], + "services": [ + { + "rid": str(uuid.uuid5(UUID_NAMESPACES["grouped_light"], bridge_id)), + "rtype": "grouped_light" + } + ], + "type": "bridge_home" + } + + # TODO add sensors devices + + areas = await self.ctl.controller_hass.async_get_area_entities() + for area in areas.values(): + result['children'].append({ + "rid": str(uuid.uuid5(UUID_NAMESPACES["room"], area.get('area_id'))), + "rtype": "room" + }) + + return result + + async def __async_get_grouped_light(self) -> list: + bridge_id = self.ctl.config_instance.bridge_id + result = [{ + "id": str(uuid.uuid5(UUID_NAMESPACES["grouped_light"], bridge_id)), + "id_v1": "/groups/0", + "owner": { + "rid": str(uuid.uuid5(UUID_NAMESPACES["bridge_home"], bridge_id)), + "rtype": "bridge_home" + }, + "on": { # TODO + "on": True + }, + "dimming": { + "brightness": 100 + }, + "dimming_delta": {}, + "color_temperature": {}, + "color_temperature_delta": {}, + "color": {}, + "alert": { + "action_values": [ + "breathe" + ] + }, + "signaling": { + "signal_values": [ + "alternating", + "no_signal", + "on_off", + "on_off_color" + ] + }, + "dynamics": {}, + "type": "grouped_light" + }] + + groups = await self.ctl.config_instance.async_get_storage_value( + "groups", default={} + ) + for group_id_v1, group_conf in groups.items(): + result.append({ + "id": str(uuid.uuid5(UUID_NAMESPACES["grouped_light"], group_conf['area_id'])), + "id_v1": "/groups/" + group_id_v1, + "owner": { + "rid": str(uuid.uuid5(UUID_NAMESPACES["room"], group_conf['area_id'])), + "rtype": "room" + }, + "on": { + "on": group_conf['state']['any_on'] + }, + "dimming": { + "brightness": 0 + }, + "dimming_delta": {}, + "color_temperature": {}, + "color_temperature_delta": {}, + "color": {}, + "alert": { + "action_values": [ + "breathe" + ] + }, + "signaling": { + "signal_values": [ + "no_signal", + "on_off" + ] + }, + "dynamics": {}, + "type": "grouped_light" + }) + + return result + + async def __async_get_room(self) -> list: + """Create a list of all rooms.""" + result = [] + + areas = await self.ctl.controller_hass.async_get_area_entities() + for area in areas.values(): + area_id = area.get('area_id') + if area_id != "office": # TODO remove + continue + group_id_v1 = await self.ctl.config_instance.async_area_id_to_group_id_v1(area_id) + new_room = { + "id": str(uuid.uuid5(UUID_NAMESPACES["room"], area_id)), + "id_v1": "/groups/" + group_id_v1, + "children": [ + ], + "services": [ + { + "rid": str(uuid.uuid5(UUID_NAMESPACES["grouped_light"], area_id)), + "rtype": "grouped_light" + } + ], + "metadata": { + "name": area.get('name'), + "archetype": "bathroom" # TODO + }, + "type": "room" + } + + for light in area.get('entities'): + device = await async_get_device(self.ctl, light) + new_room['children'].append({ + "rid": device.hue_device_id, + "rtype": "device" + }) + result.append(new_room) + + return result + + async def __async_get_device(self) -> list: + """Create a list of all devices.""" + result = [ + await self.__async_bridge_to_device() + ] + for entity_id in self.ctl.controller_hass.get_entities(): + result.append(await self.__async_entity_to_device(entity_id)) + + return result + + async def __async_get_light(self) -> list: + """Create a list of all lights.""" + result = [] + for entity_id in self.ctl.controller_hass.get_entities(): + + if not entity_id.startswith("light.signify_"): # TODO remove + continue + + result.append(await self.__async_entity_to_light(entity_id)) + + return result + + async def __async_light_action(self, entity_id: str, request_data: dict) -> None: + """Translate the Hue api request data to actions on a light entity.""" + + device = await async_get_device(self.ctl, entity_id) + call = device.new_control_state() + + logging.info("set %s: %s", entity_id, request_data) + + if const.HUE_ATTR_ON in request_data: + call.set_power_state(request_data[const.HUE_ATTR_ON][const.HUE_ATTR_ON]) + + if color := request_data.get(const.HUE_ATTR_COLOR): + if xy := color.get(const.HUE_ATTR_XY): + call.set_xy(xy['x'], xy['y']) + + if const.HUE_ATTR_DIMMING in request_data and \ + (bri := request_data[const.HUE_ATTR_DIMMING].get(const.HUE_ATTR_BRIGHTNESS)): + call.set_brightness(max(2, bri * 2.55)) + + await call.async_execute() + + async def __async_get_zigbee_connectivity(self) -> list: + """Create a list of all zigbee connectivities.""" + result = [ + await self.__async_bridge_to_zigbee_connectivity() + ] + for entity_id in self.ctl.controller_hass.get_entities(): + result.append(await self.__async_entity_to_zigbee_connectivity(entity_id)) + + return result + + async def __async_get_entertainment(self) -> list: + """Create a list of all entertainments.""" + result = [ + await self.__async_bridge_to_entertainment() + ] + for entity_id in self.ctl.controller_hass.get_entities(): + result.append(await self.__async_entity_to_entertainment(entity_id)) + + return result + + async def __async_get_bridge(self) -> dict: + bridge_id = self.ctl.config_instance.bridge_id + time_zone = self.ctl.config_instance.get_storage_value( + "bridge_config", "timezone", tzlocal.get_localzone_name() + ) + result = { + "id": str(uuid.uuid5(UUID_NAMESPACES["bridge"], bridge_id)), + "owner": { + "rid": str(uuid.uuid5(UUID_NAMESPACES["device"], bridge_id)), + "rtype": "device" + }, + "bridge_id": bridge_id.lower(), + "time_zone": { + "time_zone": time_zone + }, + "type": "bridge" + } + return result + + async def __async_get_zigbee_discovery(self) -> dict: + bridge_id = self.ctl.config_instance.bridge_id + result = { + "id": str(uuid.uuid5(UUID_NAMESPACES["zigbee_device_discovery"], bridge_id)), + "owner": { + "rid": str(uuid.uuid5(UUID_NAMESPACES["device"], bridge_id)), + "rtype": "device" + }, + "status": "ready", + "type": "zigbee_device_discovery" + } + return result + + async def __async_get_geolocation(self) -> dict: + result = { + "id": "cd986381-09f7-4292-ae89-290a666889a8", + "type": "geolocation", + "is_configured": False, + "sun_today": { + "sunset_time": "21:12:00", + "day_type": "normal_day" + } + } + return result + + async def __async_bridge_to_device(self) -> dict: + bridge_id = self.ctl.config_instance.bridge_id + result = { + "id": str(uuid.uuid5(UUID_NAMESPACES["device"], bridge_id)), + "product_data": { + "model_id": "BSB002", + "manufacturer_name": "Signify Netherlands B.V.", + "product_name": self.ctl.config_instance.bridge_name, + "product_archetype": "bridge_v2", + "certified": True, + "software_version": "1.59.1959097030" + }, + "metadata": { + "name": self.ctl.config_instance.bridge_name, + "archetype": "bridge_v2" + }, + "identify": {}, + "services": [ + { + "rid": str(uuid.uuid5(UUID_NAMESPACES["bridge"], bridge_id)), + "rtype": "bridge" + }, + { + "rid": str(uuid.uuid5(UUID_NAMESPACES["zigbee_connectivity"], bridge_id)), + "rtype": "zigbee_connectivity" + }, + { + "rid": str(uuid.uuid5(UUID_NAMESPACES["entertainment"], bridge_id)), + "rtype": "entertainment" + }, + { + "rid": str(uuid.uuid5(UUID_NAMESPACES["zigbee_device_discovery"], bridge_id)), + "rtype": "zigbee_device_discovery" + } + ], + "type": "device" + } + return result + + async def __async_bridge_to_zigbee_connectivity(self) -> dict: + bridge_id = self.ctl.config_instance.bridge_id + result = { + "id": str(uuid.uuid5(UUID_NAMESPACES["zigbee_connectivity"], bridge_id)), + "owner": { + "rid": str(uuid.uuid5(UUID_NAMESPACES["device"], bridge_id)), + "rtype": "device" + }, + "status": "connected", + "mac_address": self.ctl.config_instance.mac_addr, + "channel": { + "status": "set", + "value": "channel_15" + }, + "type": "zigbee_connectivity" + } + return result + + async def __async_bridge_to_entertainment(self) -> dict: + bridge_id = self.ctl.config_instance.bridge_id + result = { + "id": str(uuid.uuid5(UUID_NAMESPACES["entertainment"], bridge_id)), + "owner": { + "rid": str(uuid.uuid5(UUID_NAMESPACES["device"], bridge_id)), + "rtype": "device" + }, + "renderer": False, + "proxy": True, + "equalizer": False, + "max_streams": 1, + "type": "entertainment" + } + return result + + async def __async_entity_to_device(self, entity_id: str) -> dict: + """Convert an entity to its Hue device JSON representation.""" + device = await async_get_device(self.ctl, entity_id) + + retval = { + "id": device.hue_id("device"), + "id_v1": "/lights/" + device.light_id_v1, + "product_data": { + "model_id": device.device_properties.model, + "manufacturer_name": device.device_properties.manufacturer, + "product_name": device.device_properties.model, # TODO map + "product_archetype": "hue_go", # TODO + "certified": True, + "software_version": device.device_properties.sw_version, # TODO format + "hardware_platform_type": "100b-108" # TODO + }, + "metadata": { + "name": device.name, + "archetype": "hue_go" # TODO + }, + "identify": {}, + "services": [ + { + "rid": device.hue_light_id, + "rtype": "light" + }, + { + "rid": device.hue_zigbee_connectivity_id, + "rtype": "zigbee_connectivity" + }, + { + "rid": device.hue_entertainment_id, + "rtype": "entertainment" + } + ], + "type": "device" + } + return retval + + async def __async_entity_to_light(self, entity_id: str) -> dict: + """Convert an entity to its Hue Light JSON representation.""" + device = await async_get_device(self.ctl, entity_id) + + retval = { + "id": device.hue_light_id, + "id_v1": "/lights/" + device.light_id_v1, + "owner": { + "rid": device.hue_device_id, + "rtype": "device" + }, + "metadata": { + "name": device.name, + "archetype": "pendant_round" # TODO + }, + "identify": {}, + "dynamics": { ## TODO + "status": "none", + "status_values": [ + "none", + "dynamic_palette" + ], + "speed": 0, + "speed_valid": False + }, + "alert": { # TODO + "action_values": [ + "breathe" + ] + }, + "signaling": { ## TODO + "signal_values": [ + "no_signal", + "on_off", + "on_off_color", + "alternating" + ] + }, + "mode": "normal", # TODO + "effects": { # TODO + "status_values": [ + "no_effect", + "candle" + ], + "status": "no_effect", + "effect_values": [ + "no_effect", + "candle", + "fire", + "prism" + ] + }, + "powerup": { # TODO + "preset": "safety", + "configured": True, + "on": { + "mode": "on", + "on": { + "on": True + } + }, + "dimming": { + "mode": "dimming", + "dimming": { + "brightness": 100 + } + }, + "color": { + "mode": "color_temperature", + "color_temperature": { + "mirek": 366 + } + } + }, + "type": "light" + } + if isinstance(device, controllers.devices.OnOffDevice): + retval.update({ + "on": { + "on": device.power_state + }, + }) + if isinstance(device, controllers.devices.BrightnessDevice): + retval.update({ + "dimming": { + "brightness": round(10000 * device.brightness / 255) / 100, + "min_dim_level": 2 # TODO + }, + "dimming_delta": {} + }) + if isinstance(device, controllers.devices.RGBDevice): + gamut_color = device.gamut_color + retval.update({ + "color": { + "xy": { + "x": round(device.xy_color[0], 4), + "y": round(device.xy_color[1], 4) + }, + "gamut": { + "red": { + "x": round(gamut_color[0][0], 4), + "y": round(gamut_color[0][1], 4) + }, + "green": { + "x": round(gamut_color[1][0], 4), + "y": round(gamut_color[1][1], 4) + }, + "blue": { + "x": round(gamut_color[2][0], 4), + "y": round(gamut_color[2][1], 4) + } + }, + "gamut_type": "C" # TODO + } + }) + if isinstance(device, controllers.devices.RGBWWDevice): + retval.update({ + "color_temperature": { + "mirek": device.color_temp, + "mirek_valid": True, # TODO + "mirek_schema": { + "mirek_minimum": device.min_mireds, + "mirek_maximum": device.max_mireds + } + }, + "color_temperature_delta": {}, + }) + return retval + + async def __async_entity_to_zigbee_connectivity(self, entity_id: str) -> dict: + """Convert an entity to its Hue Zigbee Connectivity JSON representation.""" + device = await async_get_device(self.ctl, entity_id) + status = "connected" if device.reachable else "connectivity_issue" + + retval = { + "id": device.hue_zigbee_connectivity_id, + "id_v1": "/lights/" + device.light_id_v1, + "owner": { + "rid": device.hue_device_id, + "rtype": "device" + }, + "status": status, + "mac_address": device.device_properties.mac_address, # TODO handle missing + "type": "zigbee_connectivity" + } + return retval + + async def __async_entity_to_entertainment(self, entity_id: str) -> dict: + """Convert an entity to its Hue Entertainment JSON representation.""" + device = await async_get_device(self.ctl, entity_id) + + retval = { + "id": device.hue_entertainment_id, + "id_v1": "/lights/" + device.light_id_v1, + "owner": { + "rid": device.hue_device_id, + "rtype": "device" + }, + "renderer": True, + "renderer_reference": { + "rid": device.hue_light_id, + "rtype": "light" + }, + "proxy": True, + "equalizer": True, + "segments": { + "configurable": False, + "max_segments": 1, + "segments": [ + { + "start": 0, + "length": 1 + } + ] + }, + "type": "entertainment" + } + return retval diff --git a/emulated_hue/const.py b/emulated_hue/const.py index ccfb8ae..8977292 100644 --- a/emulated_hue/const.py +++ b/emulated_hue/const.py @@ -2,6 +2,8 @@ # Prevent overloading home assistant / implementation # Will not be respected when using udp +import uuid + DEFAULT_THROTTLE_MS = 150 BRIGHTNESS_THROTTLE_THRESHOLD = 255 / 4 ENTERTAINMENT_UPDATE_STATE_UPDATE_RATE = 1000 @@ -58,7 +60,10 @@ # Hue API states HUE_ATTR_ON = "on" HUE_ATTR_BRI = "bri" +HUE_ATTR_BRIGHTNESS = "brightness" +HUE_ATTR_COLOR = "color" HUE_ATTR_COLORMODE = "colormode" +HUE_ATTR_DIMMING = "dimming" HUE_ATTR_HUE = "hue" HUE_ATTR_SAT = "sat" HUE_ATTR_CT = "ct" @@ -89,3 +94,16 @@ HASS_DOMAIN_PERSISTENT_NOTIFICATION = "persistent_notification" HASS_SERVICE_PERSISTENT_NOTIFICATION_CREATE = "create" HASS_SERVICE_PERSISTENT_NOTIFICATION_DISMISS = "dismiss" +UUID_NAMESPACES = { + "bridge": uuid.UUID('6ba7b820-9dad-11d1-80b4-00c04fd430c8'), + "bridge_home": uuid.UUID('6ba7b821-9dad-11d1-80b4-00c04fd430c8'), + "homekit": uuid.UUID('6ba7b822-9dad-11d1-80b4-00c04fd430c8'), + "matter": uuid.UUID('6ba7b823-9dad-11d1-80b4-00c04fd430c8'), + "zigbee_connectivity": uuid.UUID('6ba7b824-9dad-11d1-80b4-00c04fd430c8'), + "zigbee_device_discovery": uuid.UUID('6ba7b825-9dad-11d1-80b4-00c04fd430c8'), + "entertainment": uuid.UUID('6ba7b826-9dad-11d1-80b4-00c04fd430c8'), + "room": uuid.UUID('6ba7b827-9dad-11d1-80b4-00c04fd430c8'), + "grouped_light": uuid.UUID('6ba7b828-9dad-11d1-80b4-00c04fd430c8'), + "device": uuid.UUID('6ba7b830-9dad-11d1-80b4-00c04fd430c8'), + "light": uuid.UUID('6ba7b831-9dad-11d1-80b4-00c04fd430c8'), +} diff --git a/emulated_hue/controllers/config.py b/emulated_hue/controllers/config.py index ac854e8..f320d63 100644 --- a/emulated_hue/controllers/config.py +++ b/emulated_hue/controllers/config.py @@ -4,12 +4,13 @@ import hashlib import logging import os +import uuid + from pathlib import Path from typing import Any - from getmac import get_mac_address -from emulated_hue.const import CONFIG_WRITE_DELAY_SECONDS, DEFAULT_THROTTLE_MS +from emulated_hue.const import CONFIG_WRITE_DELAY_SECONDS, DEFAULT_THROTTLE_MS, UUID_NAMESPACES from emulated_hue.utils import ( async_save_json, create_secure_string, @@ -160,16 +161,24 @@ def get_path(self, filename: str) -> str: """Get path to file at data location.""" return os.path.join(self.data_path, filename) - async def async_entity_id_to_light_id(self, entity_id: str) -> str: - """Get a unique light_id number for the hass entity id.""" - lights = await self.async_get_storage_value("lights", default={}) - for key, value in lights.items(): - if entity_id == value["entity_id"]: - return key + async def async_entity_id_to_light_id_v1(self, entity_id: str) -> str: + """Get a unique light_id_v1 number for the hass entity id.""" + light_config = await self.async_get_light_config(entity_id) + return light_config["light_id_v1"] + + async def async_get_light_config(self, entity_id: str) -> dict: + """Return light config for given entity id.""" + conf = await self.async_get_storage_value("lights", entity_id) + if conf: + return conf + # light does not yet exist in config, create default config - next_light_id = "1" + device_id = self.ctl.controller_hass.get_device_id_from_entity_id(entity_id) + lights = await self.async_get_storage_value("lights") if lights: - next_light_id = str(max(int(k) for k in lights) + 1) + next_light_id_v1 = str(max(int(k["light_id_v1"]) for k in lights.values()) + 1) + else: + next_light_id_v1 = "1" # generate unique id (fake zigbee address) from entity id unique_id = hashlib.md5(entity_id.encode()).hexdigest() unique_id = "00:{}:{}:{}:{}:{}:{}:{}-{}".format( @@ -183,8 +192,9 @@ async def async_entity_id_to_light_id(self, entity_id: str) -> str: unique_id[14:16], ) # create default light config - light_config = { - "entity_id": entity_id, + conf = { + "light_id": str(uuid.uuid5(UUID_NAMESPACES["light"], device_id)), + "light_id_v1": next_light_id_v1, "enabled": True, "name": "", "uniqueid": unique_id, @@ -198,38 +208,40 @@ async def async_entity_id_to_light_id(self, entity_id: str) -> str: }, "throttle": DEFAULT_THROTTLE_MS, } - await self.async_set_storage_value("lights", next_light_id, light_config) - return next_light_id - - async def async_get_light_config(self, light_id: str) -> dict: - """Return light config for given light id.""" - conf = await self.async_get_storage_value("lights", light_id) - if not conf: - raise Exception(f"Light {light_id} not found!") + await self.async_set_storage_value("lights", entity_id, conf) return conf + async def async_entity_id_from_light_id_v1(self, light_id_v1: str) -> str: + """Return the hass entity by supplying a light id v1.""" + lights = await self.async_get_storage_value("lights", default={}) + for key, value in lights.items(): + if light_id_v1 == value["light_id_v1"]: + return key + raise Exception(f"Entity {light_id_v1} not found!") + async def async_entity_id_from_light_id(self, light_id: str) -> str: """Return the hass entity by supplying a light id.""" - light_config = await self.async_get_light_config(light_id) - if not light_config: - raise Exception("Invalid light_id provided!") - entity_id = light_config["entity_id"] - entities = self.ctl.controller_hass.get_entities() - if entity_id not in entities: - raise Exception(f"Entity {entity_id} not found!") - return entity_id - - async def async_area_id_to_group_id(self, area_id: str) -> str: - """Get a unique group_id number for the hass area_id.""" + lights = await self.async_get_storage_value("lights", default={}) + for key, value in lights.items(): + if light_id == value["light_id"]: + return key + raise Exception(f"Entity {light_id} not found!") + + async def async_area_id_to_group_id_v1(self, area_id: str) -> str: + """Get a unique group_id_v1 number for the hass area_id.""" groups = await self.async_get_storage_value("groups", default={}) for key, value in groups.items(): if area_id == value.get("area_id"): return key # group does not yet exist in config, create default config - next_group_id = "1" if groups: - next_group_id = str(max(int(k) for k in groups) + 1) + next_group_id_v1 = str(max(int(g["group_id_v1"]) for g in groups.values()) + 1) + else: + next_group_id_v1 = "1" + group_config = { + "group_id": str(uuid.uuid5(UUID_NAMESPACES["room"], area_id)), + "group_id_v1": next_group_id_v1, "area_id": area_id, "enabled": True, "name": "", @@ -240,20 +252,21 @@ async def async_area_id_to_group_id(self, area_id: str) -> str: "action": {"on": False}, "state": {"any_on": False, "all_on": False}, } - await self.async_set_storage_value("groups", next_group_id, group_config) - return next_group_id + await self.async_set_storage_value("groups", area_id, group_config) + return next_group_id_v1 - async def async_get_group_config(self, group_id: str) -> dict: + async def async_get_group_config(self, group_id_v1: str) -> dict: """Return group config for given group id.""" - conf = await self.async_get_storage_value("groups", group_id) + conf = await self.async_get_storage_value("groups", group_id_v1) if not conf: - raise Exception(f"Group {group_id} not found!") + raise Exception(f"Group {group_id_v1} not found!") return conf async def async_get_storage_value( self, key: str, subkey: str = None, default: Any | None = None ) -> Any: """Get a value from persistent storage.""" + LOGGER.debug("get value %s-%s", key, subkey) return self.get_storage_value(key, subkey, default) def get_storage_value( @@ -271,6 +284,7 @@ async def async_set_storage_value( self, key: str, subkey: str, value: str or dict ) -> None: """Set a value in persistent storage.""" + LOGGER.debug("set value %s-%s: %s", key, subkey, value) needs_save = False if subkey is None and self._config.get(key) != value: # main key changed diff --git a/emulated_hue/controllers/devices.py b/emulated_hue/controllers/devices.py index 4f18a70..d5e032c 100644 --- a/emulated_hue/controllers/devices.py +++ b/emulated_hue/controllers/devices.py @@ -1,6 +1,9 @@ """Collection of devices controllable by Hue.""" import asyncio import logging +import uuid +import rgbxy + from collections.abc import Callable from dataclasses import dataclass from datetime import datetime @@ -31,10 +34,13 @@ class DeviceProperties: """A device controllable by Hue.""" + device_id: str | None + parent_device_id: str | None manufacturer: str | None model: str | None name: str | None sw_version: str | None + mac_address: str | None unique_id: str | None @classmethod @@ -45,6 +51,17 @@ def from_hass(cls, ctl: Controller, entity_id: str) -> "DeviceProperties": if device_id: device_attributes = ctl.controller_hass.get_device_attributes(device_id) + mac_address: str | None = None + if connections := device_attributes.get("connections"): + if isinstance(connections, list): + for connection in connections: + if isinstance(connection, list): + mac_address = connection[-1] + break + elif isinstance(connection, str): + mac_address = connection + break + unique_id: str | None = None if identifiers := device_attributes.get("identifiers"): if isinstance(identifiers, dict): @@ -64,12 +81,16 @@ def from_hass(cls, ctl: Controller, entity_id: str) -> "DeviceProperties": elif isinstance(identifier, str): unique_id = identifier break + return cls( + device_id, + device_attributes.get("via_device_id"), device_attributes.get("manufacturer"), device_attributes.get("model"), device_attributes.get("name"), device_attributes.get("sw_version"), - unique_id, + mac_address, + unique_id ) @@ -79,14 +100,14 @@ class OnOffDevice: def __init__( self, ctl: Controller, - light_id: str, + light_id_v1: str, entity_id: str, config: dict, hass_state_dict: dict, ): """Initialize OnOffDevice.""" self.ctl: Controller = ctl - self._light_id: str = light_id + self._light_id_v1: str = light_id_v1 self._entity_id: str = entity_id self._device = DeviceProperties.from_hass( @@ -160,7 +181,7 @@ async def async_execute(self) -> None: async def _async_save_config(self) -> None: """Save config to file.""" await self.ctl.config_instance.async_set_storage_value( - "lights", self._light_id, self._config + "lights", self._entity_id, self._config ) def _save_config(self) -> None: @@ -238,6 +259,22 @@ def unique_id(self) -> str: """Return hue unique id.""" return self.device_properties.unique_id or self._unique_id + @property + def hue_device_id(self) -> str: + return self.hue_id("device") + + @property + def hue_light_id(self) -> str: + return self.hue_id("light") + + @property + def hue_zigbee_connectivity_id(self) -> str: + return self.hue_id("zigbee_connectivity") + + @property + def hue_entertainment_id(self) -> str: + return self.hue_id("entertainment") + @property def name(self) -> str: """Return device name, prioritizing local config.""" @@ -251,9 +288,9 @@ def name(self, value: str) -> None: self._save_config() @property - def light_id(self) -> str: + def light_id_v1(self) -> str: """Return light id.""" - return self._light_id + return self._light_id_v1 @property def entity_id(self) -> str: @@ -279,6 +316,9 @@ def new_control_state(self) -> OnOffControl: """Return new control state.""" return self.OnOffControl(self) + def hue_id(self, namespace: str) -> str: + return str(uuid.uuid5(const.UUID_NAMESPACES[namespace], self.device_properties.device_id)) + async def async_update_state(self) -> None: """Update EntityState object with Hass state.""" # prevent entertainment mode updates to avoid lag @@ -517,6 +557,15 @@ def effect(self) -> str | None: """Return effect.""" return self._config_state.effect + @property + def gamut_color(self) -> tuple[tuple[float, float], tuple[float, float], tuple[float, float]]: + """Return gamut_red_color.""" + converter = rgbxy.Converter(rgbxy.GamutC) # TODO GET GAMUT + red = converter.rgb_to_xy(self.rgb_color[0], 0, 0) if self.rgb_color[0] != 0 else (0, 0) + green = converter.rgb_to_xy(0, self.rgb_color[1], 0) if self.rgb_color[1] != 0 else (0, 0) + blue = converter.rgb_to_xy(0, 0, self.rgb_color[2]) if self.rgb_color[2] != 0 else (0, 0) + return (red, green, blue) + class RGBWWDevice(CTDevice, RGBDevice): """RGBWWDevice class.""" @@ -560,8 +609,8 @@ async def async_get_device( if entity_id in __device_cache: return __device_cache[entity_id][0] - light_id: str = await ctl.config_instance.async_entity_id_to_light_id(entity_id) - config: dict = await ctl.config_instance.async_get_light_config(light_id) + light_id_v1: str = await ctl.config_instance.async_entity_id_to_light_id_v1(entity_id) + config: dict = await ctl.config_instance.async_get_light_config(entity_id) hass_state_dict = ctl.controller_hass.get_entity_state(entity_id) entity_color_modes = hass_state_dict[const.HASS_ATTR].get( @@ -571,7 +620,7 @@ async def async_get_device( def new_device_obj(klass): return klass( ctl, - light_id, + light_id_v1, entity_id, config, hass_state_dict, diff --git a/emulated_hue/controllers/entertainment.py b/emulated_hue/controllers/entertainment.py index 179b21a..7fb8186 100644 --- a/emulated_hue/controllers/entertainment.py +++ b/emulated_hue/controllers/entertainment.py @@ -131,8 +131,8 @@ async def __process_packet(self, packet: bytes) -> None: async def __async_process_light_packet(self, light_data, color_space): """Process an incoming stream message.""" - light_id = str(light_data[1] + light_data[2]) - light_conf = await self.ctl.config_instance.async_get_light_config(light_id) + light_id_v1 = str(light_data[1] + light_data[2]) + light_conf = await self.ctl.config_instance.async_get_light_config(light_id_v1) # TODO: can we send udp messages to supported lights such as esphome or native ZHA ? # For now we simply unpack the entertainment packet and forward diff --git a/emulated_hue/controllers/homeassistant.py b/emulated_hue/controllers/homeassistant.py index eb86306..bf07ec8 100644 --- a/emulated_hue/controllers/homeassistant.py +++ b/emulated_hue/controllers/homeassistant.py @@ -113,7 +113,7 @@ def get_entities(self, domain: str = "light") -> list[str]: :return: A list of entity IDs. """ return [ - entity["entity_id"] for entity in self.items_by_domain(domain) if entity + entity["entity_id"] for entity in self.items_by_domain(domain) if entity and entity["entity_id"].startswith("light.signify_") # TODO remove and ] async def async_get_area_entities( diff --git a/emulated_hue/definitions.json b/emulated_hue/definitions.json index 0444b88..64bd99d 100644 --- a/emulated_hue/definitions.json +++ b/emulated_hue/definitions.json @@ -1,9 +1,9 @@ { "bridge": { "basic": { - "datastoreversion": "113", + "datastoreversion": "159", "swversion": "1959097030", - "apiversion": "1.48.0", + "apiversion": "1.59.0", "modelid": "BSB002", "factorynew": false, "replacesbridgeid": null, @@ -46,14 +46,14 @@ }, "swupdate2": { "checkforupdate": false, - "lastchange": "2020-12-02T05:46:23", + "lastchange": "2023-08-12T15:31:38", "bridge": { "state": "noupdates", - "lastinstall": "2020-12-02T05:41:17" + "lastinstall": "2023-07-25T02:12:43" }, "state": "noupdates", "autoinstall": { - "updatetime": "T06:00:00", + "updatetime": "T04:00:00", "on": true } } diff --git a/emulated_hue/utils.py b/emulated_hue/utils.py index 798af58..1afa9a4 100644 --- a/emulated_hue/utils.py +++ b/emulated_hue/utils.py @@ -104,8 +104,24 @@ def send_json_response(data) -> web.Response: ) +def send_json_response_v2(data: list) -> web.Response: + """Send json response v2.""" + return web.Response( + text=json.dumps({"errors": [], "data": data}, ensure_ascii=False, separators=(",", ":")), + content_type="application/json", + headers={ + "server": "nginx", + "X-XSS-Protection": "1; mode=block", + "X-Frame-Options": "SAMEORIGIN", + "X-Content-Type-Options": "nosniff", + "Content-Security-Policy": "default-src 'self'", + "Referrer-Policy": "no-referrer", + }, + ) + + # TODO: figure out correct response for: -# PUT: /api/username/lights/light_id +# PUT: /api/username/lights/light_id_v1 # {'config': {'startup': {'mode': 'safety'}}} def send_success_response( request_path: str, request_data: dict, username: str = None diff --git a/emulated_hue/web.py b/emulated_hue/web.py index 3b731b3..dee9691 100644 --- a/emulated_hue/web.py +++ b/emulated_hue/web.py @@ -4,9 +4,10 @@ import ssl from typing import TYPE_CHECKING -from aiohttp import web +from aiohttp import web, log from emulated_hue.apiv1 import HueApiV1Endpoints +from emulated_hue.apiv2 import HueApiV2Endpoints from emulated_hue.controllers import Controller from emulated_hue.ssl_cert import async_generate_selfsigned_cert, check_certificate @@ -29,14 +30,17 @@ def __init__(self, ctl: Controller): """Initialize with Hue object.""" self.ctl: Controller = ctl self.v1_api = HueApiV1Endpoints(ctl) + self.v2_api = HueApiV2Endpoints(ctl) self.http_site: web.TCPSite | None = None self.https_site: web.TCPSite | None = None async def async_setup(self): """Async set-up of the webserver.""" - app = web.Application() + app = web.Application(middlewares=[access_logging]) + app["event_streams"] = set() # add all routes defined with decorator app.add_routes(self.v1_api.route) + app.add_routes(self.v2_api.route) # static files hosting app.router.add_static("/", STATIC_DIR, append_version=True) self.runner = web.AppRunner(app, access_log=None) @@ -92,3 +96,9 @@ async def async_stop(self): await self.http_site.stop() await self.https_site.stop() await self.v1_api.async_stop() + + +@web.middleware +async def access_logging(request: web.Request, handler): + LOGGER.info("[%s] %s", request.method, request.path) + return await handler(request) diff --git a/requirements.txt b/requirements.txt index b15f5c8..aed0c5a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,11 +1,13 @@ aiohttp==3.8.5; sys_platform == 'win32' aiohttp[speedups]==3.8.4; sys_platform != 'win32' +aiohttp-sse==2.1.0 aiorun==2023.7.2 cryptography==41.0.2 getmac==0.9.4 netaddr==0.8.0 pydantic==2.0.3 python-slugify==8.0.1 +rgbxy==0.5 tzlocal==5.0.1 uvloop==0.17.0; sys_platform != 'win32' zeroconf==0.71.3