diff --git a/src/evohomeasync2/schemas/typedefs.py b/src/evohomeasync2/schemas/typedefs.py index 60ef20b..ae3b720 100644 --- a/src/evohomeasync2/schemas/typedefs.py +++ b/src/evohomeasync2/schemas/typedefs.py @@ -45,7 +45,7 @@ class EvoUsrConfigResponseT(TypedDict): # GET /locations?userId={user_id}&allData=True returns list of these dicts class EvoLocConfigResponseT(TypedDict): - """Response to GET /locations?userId={user_id}&allData=True. + """Response to GET /locations?userId={user_id}&allData=True The response is a list of these dicts. """ @@ -103,10 +103,10 @@ class EvoGwyConfigEntryT(TypedDict): class EvoTcsConfigEntryT(TypedDict): system_id: str model_type: TcsModelType - allowed_system_modes: list[EvoAllowedSystemModeT] + allowed_system_modes: list[EvoAllowedSystemModeResponseT] -class EvoAllowedSystemModeT(TypedDict): +class EvoAllowedSystemModeResponseT(TypedDict): system_mode: SystemMode can_be_permanent: Literal[True] can_be_temporary: bool @@ -180,6 +180,11 @@ class EvoDhwConfigEntryT(EvoDhwConfigResponseT): # GET /location/{loc_id}/status?includeTemperatureControlSystems=True returns this dict class EvoLocStatusResponseT(TypedDict): + """Response to /location/{loc_id}/status?includeTemperatureControlSystems=True + + The response is a dict of of a single location. + """ + location_id: str gateways: list[EvoGwyStatusResponseT] @@ -206,7 +211,7 @@ class EvoTcsStatusResponseT(TypedDict): class EvoSystemModeStatusResponseT(TypedDict): mode: SystemMode is_permanent: bool - until: NotRequired[str] + time_until: NotRequired[str] class EvoZonStatusResponseT(TypedDict): @@ -241,19 +246,23 @@ class EvoDhwStateStatusResponseT(TypedDict): ####################################################################################### +# WIP: These are setters, PUT, url, jason=json... +# PUT class EvoZoneStatusT(TypedDict): mode: str is_permanent: bool +# PUT class EvoSystemModeStatusT(TypedDict): mode: SystemMode is_permanent: bool time_until: NotRequired[str] +# PUT class EvoTcsStatusT(TypedDict): system_id: str system_mode_status: dict[str, Any] # TODO diff --git a/src/evohomeasync2/system.py b/src/evohomeasync2/system.py index 1a4b7bf..ecf6f3f 100644 --- a/src/evohomeasync2/system.py +++ b/src/evohomeasync2/system.py @@ -17,6 +17,7 @@ SZ_DHW, SZ_DHW_ID, SZ_ID, # remove? + SZ_IS_PERMANENT, SZ_MODE, SZ_MODEL_TYPE, SZ_NAME, @@ -27,6 +28,7 @@ SZ_SYSTEM_MODE_STATUS, SZ_TEMPERATURE, # remove? SZ_THERMOSTAT, # remove? + SZ_TIME_UNTIL, SZ_ZONE_ID, SZ_ZONES, ) @@ -39,7 +41,7 @@ EntityType, TcsModelType, ) -from .zone import ActiveFaultsBase, Zone +from .zone import ActiveFaultsBase, Zone, as_local_time if TYPE_CHECKING: from datetime import datetime as dt @@ -50,10 +52,10 @@ from .schemas.typedefs import ( DayOfWeekDhwT, DayOfWeekZoneT, - EvoAllowedSystemModeT, + EvoAllowedSystemModeResponseT, EvoScheduleDhwT, EvoScheduleZoneT, - EvoSystemModeStatusT, + EvoSystemModeStatusResponseT, EvoTcsConfigEntryT, EvoTcsConfigResponseT, EvoTcsStatusResponseT, @@ -131,7 +133,16 @@ def systemId(self) -> str: # noqa: N802 return self._id @property - def allowed_system_modes(self) -> tuple[EvoAllowedSystemModeT, ...]: + def model(self) -> TcsModelType: + return self._config[SZ_MODEL_TYPE] + + @property + def zones_by_name(self) -> dict[str, Zone]: + """Return the zones by name (names are not fixed attrs).""" + return {zone.name: zone for zone in self.zones} + + @property + def allowed_system_modes(self) -> tuple[EvoAllowedSystemModeResponseT, ...]: """ "allowedSystemModes": [ {"systemMode": "HeatingOff", "canBePermanent": true, "canBeTemporary": false}, @@ -146,36 +157,33 @@ def allowed_system_modes(self) -> tuple[EvoAllowedSystemModeT, ...]: return tuple(self._config[SZ_ALLOWED_SYSTEM_MODES]) - @property # a convenience attr - def mode(self) -> SystemMode | None: - if self.system_mode_status is None: - return None - return self.system_mode_status[SZ_MODE] - @property # a convenience attr def modes(self) -> tuple[SystemMode, ...]: return tuple(d[SZ_SYSTEM_MODE] for d in self.allowed_system_modes) @property - def model(self) -> TcsModelType: - return self._config[SZ_MODEL_TYPE] - - @property - def system_mode_status(self) -> EvoSystemModeStatusT | None: + def system_mode_status(self) -> EvoSystemModeStatusResponseT: """ - "systemModeStatus": { - "mode": "AutoWithEco", - "isPermanent": true - } + "systemModeStatus": {"mode": "AutoWithEco", "isPermanent": true} + "systemModeStatus": {'mode': 'AutoWithEco', 'isPermanent': false, 'timeUntil': '2024-12-21T15:55:00Z'}} """ if self._status is None: raise exc.InvalidStatusError("No system mode status, has it been fetched?") return self._status[SZ_SYSTEM_MODE_STATUS] - @property - def zones_by_name(self) -> dict[str, Zone]: - """Return the zones by name (names are not fixed attrs).""" - return {zone.name: zone for zone in self.zones} + @property # a convenience attr (could have a setter in future) + def mode(self) -> SystemMode | None: + return self.system_mode_status[SZ_MODE] + + @property # a convenience attr (could have a setter in future) + def is_permanent(self) -> bool | None: + return self.system_mode_status[SZ_IS_PERMANENT] + + @property # a convenience attr (could have a setter in future) + def until(self) -> dt | None: # aka timeUntil + if (until := self.system_mode_status.get(SZ_TIME_UNTIL)) is None: + return None + return as_local_time(until, self.location.tzinfo) async def _set_mode(self, mode: dict[str, str | bool]) -> None: """Set the TCS mode.""" # {'mode': 'Auto', 'isPermanent': True} @@ -185,6 +193,8 @@ async def _set_mode(self, mode: dict[str, str | bool]) -> None: f"{self}: Unsupported/unknown {S2_SYSTEM_MODE}: {mode}" ) + mode[S2_SYSTEM_MODE] = str(mode[S2_SYSTEM_MODE]) + await self._auth.put(f"{self._TYPE}/{self.id}/mode", json=mode) async def reset(self) -> None: diff --git a/src/evohomeasync2/zone.py b/src/evohomeasync2/zone.py index b9253a4..371dea3 100644 --- a/src/evohomeasync2/zone.py +++ b/src/evohomeasync2/zone.py @@ -51,7 +51,7 @@ import voluptuous as vol - from . import ControlSystem + from . import ControlSystem, Location from .auth import Auth from .schemas.typedefs import ( DailySchedulesT, @@ -149,11 +149,13 @@ def status( class ActiveFaultsBase(EntityBase): """Provide the base for active faults.""" + location: Location + def __init__(self, entity_id: str, broker: Auth, logger: logging.Logger) -> None: super().__init__(entity_id, broker, logger) self._active_faults: list[EvoActiveFaultResponseT] = [] - self._last_logged: dict[str, dt] = {} + self._last_logged: dict[str, dt] = {} # OK to use a tz=UTC datetimes def _update_faults( self, @@ -187,6 +189,10 @@ def log_as_resolved(fault: EvoActiveFaultResponseT) -> None: elif dt.now(tz=UTC) - self._last_logged[hash_(fault)] > _ONE_DAY: log_as_active(fault) + # self._active_faults = [ + # {**fault, "since": as_local_time(fault["since"], self.location.tzinfo)} + # for fault in active_faults + # ] self._active_faults = active_faults self._last_logged |= last_logged @@ -204,12 +210,14 @@ def active_faults(self) -> tuple[EvoActiveFaultResponseT, ...]: return tuple(self._active_faults) -def as_local_time(dtm: dt, tzinfo: tzinfo) -> dt: +def as_local_time(dtm: dt | str, tzinfo: tzinfo) -> dt: """Convert a datetime into a aware datetime in the given TZ.""" + if isinstance(dtm, str): + dtm = dt.fromisoformat(dtm) return dtm.replace(tzinfo=tzinfo) if dtm.tzinfo is None else dtm.astimezone(tzinfo) -def dt_to_dow_and_tod(dtm: dt, tzinfo: tzinfo) -> tuple[DayOfWeek, str]: +def _dt_to_dow_and_tod(dtm: dt, tzinfo: tzinfo) -> tuple[DayOfWeek, str]: """Return a pair of strings representing the local day of week and time of day.""" dtm = as_local_time(dtm, tzinfo) return dtm.strftime("%A"), dtm.strftime("%H:%M") # type: ignore[return-value] @@ -222,7 +230,9 @@ def _find_switchpoints( ) -> tuple[SwitchpointT, int, SwitchpointT, int]: """Find this/next switchpoints for a given day of week and time of day.""" - # assumes >1 switchpoint per day, which could be this_sp or next_sp only + # TODO: schedule can be [], i.e. response was {'DailySchedules': []} + + # assumes 1+ switchpoint per day, which could be this_sp or next_sp only try: day_idx = list(DayOfWeek).index(day_of_week) @@ -347,7 +357,7 @@ def _find_switchpoints(self, dtm: dt | str) -> dict[dt, float | str]: dtm = as_local_time(dtm, self.location.tzinfo) this_sp, this_offset, next_sp, next_offset = _find_switchpoints( - self._schedule, *dt_to_dow_and_tod(dtm, self.location.tzinfo) + self._schedule, *_dt_to_dow_and_tod(dtm, self.location.tzinfo) ) this_tod = dt.strptime(this_sp["time_of_day"], "%H:%M").time() # noqa: DTZ007 @@ -356,6 +366,7 @@ def _find_switchpoints(self, dtm: dt | str) -> dict[dt, float | str]: this_dtm = dt.combine(dtm + td(days=this_offset), this_tod) next_dtm = dt.combine(dtm + td(days=next_offset), next_tod) + # either "dhw_state" or "heat_setpoint" _will_ be present... this_val = this_sp.get("dhw_state") or this_sp["heat_setpoint"] next_val = next_sp.get("dhw_state") or next_sp["heat_setpoint"] @@ -456,33 +467,13 @@ def __init__(self, tcs: ControlSystem, config: EvoZonConfigResponseT) -> None: def zoneId(self) -> str: # noqa: N802 return self._id - @property # convenience attr - def max_heat_setpoint(self) -> float: - return self.setpoint_capabilities[SZ_MAX_HEAT_SETPOINT] - - @property # convenience attr - def min_heat_setpoint(self) -> float: - return self.setpoint_capabilities[SZ_MIN_HEAT_SETPOINT] - @property def model(self) -> ZoneModelType: return self._config[SZ_MODEL_TYPE] - @property # a convenience attr - def mode(self) -> ZoneMode | None: - if not self.setpoint_status: - return None - return self.setpoint_status[SZ_SETPOINT_MODE] - - @property # a convenience attr - def modes(self) -> tuple[ZoneMode, ...]: - """ - "allowedSetpointModes": [ - "PermanentOverride", "FollowSchedule", "TemporaryOverride" - ] - """ - - return tuple(self.setpoint_capabilities[SZ_ALLOWED_SETPOINT_MODES]) + @property + def type(self) -> ZoneType: + return self._config[SZ_ZONE_TYPE] @property def name(self) -> str: @@ -519,6 +510,26 @@ def setpoint_capabilities(self) -> EvoZonSetpointCapabilitiesResponseT: """ return self._config[SZ_SETPOINT_CAPABILITIES] + @property # convenience attr + def max_heat_setpoint(self) -> float: + # consider: if not self.setpoint_capabilities["can_control_heat"]: return None + return self.setpoint_capabilities[SZ_MAX_HEAT_SETPOINT] + + @property # convenience attr + def min_heat_setpoint(self) -> float: + # consider: if not self.setpoint_capabilities["can_control_heat"]: return None + return self.setpoint_capabilities[SZ_MIN_HEAT_SETPOINT] + + @property # a convenience attr + def modes(self) -> tuple[ZoneMode, ...]: + """ + "allowedSetpointModes": [ + "PermanentOverride", "FollowSchedule", "TemporaryOverride" + ] + """ + + return tuple(self.setpoint_capabilities[SZ_ALLOWED_SETPOINT_MODES]) + @property def setpoint_status(self) -> EvoZonSetpointStatusResponseT | None: """ @@ -532,15 +543,17 @@ def setpoint_status(self) -> EvoZonSetpointStatusResponseT | None: return self._status[SZ_SETPOINT_STATUS] @property # a convenience attr + def mode(self) -> ZoneMode | None: + if not self.setpoint_status: + return None + return self.setpoint_status[SZ_SETPOINT_MODE] + + @property # a convenience attr (one day a target_cool_temperature may be added?) def target_heat_temperature(self) -> float | None: if self.setpoint_status is None: return None return self.setpoint_status[SZ_TARGET_HEAT_TEMPERATURE] - @property - def type(self) -> ZoneType: - return self._config[SZ_ZONE_TYPE] - async def _set_mode(self, mode: dict[str, str | float]) -> None: """Set the zone mode (heat_setpoint, cooling is TBD)."""