Hmmmm, more or less working hue stuff

This commit is contained in:
Maximilian Giller 2025-01-21 06:45:21 +01:00
parent 5e8ad4f400
commit 1d46b570f6
9 changed files with 277 additions and 105 deletions

View file

@ -19,11 +19,22 @@ class BedscaleWeightResult:
class BedscaleEntity(Entity): 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") super().__init__(id=id, name=name, room=room, device_type="bedscale")
self._ip_address = ip_address 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() loop = asyncio.get_event_loop()
tr_request = loop.run_in_executor(None, self.__poll_scale__, "tr") 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") br_request = loop.run_in_executor(None, self.__poll_scale__, "br")
bl_request = loop.run_in_executor(None, self.__poll_scale__, "bl") bl_request = loop.run_in_executor(None, self.__poll_scale__, "bl")
results = BedscaleWeightResult( new_result = BedscaleWeightResult(
tr=await tr_request, tr=await tr_request,
tl=await tl_request, tl=await tl_request,
br=await br_request, br=await br_request,
@ -42,10 +53,15 @@ class BedscaleEntity(Entity):
# TODO: Keep track of empty-bed weight # 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: def __poll_scale__(self, leg: str) -> float | None:
try: try:
return requests.get(f"{self._ip_address}/sensor/{leg}/").json()["value"] return requests.get(f"{self._ip_address}/sensor/{leg}/").json()["value"]
except: except:
return None return None
def get_history(self) -> list[BedscaleWeightResult]:
return self._history

View file

@ -1,5 +1,5 @@
import json
import logging import logging
from .hue_light import HueLight
from core import Bridge, Group from core import Bridge, Group
from phue import Bridge as phueBridge from phue import Bridge as phueBridge
from time import sleep from time import sleep
@ -49,8 +49,35 @@ class HueBridge(Bridge):
return self._hue.get_api() return self._hue.get_api()
def get_all_lights(self) -> Group: def get_all_lights(self) -> Group:
light_states = await self.list_api() rooms = {
# TODO 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: def list_scenes(self) -> dict:
return self._hue.get_scene() return self._hue.get_scene()
@ -62,7 +89,7 @@ class HueBridge(Bridge):
return scene return scene
return None return None
def set_light(self, lights, command): def set_light(self, lights: int | list[int], command):
return self._hue.set_light(lights, command) return self._hue.set_light(lights, command)
def get_light(self, id, command=None): def get_light(self, id, command=None):

View file

@ -1,56 +1,129 @@
import asyncio
from core import Entity, Color from core import Entity, Color
from bridges.hue import HueBridge from phue import Light
class HueLight(Entity): 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__( def __init__(
self, self,
*, *,
bridge: HueBridge, hue_bridge: "HueBridge",
initial_state: dict, hue_light: Light,
id: str, id: str,
name: str, name: str,
room: str, room: str,
groups: list[str] = ... groups: list[str] = [],
) -> None: ) -> None:
super().__init__(id=id, name=name, room=room, groups=groups) super().__init__(
self._bridge: HueBridge = bridge 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._color: Color = Color()
self._on: bool = False
self._transition_duration_sec = 0 self._transition_duration_sec = 0
self.__parse_state__(initial_state) asyncio.run(self.update())
def __parse_state__(self, state: dict): async def __commit_to_device__(self):
max_int_value = 255 self._hue_light.transitiontime = (
self._on = state["state"]["on"] self._transition_duration_sec * HueLight.TRANSITION_TIME_SCALE
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."
) )
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__()

View file

@ -1,5 +1,6 @@
class Color: 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.hue = hue
self.saturation = saturation self.saturation = saturation
self.brightness = brightness self.brightness = brightness

View file

@ -16,12 +16,14 @@ class Entity:
room: str | None, room: str | None,
device_type: str, device_type: str,
groups: list[str] = [], groups: list[str] = [],
update_period: float = 1,
) -> None: ) -> None:
self._id = id self._id = id
self._name = name self._name = name
self._room = room self._room = room
self._device_type = device_type.strip().lower() self._device_type = device_type.strip().lower()
self._groups = set(groups) self._groups = set(groups)
self._update_period: float = update_period
@property @property
def id(self) -> str: def id(self) -> str:
@ -44,6 +46,34 @@ class Entity:
def groups(self) -> set[str]: def groups(self) -> set[str]:
return self._groups 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: def __str__(self) -> str:
return f"{self.name} [{self.id}, type {self.device_type}, room {self.room}, in {len(self.groups)} groups]" return f"{self.name} [{self.id}, type {self.device_type}, room {self.room}, in {len(self.groups)} groups]"

View file

@ -1,3 +1,4 @@
from fnmatch import fnmatch
from .entity import Entity, EntityOpNotSupportedError from .entity import Entity, EntityOpNotSupportedError
@ -8,7 +9,7 @@ class Group(Entity):
*, *,
entities: list[Entity] = ..., entities: list[Entity] = ...,
id: str = "group", id: str = "group",
name: str = "Empty Group" name: str = "Empty Group",
): ):
super().__init__(id=id, name=name, room=None, device_type="group") super().__init__(id=id, name=name, room=None, device_type="group")
self._entities: list[Entity] = entities self._entities: list[Entity] = entities
@ -27,6 +28,18 @@ class Group(Entity):
for method_name in methods_to_create: for method_name in methods_to_create:
setattr(self, method_name, self._create_group_method(method_name)) 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): async def __call_method__(self, method_name: str, *args, **kwargs):
for entity in self._entities: for entity in self._entities:
try: try:
@ -41,3 +54,44 @@ class Group(Entity):
await self.__call_method__(method_name, *args, **kwargs) await self.__call_method__(method_name, *args, **kwargs)
return group_method 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)

