Skip to content

Commit

Permalink
refactor MtsSchedule (calendar) entity and state management
Browse files Browse the repository at this point in the history
  • Loading branch information
krahabb committed Feb 14, 2024
1 parent e894f1e commit e8a630a
Show file tree
Hide file tree
Showing 3 changed files with 91 additions and 85 deletions.
170 changes: 88 additions & 82 deletions custom_components/meross_lan/calendar.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

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

Expand Down Expand Up @@ -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",
Expand All @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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("#")
Expand All @@ -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
Expand Down Expand Up @@ -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()
4 changes: 2 additions & 2 deletions custom_components/meross_lan/meross_device_hub.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion custom_components/meross_lan/meross_entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down

0 comments on commit e8a630a

Please sign in to comment.