From e8a630a60b45c9bde2978731810f15aced8816f3 Mon Sep 17 00:00:00 2001 From: krahabb <13969600+krahabb@users.noreply.github.com> Date: Wed, 14 Feb 2024 17:44:32 +0000 Subject: [PATCH] refactor MtsSchedule (calendar) entity and state management --- custom_components/meross_lan/calendar.py | 170 +++++++++--------- .../meross_lan/meross_device_hub.py | 4 +- custom_components/meross_lan/meross_entity.py | 2 +- 3 files changed, 91 insertions(+), 85 deletions(-) diff --git a/custom_components/meross_lan/calendar.py b/custom_components/meross_lan/calendar.py index aadc0789..8e472766 100644 --- a/custom_components/meross_lan/calendar.py +++ b/custom_components/meross_lan/calendar.py @@ -21,6 +21,8 @@ from .merossclient import const as mc if typing.TYPE_CHECKING: + from typing import ClassVar, Final + from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -33,10 +35,6 @@ async def async_setup_entry( me.platform_setup_entry(hass, config_entry, async_add_devices, calendar.DOMAIN) -class MLCalendar(me.MerossEntity, calendar.CalendarEntity): # type: ignore - PLATFORM = calendar.DOMAIN - - MTS_SCHEDULE_WEEKDAY = ("mon", "tue", "wed", "thu", "fri", "sat", "sun") MTS_SCHEDULE_RRULE = "FREQ=WEEKLY" @@ -95,29 +93,31 @@ def get_event(self, climate: MtsClimate) -> calendar.CalendarEvent: ) -class MtsSchedule(MLCalendar): +class MtsSchedule(me.MerossEntity, calendar.CalendarEntity): + PLATFORM = calendar.DOMAIN manager: MerossDeviceBase - climate: typing.Final[MtsClimate] # set in descendants class def - namespace: str - key_namespace: str - key_channel: str + namespace: ClassVar[str] + key_namespace: ClassVar[str] + key_channel: ClassVar[str] # HA core entity attributes: entity_category = me.EntityCategory.CONFIG - _attr_state: MtsScheduleNativeType | None supported_features: calendar.CalendarEntityFeature = ( calendar.CalendarEntityFeature.CREATE_EVENT | calendar.CalendarEntityFeature.DELETE_EVENT | calendar.CalendarEntityFeature.UPDATE_EVENT ) + climate: Final[MtsClimate] + _native_schedule: MtsScheduleNativeType | None _schedule: MtsScheduleNativeType | None __slots__ = ( "climate", "_flatten", + "_native_schedule", "_schedule", "_schedule_unit_time", "_schedule_entry_count", @@ -132,8 +132,9 @@ def __init__( # save a flattened version of the device schedule to ease/optimize CalendarEvent management # since the original schedule has a fixed number of contiguous events spanning the day(s) (6 on my MTS100) # we might 'compress' these when 2 or more consecutive entries don't change the temperature - # self._attr_state carries the original unpacked schedule payload from the device representing + # _native_schedule carries the original unpacked schedule payload from the device representing # its effective state + self._native_schedule = None self._schedule = None # set the 'granularity' of the schedule entries i.e. the schedule duration # must be a multiple of this time (in minutes). It is set lately by customized @@ -155,6 +156,11 @@ async def async_added_to_hass(self): self.manager.check_device_timezone() return await super().async_added_to_hass() + def set_unavailable(self): + self._native_schedule = None + self._schedule = None + super().set_unavailable() + # interface: Calendar @property def event(self) -> calendar.CalendarEvent | None: @@ -195,7 +201,7 @@ async def async_create_event(self, **kwargs): await self._async_request_schedule() except Exception as exception: # invalidate working data (might be dirty) - self._schedule = None + self._build_internal_schedule() raise HomeAssistantError( f"{type(exception).__name__} {str(exception)}" ) from exception @@ -213,7 +219,7 @@ async def async_delete_event( raise Exception("The daily schedule must contain at least one event") except Exception as error: # invalidate working data (might be dirty) - self._schedule = None + self._build_internal_schedule() raise HomeAssistantError(str(error)) from error async def async_update_event( @@ -229,63 +235,12 @@ async def async_update_event( await self._async_request_schedule() except Exception as error: # invalidate working data (might be dirty) - self._schedule = None + self._build_internal_schedule() raise HomeAssistantError(str(error)) from error # interface: self - @property - def schedule(self): - if self._schedule is None: - if state := self._attr_state: - # state = { - # ... - # "mon": [[390,150],[90,240],[300,190],[270,220],[300,150],[90,150]], - # "tue": [[390,150],[90,240],[300,190],[270,220],[300,150],[90,150]], - # "wed": [[390,150],[90,240],[300,190],[270,220],[300,150],[90,150]], - # "thu": [[390,150],[90,240],[300,190],[270,220],[300,150],[90,150]], - # "fri": [[390,150],[90,240],[300,190],[270,220],[300,150],[90,150]], - # "sat": [[390,150],[90,240],[300,190],[270,220],[300,150],[90,150]], - # "sun": [[390,150],[90,240],[300,190],[270,220],[300,150],[90,150]] - # } - schedule: MtsScheduleNativeType = {w: [] for w in MTS_SCHEDULE_WEEKDAY} - for weekday, weekday_schedule in schedule.items(): - if weekday_state := state.get(weekday): - # weekday_state = [[390,150],[90,240],[300,190],[270,220],[300,150],[90,150]] - # recover the length and do a sanity check: we expect - # the device schedules to be the same fixed length - schedule_entry_count = len(weekday_state) - if self._schedule_entry_count != schedule_entry_count: - # this should fire only on first weekday scan - if self._schedule_entry_count: - # TODO: mts200b (trace from #369) shows this is possible - # so we'll have to rethink our algorithm - self.log( - self.WARNING, - "unexpected device schedule entries count", - timeout=14400, - ) - else: - self._schedule_entry_count = schedule_entry_count - if self._flatten: - current_entry = None - for entry in weekday_state: - if current_entry and (entry[1] == current_entry[1]): - # same T: flatten out - current_entry[0] = current_entry[0] + entry[0] - else: - current_entry = list(entry) - weekday_schedule.append(current_entry) - else: - # don't flatten..but (deep)copy over - for entry in weekday_state: - weekday_schedule.append(list(entry)) - - self._schedule = schedule - - return self._schedule - async def _async_request_schedule(self): - if schedule := self.schedule: + if schedule := self._schedule: payload = {} # the time duration step (minimum interval) of the schedule intervals schedule_entry_unittime = self._schedule_unit_time @@ -346,7 +301,7 @@ def _get_event_entry(self, event_time: datetime) -> MtsScheduleEntry | None: the internal representation to the HA CaleandarEvent used to pass the state to HA. event_time is expressed in local time of the device (if it has any configured) """ - schedule = self.schedule + schedule = self._schedule if not schedule: return None weekday_index = event_time.weekday() @@ -379,10 +334,10 @@ def _get_next_event_entry( """Extracts the next event entry description from the internal schedule representation Useful to iterate over when HA asks for data """ + schedule = self._schedule + if not schedule: + return None with self.exception_warning("parsing internal schedule", timeout=14400): - schedule = self.schedule - if not schedule: - return None weekday_index = event_entry.weekday_index weekday_schedule: list = schedule[MTS_SCHEDULE_WEEKDAY[weekday_index]] schedule_index = event_entry.index + 1 @@ -395,14 +350,14 @@ def _get_next_event_entry( weekday_schedule = schedule[MTS_SCHEDULE_WEEKDAY[weekday_index]] schedule_index = 0 schedule_minutes_begin = 0 - schedule = weekday_schedule[schedule_index] + schedule_native_entry = weekday_schedule[schedule_index] return MtsScheduleEntry( weekday_index=weekday_index, index=schedule_index, minutes_begin=schedule_minutes_begin, - minutes_end=schedule_minutes_begin + schedule[0], + minutes_end=schedule_minutes_begin + schedule_native_entry[0], day=event_day, - data=schedule, + data=schedule_native_entry, ) def _extract_rfc5545_temp(self, event: dict[str, typing.Any]) -> int: @@ -447,7 +402,7 @@ def _extract_rfc5545_info( ) def _internal_delete_event(self, uid: str): - schedule = self.schedule + schedule = self._schedule if not schedule: raise Exception("Internal state unavailable") uid_split = uid.split("#") @@ -469,7 +424,7 @@ def _internal_delete_event(self, uid: str): return True async def _internal_create_event(self, **kwargs): - schedule = self.schedule + schedule = self._schedule if not schedule: raise Exception("Internal state unavailable") # get the number of maximum entries for the day from device state @@ -646,17 +601,68 @@ async def _internal_create_event(self, **kwargs): # end for weekday + def _build_internal_schedule(self): + self._schedule = None + self._schedule_entry_count = 0 + if state := self._native_schedule: + # state = { + # ... + # "mon": [[390,150],[90,240],[300,190],[270,220],[300,150],[90,150]], + # "tue": [[390,150],[90,240],[300,190],[270,220],[300,150],[90,150]], + # "wed": [[390,150],[90,240],[300,190],[270,220],[300,150],[90,150]], + # "thu": [[390,150],[90,240],[300,190],[270,220],[300,150],[90,150]], + # "fri": [[390,150],[90,240],[300,190],[270,220],[300,150],[90,150]], + # "sat": [[390,150],[90,240],[300,190],[270,220],[300,150],[90,150]], + # "sun": [[390,150],[90,240],[300,190],[270,220],[300,150],[90,150]] + # } + with self.exception_warning("_build_internal_schedule", timeout=14400): + schedule: MtsScheduleNativeType = {w: [] for w in MTS_SCHEDULE_WEEKDAY} + for weekday, weekday_schedule in schedule.items(): + if weekday_state := state.get(weekday): + # weekday_state = [[390,150],[90,240],[300,190],[270,220],[300,150],[90,150]] + # recover the length and do a sanity check: we expect + # the device schedules to be the same fixed length + schedule_entry_count = len(weekday_state) + if self._schedule_entry_count != schedule_entry_count: + # this should fire only on first weekday scan + if self._schedule_entry_count: + # TODO: mts200b (trace from #369) shows this is possible + # so we'll have to rethink our algorithm + self.log( + self.WARNING, + "unexpected device schedule entries count", + timeout=14400, + ) + else: + self._schedule_entry_count = schedule_entry_count + if self._flatten: + current_entry = None + for entry in weekday_state: + if current_entry and (entry[1] == current_entry[1]): + # same T: flatten out + current_entry[0] = current_entry[0] + entry[0] + else: + current_entry = list(entry) + weekday_schedule.append(current_entry) + else: + # don't flatten..but (deep)copy over + for entry in weekday_state: + weekday_schedule.append(list(entry)) + + self._schedule = schedule + # message handlers def _parse(self, payload: dict): # the payload we receive from the device might be partial # if we're getting the PUSH in realtime since it only carries # the updated entries for the updated day. - if isinstance(self._attr_state, dict): - self._attr_state.update(payload) + native_schedule = self._native_schedule + if native_schedule: + native_schedule.update(payload) + if self._native_schedule == native_schedule: + return else: - self._attr_state = payload - self.extra_state_attributes[self.key_namespace] = str(self._attr_state) - # invalidate our internal representation and flush - self._schedule = None - self._schedule_entry_count = 0 + native_schedule = payload + self.extra_state_attributes[self.key_namespace] = str(native_schedule) + self._build_internal_schedule() self.flush_state() diff --git a/custom_components/meross_lan/meross_device_hub.py b/custom_components/meross_lan/meross_device_hub.py index 78ebd4da..ae72a2d6 100644 --- a/custom_components/meross_lan/meross_device_hub.py +++ b/custom_components/meross_lan/meross_device_hub.py @@ -4,7 +4,7 @@ from . import const as mlc, meross_entity as me from .binary_sensor import MLBinarySensor -from .calendar import MLCalendar +from .calendar import MtsSchedule from .climate import MtsClimate from .helpers.namespaces import ( NamespaceHandler, @@ -221,7 +221,7 @@ class MerossDeviceHub(MerossDevice): DEFAULT_PLATFORMS = MerossDevice.DEFAULT_PLATFORMS | { MLBinarySensor.PLATFORM: None, - MLCalendar.PLATFORM: None, + MtsSchedule.PLATFORM: None, MLConfigNumber.PLATFORM: None, MLNumericSensor.PLATFORM: None, MLSwitch.PLATFORM: None, diff --git a/custom_components/meross_lan/meross_entity.py b/custom_components/meross_lan/meross_entity.py index 49d6bb84..6504734b 100644 --- a/custom_components/meross_lan/meross_entity.py +++ b/custom_components/meross_lan/meross_entity.py @@ -55,7 +55,7 @@ class MerossEntity(Loggable, Entity if typing.TYPE_CHECKING else object): class MyCustomSwitch(MerossEntity, Switch) """ - PLATFORM: str + PLATFORM: ClassVar[str] EntityCategory = EntityCategory