diff --git a/src/bridges/bedscale/bedscale_entity.py b/src/bridges/bedscale/bedscale_entity.py index 5665648..564a99f 100644 --- a/src/bridges/bedscale/bedscale_entity.py +++ b/src/bridges/bedscale/bedscale_entity.py @@ -19,11 +19,22 @@ class BedscaleWeightResult: class BedscaleEntity(Entity): - def __init__(self, *, ip_address: str, id: str, name: str, room: str): + def __init__( + self, + *, + ip_address: str, + id: str, + name: str, + room: str, + history_length: int = 60 * 5, + ): super().__init__(id=id, name=name, room=room, device_type="bedscale") self._ip_address = ip_address + self._history: list[BedscaleWeightResult] = [] + self._history_length = history_length + self.latest_weight: BedscaleWeightResult = None - async def poll_weights(self) -> BedscaleWeightResult: + async def update(self): loop = asyncio.get_event_loop() tr_request = loop.run_in_executor(None, self.__poll_scale__, "tr") @@ -31,7 +42,7 @@ class BedscaleEntity(Entity): br_request = loop.run_in_executor(None, self.__poll_scale__, "br") bl_request = loop.run_in_executor(None, self.__poll_scale__, "bl") - results = BedscaleWeightResult( + new_result = BedscaleWeightResult( tr=await tr_request, tl=await tl_request, br=await br_request, @@ -42,10 +53,15 @@ class BedscaleEntity(Entity): # TODO: Keep track of empty-bed weight - return results + self._history.append(new_result) + if len(self._history) > self._history_length: + self._history = self._history[-self._history_length :] def __poll_scale__(self, leg: str) -> float | None: try: return requests.get(f"{self._ip_address}/sensor/{leg}/").json()["value"] except: return None + + def get_history(self) -> list[BedscaleWeightResult]: + return self._history diff --git a/src/bridges/hue/hue_bridge.py b/src/bridges/hue/hue_bridge.py index b74e551..8c14e50 100644 --- a/src/bridges/hue/hue_bridge.py +++ b/src/bridges/hue/hue_bridge.py @@ -1,5 +1,5 @@ -import json import logging +from .hue_light import HueLight from core import Bridge, Group from phue import Bridge as phueBridge from time import sleep @@ -49,8 +49,35 @@ class HueBridge(Bridge): return self._hue.get_api() def get_all_lights(self) -> Group: - light_states = await self.list_api() - # TODO + rooms = { + r["name"]: [int(l) for l in r["lights"]] + for r in self._hue.get_api()["groups"].values() + if r["type"] == "Room" + } + + lights = [] + for l in self._hue.get_light_objects(): + if l.colormode != "xy": + # TODO: Implement light to handle all color modes + continue + + room = "Unknown" + for room_name, room_lights in rooms.items(): + if l.light_id in room_lights: + room = room_name + break + + lights.append( + HueLight( + hue_bridge=self, + hue_light=l, + id=f"hue-light-{l.light_id}", + name=l.name, + room=room, + ) + ) + + return Group(entities=lights, id="all-hue-lights", name="All Hue Lights") def list_scenes(self) -> dict: return self._hue.get_scene() @@ -62,7 +89,7 @@ class HueBridge(Bridge): return scene return None - def set_light(self, lights, command): + def set_light(self, lights: int | list[int], command): return self._hue.set_light(lights, command) def get_light(self, id, command=None): diff --git a/src/bridges/hue/hue_light.py b/src/bridges/hue/hue_light.py index 8080209..0ba40e3 100644 --- a/src/bridges/hue/hue_light.py +++ b/src/bridges/hue/hue_light.py @@ -1,56 +1,129 @@ +import asyncio from core import Entity, Color -from bridges.hue import HueBridge +from phue import Light class HueLight(Entity): + MAX_HUE_RANGE: int = 65535 + MAX_SAT_RANGE: int = 254 + MAX_BRI_RANGE: int = 254 + TRANSITION_TIME_SCALE: float = 0.1 def __init__( self, *, - bridge: HueBridge, - initial_state: dict, + hue_bridge: "HueBridge", + hue_light: Light, id: str, name: str, room: str, - groups: list[str] = ... + groups: list[str] = [], ) -> None: - super().__init__(id=id, name=name, room=room, groups=groups) - self._bridge: HueBridge = bridge + super().__init__( + id=id, name=name, room=room, groups=groups, device_type="light" + ) + self._bridge = hue_bridge + self._hue_light = hue_light + + self._on = False self._color: Color = Color() - self._on: bool = False self._transition_duration_sec = 0 - self.__parse_state__(initial_state) + asyncio.run(self.update()) - def __parse_state__(self, state: dict): - max_int_value = 255 - self._on = state["state"]["on"] - - h = state["state"]["hue"] / max_int_value - s = state["state"]["sat"] / max_int_value - v = state["state"]["bri"] / max_int_value - - # TODO: Update color instead of overwriting it, to better keep track of change? - self._color = Color(hue=h, saturation=s, brightness=v) - - def update(self): - # TODO - pass - - def set_brightness(self, brightness: float): - """Does not turn an entity on if brightness > 0 and entity turned off.""" - raise NotImplementedError("Entity does not support 'set_brightness' operation.") - - def set_hue(self, hue: float): - raise NotImplementedError("Entity does not support 'set_hue' operation.") - - def set_saturation(self, saturation: float): - raise NotImplementedError("Entity does not support 'set_saturation' operation.") - - def set_color(self, color: Color): - raise NotImplementedError("Entity does not support 'set_color' operation.") - - def set_transition_duration(self, seconds: float): - raise NotImplementedError( - "Entity does not support 'set_transition_duration' operation." + async def __commit_to_device__(self): + self._hue_light.transitiontime = ( + self._transition_duration_sec * HueLight.TRANSITION_TIME_SCALE ) + + self._hue_light.on = self._on + + self._hue_light.hue = self._color.hue * HueLight.MAX_HUE_RANGE + self._hue_light.saturation = self._color.saturation * HueLight.MAX_SAT_RANGE + self._hue_light.brightness = self._color.brightness * HueLight.MAX_BRI_RANGE + + async def update(self): + def __internal_update__(): + self._on = self._hue_light.on + + # TODO: Update Color instead of overwriting to better track change + self._color = Color( + hue=self._hue_light.hue / HueLight.MAX_HUE_RANGE, + saturation=self._hue_light.saturation / HueLight.MAX_SAT_RANGE, + brightness=self._hue_light.brightness / HueLight.MAX_BRI_RANGE, + ) + + await asyncio.get_running_loop().run_in_executor(None, __internal_update__) + + @property + def color(self) -> Color: + return self._color + + @property + def on(self) -> bool: + return self._on + + @property + def transition_time(self) -> float: + return self._transition_duration_sec + + async def set_brightness(self, brightness: float): + if self._color.brightness == brightness: + return + + self._color.brightness = brightness + + if self._on: + await self.__commit_to_device__() + + async def set_hue(self, hue: float): + if self._color.hue == hue: + return + + self._color.hue = hue + + if self._on: + await self.__commit_to_device__() + + async def set_saturation(self, saturation: float): + if self._color.saturation == saturation: + return + + self._color.saturation = saturation + + if self._on: + await self.__commit_to_device__() + + async def set_color(self, color: Color): + if self._color == color: + return + + # TODO: Avoid overwriting to better track change + self._color = color + + if self._on: + await self.__commit_to_device__() + + async def set_transition_duration(self, seconds: float): + if seconds < 0: + raise ValueError( + f"Given transition duration in seconds must be greater 0. Instead [{str(seconds)}] was provided." + ) + + self._transition_duration_sec = seconds + + async def turn_on(self): + if self._on: + return + + self._on = True + + await self.__commit_to_device__() + + async def turn_off(self): + if not self._on: + return + + self._on = False + + await self.__commit_to_device__() diff --git a/src/core/color.py b/src/core/color.py index 1957d25..a67b3c5 100644 --- a/src/core/color.py +++ b/src/core/color.py @@ -1,5 +1,6 @@ class Color: - def __init__(self, *, hue: float, saturation: float, brightness: float): + + def __init__(self, *, hue: float = 0, saturation: float = 0, brightness: float = 0): self.hue = hue self.saturation = saturation self.brightness = brightness diff --git a/src/core/entity.py b/src/core/entity.py index 3565917..a890012 100644 --- a/src/core/entity.py +++ b/src/core/entity.py @@ -16,12 +16,14 @@ class Entity: room: str | None, device_type: str, groups: list[str] = [], + update_period: float = 1, ) -> None: self._id = id self._name = name self._room = room self._device_type = device_type.strip().lower() self._groups = set(groups) + self._update_period: float = update_period @property def id(self) -> str: @@ -44,6 +46,34 @@ class Entity: def groups(self) -> set[str]: return self._groups + @property + def update_period(self) -> float: + return self._update_period + + @property + def color(self) -> Color: + raise EntityOpNotSupportedError("color") + + @property + def brightness(self) -> float: + return self.color.brightness + + @property + def hue(self) -> float: + return self.color.hue + + @property + def saturation(self) -> float: + return self.color.saturation + + @property + def on(self) -> bool: + raise EntityOpNotSupportedError("on") + + @property + def transition_time(self) -> float: + raise EntityOpNotSupportedError("transition_time") + def __str__(self) -> str: return f"{self.name} [{self.id}, type {self.device_type}, room {self.room}, in {len(self.groups)} groups]" diff --git a/src/core/group.py b/src/core/group.py index 7d60029..ae33d2c 100644 --- a/src/core/group.py +++ b/src/core/group.py @@ -1,3 +1,4 @@ +from fnmatch import fnmatch from .entity import Entity, EntityOpNotSupportedError @@ -8,7 +9,7 @@ class Group(Entity): *, entities: list[Entity] = ..., id: str = "group", - name: str = "Empty Group" + name: str = "Empty Group", ): super().__init__(id=id, name=name, room=None, device_type="group") self._entities: list[Entity] = entities @@ -27,6 +28,18 @@ class Group(Entity): for method_name in methods_to_create: setattr(self, method_name, self._create_group_method(method_name)) + def __iter__(self): + return iter(self._entities) + + def __len__(self): + return len(self._entities) + + def __getitem__(self, id: str) -> "Group": + if type(id) is int: + raise "Numerical index not supported." + + return self.__get_entities_with_specific_property__("id", id) + async def __call_method__(self, method_name: str, *args, **kwargs): for entity in self._entities: try: @@ -41,3 +54,44 @@ class Group(Entity): await self.__call_method__(method_name, *args, **kwargs) return group_method + + def __get_entities_with_specific_property__( + self, + property: str, + target_pattern: str, + ) -> list[Entity]: + """Returns all entities for which the property getter matches the desired pattern. + + Args: + property_getter (callable[[Entity], str]): Takes one entity and returns the value of the filtered property. + target_pattern (str): Pattern that is matched against. + + Returns: + list[Entity]: A new group of entities or an empty group, if no entity property matches the target pattern. + """ + return Group( + entities=[ + e + for e in self._entities + if fnmatch( + getattr(e, property), + target_pattern, + ) + ], + name=f"{property}={target_pattern}", + ) + + def with_id(self, id_pattern: str) -> "Group": + return self.__get_entities_with_specific_property__("id", id_pattern) + + def with_name(self, name_pattern: str) -> "Group": + return self.__get_entities_with_specific_property__("name", name_pattern) + + def in_room(self, room_pattern: str) -> "Group": + return self.__get_entities_with_specific_property__("room", room_pattern) + + def of_device_type(self, type_pattern: str) -> "Group": + return self.__get_entities_with_specific_property__("device_type", type_pattern) + + def in_groups(self, groups_pattern: str) -> "Group": + return self.__get_entities_with_specific_property__("groups", groups_pattern) diff --git a/src/endpoints/bedscale.py b/src/endpoints/bedscale.py index f431ae9..1fc46f8 100644 --- a/src/endpoints/bedscale.py +++ b/src/endpoints/bedscale.py @@ -16,38 +16,18 @@ bedscale = BedscaleEntity( room="Max Zimmer", ) -history = [] - -measure_delay_secs = 1 -history_length_secs = 60 * 10 - async def bedscale_service(): - global history - global bedscale - history_max_num = int(history_length_secs / measure_delay_secs) while True: - r = await bedscale.poll_weights() - history.append(r) - - if len(history) > history_max_num: - history = history[-history_max_num:] - - await asyncio.sleep(1) + r = await bedscale.update() + await asyncio.sleep(bedscale.update_period) @router.get("/latest") async def get_latest(): - if len(history) == 0: + if len(bedscale.get_history()) == 0: return HTMLResponse(status_code=200, content="No data given yet") - return history[-1] - - -# @router.get("/file", tags=["file"]) -# async def get_file(): -# with open(file_path, "r", encoding="UTF-8") as fp: -# return HTMLResponse("\n".join(fp.readlines())) - + return bedscale.get_history()[-1] # @router.get("/history") # async def get_history(count: int = None) -> list[dict]: @@ -74,9 +54,3 @@ async def get_latest(): # return points[-count] # else: # return points - - -# @router.delete("/delete", tags=["file"]) -# async def delete_file(): -# os.remove(file_path) -# return "Deleted file" diff --git a/src/endpoints/hue.py b/src/endpoints/hue.py index 3cb9404..15b9eb9 100644 --- a/src/endpoints/hue.py +++ b/src/endpoints/hue.py @@ -9,34 +9,34 @@ from core import Group router = APIRouter(tags=["hue"]) hue_bridge = HueBridge(ip_address="192.168.178.85", id="hue-bridge") -hue_lights: Group = Group() +hue_lights: Group = hue_bridge.get_all_lights() -poll_delay_sec = 5 +update_period_seconds = 5 async def hue_service(): - global hue_lights - - hue_lights = await hue_bridge.get_all_lights() - while True: try: await hue_lights.update() - - await asyncio.sleep(poll_delay_sec) + await asyncio.sleep(update_period_seconds) except: pass -@router.get("/scenes", tags=["scene"]) +@router.get("/scenes") async def get_scenes(): return hue_bridge.list_scenes() -@router.post( - "/room/{room_name}/scene/{scene_name}", - tags=["room", "scene"], -) +@router.get("/lights") +async def get_scenes(): + return { + l.id: {"name": l.name, "room": l.room, "color": str(l.color), "on": l.on} + for l in hue_lights + } + + +@router.post("/room/{room_name}/scene/{scene_name}") async def activate_scene(room_name: str, scene_name: str): try: hue_bridge.in_room_activate_scene(room_name, scene_name) @@ -44,23 +44,17 @@ async def activate_scene(room_name: str, scene_name: str): return HTMLResponse(status_code=400, content=str(e)) -@router.post( - "/room/{room_name}/off", - tags=["room"], -) +@router.post("/room/{room_name}/off") async def deactivate_room(room_name: str): try: - hue_bridge.in_room_deactivate_lights(room_name) + await hue_lights.in_room(room_name).turn_off() except Exception as e: return HTMLResponse(status_code=400, content=str(e)) -@router.post( - "/room/{room_name}/on", - tags=["room"], -) +@router.post("/room/{room_name}/on") async def activate_room(room_name: str): try: - hue_bridge.in_room_activate_lights(room_name) + await hue_lights.in_room(room_name).turn_on() except Exception as e: return HTMLResponse(status_code=400, content=str(e)) diff --git a/src/main.py b/src/main.py index 80dd53a..ddab30b 100644 --- a/src/main.py +++ b/src/main.py @@ -13,12 +13,15 @@ background_tasks = [] @asynccontextmanager async def lifespan(app: FastAPI): """Start background services.""" - fritz_task = asyncio.create_task(track_network_devices(), name="Fritz!Box Tracker") + # fritz_task = asyncio.create_task(track_network_devices(), name="Fritz!Box Tracker") bedscale_task = asyncio.create_task(bedscale_service(), name="Polling bed-scale") - hue_task = asyncio.create_task(hue_service(), name="Polling Hue Bridge") + # hue_task = asyncio.create_task(hue_service(), name="Polling Hue Bridge") + + # TODO: Fix background task execution. ^ these calls are blocking # Store references to the tasks - background_tasks.extend([fritz_task, bedscale_task, hue_task]) + # background_tasks.extend([fritz_task, bedscale_task, hue_task]) + background_tasks.extend([bedscale_task]) yield