View file

@ -16,38 +16,18 @@ bedscale = BedscaleEntity(
room="Max Zimmer", room="Max Zimmer",
) )
history = []
measure_delay_secs = 1
history_length_secs = 60 * 10
async def bedscale_service(): async def bedscale_service():
global history
global bedscale
history_max_num = int(history_length_secs / measure_delay_secs)
while True: while True:
r = await bedscale.poll_weights() r = await bedscale.update()
history.append(r) await asyncio.sleep(bedscale.update_period)
if len(history) > history_max_num:
history = history[-history_max_num:]
await asyncio.sleep(1)
@router.get("/latest") @router.get("/latest")
async def 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 HTMLResponse(status_code=200, content="No data given yet")
return history[-1] return bedscale.get_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()))
# @router.get("/history") # @router.get("/history")
# async def get_history(count: int = None) -> list[dict]: # async def get_history(count: int = None) -> list[dict]:
@ -74,9 +54,3 @@ async def get_latest():
# return points[-count] # return points[-count]
# else: # else:
# return points # return points
# @router.delete("/delete", tags=["file"])
# async def delete_file():
# os.remove(file_path)
# return "Deleted file"

View file

@ -9,34 +9,34 @@ from core import Group
router = APIRouter(tags=["hue"]) router = APIRouter(tags=["hue"])
hue_bridge = HueBridge(ip_address="192.168.178.85", id="hue-bridge") 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(): async def hue_service():
global hue_lights
hue_lights = await hue_bridge.get_all_lights()
while True: while True:
try: try:
await hue_lights.update() await hue_lights.update()
await asyncio.sleep(update_period_seconds)
await asyncio.sleep(poll_delay_sec)
except: except:
pass pass
@router.get("/scenes", tags=["scene"]) @router.get("/scenes")
async def get_scenes(): async def get_scenes():
return hue_bridge.list_scenes() return hue_bridge.list_scenes()
@router.post( @router.get("/lights")
"/room/{room_name}/scene/{scene_name}", async def get_scenes():
tags=["room", "scene"], 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): async def activate_scene(room_name: str, scene_name: str):
try: try:
hue_bridge.in_room_activate_scene(room_name, scene_name) 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)) return HTMLResponse(status_code=400, content=str(e))
@router.post( @router.post("/room/{room_name}/off")
"/room/{room_name}/off",
tags=["room"],
)
async def deactivate_room(room_name: str): async def deactivate_room(room_name: str):
try: try:
hue_bridge.in_room_deactivate_lights(room_name) await hue_lights.in_room(room_name).turn_off()
except Exception as e: except Exception as e:
return HTMLResponse(status_code=400, content=str(e)) return HTMLResponse(status_code=400, content=str(e))
@router.post( @router.post("/room/{room_name}/on")
"/room/{room_name}/on",
tags=["room"],
)
async def activate_room(room_name: str): async def activate_room(room_name: str):
try: try:
hue_bridge.in_room_activate_lights(room_name) await hue_lights.in_room(room_name).turn_on()
except Exception as e: except Exception as e:
return HTMLResponse(status_code=400, content=str(e)) return HTMLResponse(status_code=400, content=str(e))

View file

@ -13,12 +13,15 @@ background_tasks = []
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
"""Start background services.""" """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") 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 # 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 yield