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):
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

View file

@ -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):

View file

@ -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__()

View file

@ -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

View file

@ -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]"

View file

@ -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)

View file

@ -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"

View file

@ -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))

View file

@ -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