pydantic work

This commit is contained in:
John Lancaster
2024-04-02 22:30:23 -05:00
parent c0a22b63f8
commit 7f8d0311ab
2 changed files with 71 additions and 37 deletions

View File

@@ -1,10 +1,10 @@
from datetime import datetime, timedelta, time from datetime import datetime, time, timedelta
from pathlib import Path from pathlib import Path
from typing import Annotated, Dict, List, Self from typing import Annotated, Dict, List, Optional, Self
import yaml import yaml
from astral import SunDirection from astral import SunDirection
from pydantic import BaseModel, BeforeValidator, conint, root_validator from pydantic import BaseModel, BeforeValidator, Field, conint, root_validator
from pydantic_core import PydanticCustomError from pydantic_core import PydanticCustomError
from rich.console import Console, ConsoleOptions, RenderResult from rich.console import Console, ConsoleOptions, RenderResult
from rich.table import Column, Table from rich.table import Column, Table
@@ -19,11 +19,9 @@ def str_to_timedelta(input_str: str) -> timedelta:
def str_to_direction(input_str: str) -> SunDirection: def str_to_direction(input_str: str) -> SunDirection:
if input_str.lower() == 'setting': try:
return SunDirection.SETTING return getattr(SunDirection, input_str.upper())
elif input_str == 'rising': except AttributeError:
return SunDirection.RISING
else:
raise PydanticCustomError('invalid_dir', 'Invalid sun direction: {dir}', dict(dir=input_str)) raise PydanticCustomError('invalid_dir', 'Invalid sun direction: {dir}', dict(dir=input_str))
@@ -32,30 +30,28 @@ OffDuration = Annotated[timedelta, BeforeValidator(str_to_timedelta)]
class State(BaseModel): class State(BaseModel):
state: bool = True state: bool = True
brightness: conint(ge=1, le=255) = None brightness: Optional[int] = Field(default=None, ge=1, le=255)
color_temp: conint(ge=200, le=650) = None color_temp: Optional[int] = Field(default=None, ge=200, le=650)
rgb_color: Optional[list[int]] = Field(default=None, min_length=3, max_length=3)
class ApplyKwargs(BaseModel): class ApplyKwargs(BaseModel):
"""Arguments to call with the 'scene/apply' service""" """Arguments to call with the 'scene/apply' service"""
entities: Dict[str, State] entities: Dict[str, State]
transition: int = None transition: Optional[int] = None
class ControllerStateConfig(BaseModel): class ControllerStateConfig(BaseModel):
time: str | datetime = None time: Optional[str | datetime] = None
elevation: float = None elevation: Optional[float] = None
direction: Annotated[SunDirection, BeforeValidator(str_to_direction)] = None direction: Optional[Annotated[SunDirection, BeforeValidator(str_to_direction)]] = None
off_duration: OffDuration = None off_duration: Optional[OffDuration] = None
scene: Dict[str, State] scene: dict[str, State]
@root_validator(pre=True) @root_validator(pre=True)
def check_args(cls, values): def check_args(cls, values):
time, elevation = values.get('time'), values.get('elevation') time, elevation = values.get('time'), values.get('elevation')
if time is None and elevation is None: if time is not None and elevation is not None:
raise PydanticCustomError('bad_time_spec', 'Either time or elevation must be set.')
elif time is not None and elevation is not None:
raise PydanticCustomError('bad_time_spec', 'Only one of time or elevation can be set.') raise PydanticCustomError('bad_time_spec', 'Only one of time or elevation can be set.')
elif elevation is not None and 'direction' not in values: elif elevation is not None and 'direction' not in values:
raise PydanticCustomError('no_sun_dir', 'Needs sun direction with elevation') raise PydanticCustomError('no_sun_dir', 'Needs sun direction with elevation')
@@ -66,18 +62,17 @@ class ControllerStateConfig(BaseModel):
class RoomControllerConfig(BaseModel): class RoomControllerConfig(BaseModel):
states: List[ControllerStateConfig] states: List[ControllerStateConfig] = Field(default_factory=list)
off_duration: OffDuration = None off_duration: Optional[OffDuration] = None
sleep_state: Optional[ControllerStateConfig] = None
@classmethod @classmethod
def from_yaml(cls: Self, yaml_path: Path): def from_yaml(cls: Self, yaml_path: Path) -> Self:
yaml_path = Path(yaml_path) yaml_path = Path(yaml_path)
with yaml_path.open('r') as f: with yaml_path.open('r') as f:
for appname, app_cfg in yaml.load(f, Loader=yaml.SafeLoader).items(): for appname, app_cfg in yaml.load(f, Loader=yaml.SafeLoader).items():
if app_cfg['class'] == 'RoomController': if app_cfg['class'] == 'RoomController':
break return cls.model_validate(app_cfg)
print(app_cfg)
return cls(**app_cfg)
def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult: def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
table = Table( table = Table(
@@ -104,15 +99,11 @@ class RoomControllerConfig(BaseModel):
self.states = sorted(self.states, key=lambda s: s.time, reverse=True) self.states = sorted(self.states, key=lambda s: s.time, reverse=True)
def current_state(self, now: time) -> ControllerStateConfig: def current_state(self, now: time) -> ControllerStateConfig:
# time_fmt = '%I:%M:%S %p'
# print(now.strftime(time_fmt))
self.sort_states() self.sort_states()
for state in self.states: for state in self.states:
if state.time <= now: if state.time <= now:
return state return state
else: else:
# self.log(f'Defaulting to first state')
return self.states[0] return self.states[0]
def current_scene(self, now: time) -> Dict: def current_scene(self, now: time) -> Dict:

View File

@@ -1,13 +1,29 @@
import re import re
from datetime import timedelta from datetime import timedelta
from typing import Literal, Optional
from appdaemon.entity import Entity from appdaemon.entity import Entity
from appdaemon.plugins.hass.hassapi import Hass from appdaemon.plugins.hass.hassapi import Hass
from console import setup_component_logging from console import console, setup_component_logging
from pydantic import BaseModel, TypeAdapter
from room_control import RoomController from room_control import RoomController
class CallbackEntry(BaseModel):
entity: str
event: Optional[str] = None
type: Literal['state', 'event']
kwargs: str
function: str
name: str
pin_app: bool
pin_thread: int
Callbacks = dict[str, dict[str, CallbackEntry]]
class Motion(Hass): class Motion(Hass):
@property @property
def sensor(self) -> Entity: def sensor(self) -> Entity:
@@ -39,14 +55,33 @@ class Motion(Hass):
) )
if self.sensor_state != self.ref_entity_state: if self.sensor_state != self.ref_entity_state:
self.log(f'Sensor is {self.sensor_state} ' f'and light is {self.ref_entity_state}', level='WARNING') self.log(
f'Sensor is {self.sensor_state} ' f'and light is {self.ref_entity_state}',
level='WARNING',
)
if self.sensor_state: if self.sensor_state:
self.app.activate(kwargs={'cause': f'Syncing state with {self.sensor.entity_id}'}) self.app.activate(kwargs={'cause': f'Syncing state with {self.sensor.entity_id}'})
# don't need to await these because they'll already get turned into a task by the utils.sync_wrapper decorator # don't need to await these because they'll already get turned into a task by the utils.sync_wrapper decorator
self.listen_state(**base_kwargs, attribute='brightness', callback=self.callback_light_on,) self.listen_state(
**base_kwargs,
attribute='brightness',
callback=self.callback_light_on,
)
self.listen_state(**base_kwargs, new='off', callback=self.callback_light_off) self.listen_state(**base_kwargs, new='off', callback=self.callback_light_off)
if callbacks := self.callbacks():
for handle, entry in callbacks.items():
self.log(f'Handle [yellow]{handle[:4]}[/]: {entry.function}')
def callbacks(self):
data = TypeAdapter(Callbacks).validate_python(self.get_callback_entries())
name: str = self.name
try:
return data[name]
except KeyError:
return []
def listen_motion_on(self): def listen_motion_on(self):
"""Sets up the motion on callback to activate the room""" """Sets up the motion on callback to activate the room"""
self.cancel_motion_callback() self.cancel_motion_callback()
@@ -59,7 +94,9 @@ class Motion(Hass):
) )
self.log(f'Waiting for motion on [friendly_name]{self.sensor.friendly_name}[/]') self.log(f'Waiting for motion on [friendly_name]{self.sensor.friendly_name}[/]')
if self.sensor_state: if self.sensor_state:
self.log(f'[friendly_name]{self.sensor.friendly_name}[/] is already on', level='WARNING') self.log(
f'[friendly_name]{self.sensor.friendly_name}[/] is already on', level='WARNING'
)
def listen_motion_off(self, duration: timedelta): def listen_motion_off(self, duration: timedelta):
"""Sets up the motion off callback to deactivate the room""" """Sets up the motion off callback to deactivate the room"""
@@ -72,10 +109,14 @@ class Motion(Hass):
oneshot=True, oneshot=True,
cause='motion off', cause='motion off',
) )
self.log(f'Waiting for motion to stop on [friendly_name]{self.sensor.friendly_name}[/] for {duration}') self.log(
f'Waiting for [friendly_name]{self.sensor.friendly_name}[/] to be clear for {duration}'
)
if not self.sensor_state: if not self.sensor_state:
self.log(f'[friendly_name]{self.sensor.friendly_name}[/] is currently off', level='WARNING') self.log(
f'[friendly_name]{self.sensor.friendly_name}[/] is currently off', level='WARNING'
)
def callback_light_on(self, entity=None, attribute=None, old=None, new=None, kwargs=None): def callback_light_on(self, entity=None, attribute=None, old=None, new=None, kwargs=None):
"""Called when the light turns on""" """Called when the light turns on"""
@@ -102,7 +143,9 @@ class Motion(Hass):
def get_sensor_callbacks(self): def get_sensor_callbacks(self):
return { return {
handle: info for handle, info in self.get_app_callbacks().items() if info['entity'] == self.sensor.entity_id handle: info
for handle, info in self.get_app_callbacks().items()
if info['entity'] == self.sensor.entity_id
} }
def cancel_motion_callback(self): def cancel_motion_callback(self):