"""Irrigation Unlimited Coordinator and sub classes""" # pylint: disable=too-many-lines import weakref from datetime import datetime, time, timedelta, timezone, date from collections import deque from collections.abc import Iterator from types import MappingProxyType from typing import OrderedDict, NamedTuple, Callable, Awaitable from logging import WARNING, Logger, getLogger, INFO, DEBUG, ERROR import uuid import time as tm import json from decimal import Decimal from enum import Enum, Flag, auto import voluptuous as vol from crontab import CronTab from homeassistant.core import ( HomeAssistant, HassJob, CALLBACK_TYPE, DOMAIN as HADOMAIN, Event as HAEvent, split_entity_id, ) from homeassistant.helpers.entity import Entity from homeassistant.helpers.template import Template, render_complex from homeassistant.helpers.event import ( async_track_point_in_utc_time, async_track_state_change_event, ) from homeassistant.helpers import sun from homeassistant.util import dt from homeassistant.helpers import config_validation as cv from homeassistant.const import ( ATTR_ICON, CONF_AFTER, CONF_BEFORE, CONF_DELAY, CONF_ENTITY_ID, CONF_ICON, CONF_NAME, CONF_REPEAT, CONF_STATE, CONF_WEEKDAY, EVENT_HOMEASSISTANT_STOP, SERVICE_TURN_OFF, SERVICE_TURN_ON, SERVICE_CLOSE_VALVE, SERVICE_OPEN_VALVE, SERVICE_CLOSE_COVER, SERVICE_OPEN_COVER, STATE_OFF, STATE_ON, STATE_CLOSED, STATE_OPEN, WEEKDAYS, ATTR_ENTITY_ID, CONF_FOR, CONF_UNTIL, Platform, ) from .history import IUHistory from .const import ( ATTR_ADJUSTED_DURATION, ATTR_ADJUSTMENT, ATTR_BASE_DURATION, ATTR_CURRENT_DURATION, ATTR_DEFAULT_DELAY, ATTR_DEFAULT_DURATION, ATTR_DURATION, ATTR_DURATION_FACTOR, ATTR_ENABLED, ATTR_FINAL_DURATION, ATTR_INDEX, ATTR_NAME, ATTR_SCHEDULE, ATTR_START, ATTR_STATUS, ATTR_SWITCH_ENTITIES, ATTR_TOTAL_DELAY, ATTR_TOTAL_DURATION, ATTR_ZONE_IDS, ATTR_ZONES, ATTR_SUSPENDED, BINARY_SENSOR, CONF_ACTUAL, CONF_ALL_ZONES_CONFIG, CONF_ALLOW_MANUAL, CONF_CLOCK, CONF_CONTROLLER, CONF_ZONE, CONF_MODE, CONF_RENAME_ENTITIES, CONF_ENTITY_BASE, CONF_RUN, CONF_SYNC_SWITCHES, DOMAIN, CONF_DAY, CONF_DECREASE, CONF_FINISH, CONF_INCREASE, CONF_INDEX, CONF_LOGGING, CONF_OUTPUT_EVENTS, CONF_PERCENTAGE, CONF_REFRESH_INTERVAL, CONF_RESET, CONF_RESULTS, CONF_SCHEDULE, CONF_SEQUENCE, CONF_SEQUENCE_ZONES, CONF_SEQUENCES, CONF_SEQUENCE_ID, CONF_SHOW_LOG, CONF_AUTOPLAY, CONF_ANCHOR, CONF_VERSION, COORDINATOR, DEFAULT_GRANULARITY, DEFAULT_REFRESH_INTERVAL, DEFAULT_TEST_SPEED, CONF_DURATION, CONF_ENABLED, CONF_GRANULARITY, CONF_TIME, CONF_SUN, CONF_PREAMBLE, CONF_POSTAMBLE, CONF_TESTING, CONF_SPEED, CONF_TIMES, CONF_START, CONF_END, CONF_CONTROLLERS, CONF_SCHEDULES, CONF_ZONES, CONF_MINIMUM, CONF_MAXIMUM, CONF_MONTH, EVENT_START, EVENT_FINISH, ICON_BLOCKED, ICON_CONTROLLER_OFF, ICON_CONTROLLER_ON, ICON_CONTROLLER_PAUSED, ICON_CONTROLLER_DELAY, ICON_DISABLED, ICON_SUSPENDED, ICON_SEQUENCE_DELAY, ICON_SEQUENCE_PAUSED, ICON_SEQUENCE_ZONE_OFF, ICON_SEQUENCE_ZONE_ON, ICON_ZONE_OFF, ICON_ZONE_ON, ICON_SEQUENCE_OFF, ICON_SEQUENCE_ON, RES_MANUAL, RES_TIMELINE_HISTORY, RES_TIMELINE_NEXT, RES_TIMELINE_RUNNING, RES_TIMELINE_SCHEDULED, TIMELINE_ADJUSTMENT, TIMELINE_END, TIMELINE_SCHEDULE_NAME, TIMELINE_START, MONTHS, CONF_ODD, CONF_EVEN, CONF_SHOW, CONF_CONFIG, CONF_TIMELINE, CONF_CONTROLLER_ID, CONF_ZONE_ID, CONF_FUTURE_SPAN, SERVICE_CANCEL, SERVICE_DISABLE, SERVICE_ENABLE, SERVICE_TOGGLE, SERVICE_MANUAL_RUN, SERVICE_TIME_ADJUST, SERVICE_LOAD_SCHEDULE, SERVICE_SUSPEND, SERVICE_SKIP, SERVICE_PAUSE, SERVICE_RESUME, STATUS_BLOCKED, STATUS_DELAY, STATUS_PAUSED, STATUS_DISABLED, STATUS_SUSPENDED, STATUS_INITIALISING, TIMELINE_STATUS, CONF_FIXED, CONF_MAX_LOG_ENTRIES, DEFAULT_MAX_LOG_ENTRIES, CONF_CRON, CONF_EVERY_N_DAYS, CONF_START_N_DAYS, CONF_CHECK_BACK, CONF_STATES, CONF_RETRIES, CONF_RESYNC, EVENT_SYNC_ERROR, EVENT_SWITCH_ERROR, CONF_FOUND, CONF_EXPECTED, CONF_SCHEDULE_ID, CONF_FROM, CONF_VOLUME, CONF_VOLUME_PRECISION, CONF_VOLUME_SCALE, CONF_FLOW_RATE_PRECISION, CONF_FLOW_RATE_SCALE, CONF_QUEUE, CONF_QUEUE_MANUAL, CONF_USER, CONF_TOGGLE, CONF_EXTENDED_CONFIG, ) _LOGGER: Logger = getLogger(__package__) # Useful time manipulation routine def time_to_timedelta(offset: time) -> timedelta: """Create a timedelta from a time object""" return datetime.combine(datetime.min, offset) - datetime.min def dt2lstr(stime: datetime) -> str: """Format the passed UTC datetime into a local date time string. The result is formatted to 24 hour time notation (YYYY-MM-DD HH:MM:SS). Example 2021-12-25 17:00:00 for 5pm on December 25 2021.""" return datetime.strftime(dt.as_local(stime), "%Y-%m-%d %H:%M:%S") def to_secs(atime: timedelta) -> int: """Convert the supplied time to whole seconds""" return round(atime.total_seconds()) # These routines truncate dates, times and deltas to the internal # granularity. This should be no more than 1 minute and realistically # no less than 1 second i.e. 1 >= GRANULARITY <= 60 # The current boundaries are whole minutes (60 seconds). SYSTEM_GRANULARITY: int = DEFAULT_GRANULARITY # Granularity in seconds def reset_granularity() -> None: """Restore the original granularity""" global SYSTEM_GRANULARITY # pylint: disable=global-statement SYSTEM_GRANULARITY = DEFAULT_GRANULARITY def granularity_time() -> timedelta: """Return the system granularity as a timedelta""" return timedelta(seconds=SYSTEM_GRANULARITY) def wash_td(delta: timedelta, granularity: int = None) -> timedelta: """Truncate the supplied timedelta to internal boundaries""" if delta is not None: if granularity is None: granularity = SYSTEM_GRANULARITY whole_seconds = int(delta.total_seconds()) rounded_seconds = int(whole_seconds / granularity) * granularity return timedelta(seconds=rounded_seconds) return None def wash_dt(value: datetime, granularity: int = None) -> datetime: """Truncate the supplied datetime to internal boundaries""" if value is not None: if granularity is None: granularity = SYSTEM_GRANULARITY rounded_seconds = int(value.second / granularity) * granularity return value.replace(second=rounded_seconds, microsecond=0) return None def wash_t(stime: time, granularity: int = None) -> time: """Truncate the supplied time to internal boundaries""" if stime is not None: if granularity is None: granularity = SYSTEM_GRANULARITY utc = dt.utcnow() full_date = utc.combine(utc.date(), stime) rounded_seconds = int(full_date.second / granularity) * granularity return full_date.replace(second=rounded_seconds, microsecond=0).timetz() return None def round_dt(stime: datetime, granularity: int = None) -> datetime: """Round the supplied datetime to internal boundaries""" if stime is not None: base_time = wash_dt(stime, granularity) return base_time + round_td(stime - base_time, granularity) return None def round_td(delta: timedelta, granularity: int = None) -> timedelta: """Round the supplied timedelta to internal boundaries""" if delta is not None: if granularity is None: granularity = SYSTEM_GRANULARITY rounded_seconds = ( int((delta.total_seconds() + granularity / 2) / granularity) * granularity ) return timedelta(seconds=rounded_seconds) return None def str_to_td(atime: str) -> timedelta: """Convert a string in 0:00:00 format to timedelta""" dur = datetime.strptime(atime, "%H:%M:%S") return timedelta(hours=dur.hour, minutes=dur.minute, seconds=dur.second) def utc_eot() -> datetime: """Return the end of time in UTC format""" return datetime.max.replace(tzinfo=timezone.utc) def render_positive_time_period(data: dict, key: str) -> None: """Resolve a template that specifies a timedelta""" if key in data: schema = vol.Schema({key: cv.positive_time_period}) rendered = render_complex(data[key]) data[key] = schema({key: rendered})[key] def render_positive_float(hass: HomeAssistant, data: dict, key: str) -> None: """Resolve a template that specifies a float""" if isinstance(data.get(key), Template): template: Template = data[key] template.hass = hass schema = vol.Schema({key: cv.positive_float}) data[key] = schema({key: template.async_render()})[key] def check_item(index: int, items: list[int] | None) -> bool: """If items is None or contains only a 0 (match all) then return True. Otherwise check if index + 1 is in the list""" return items is None or (items is not None and items == [0]) or index + 1 in items def s2b(test: bool, service: str) -> bool: """Convert the service code to bool""" if service == SERVICE_ENABLE: return True if service == SERVICE_DISABLE: return False return not test # Must be SERVICE_TOGGLE def suspend_until_date( data: MappingProxyType, stime: datetime, ) -> datetime: """Determine the suspend date and time""" if CONF_UNTIL in data: suspend_time = dt.as_utc(data[CONF_UNTIL]) elif CONF_FOR in data: suspend_time = stime + data[CONF_FOR] else: suspend_time = None return wash_dt(suspend_time) class IUJSONEncoder(json.JSONEncoder): """JSON serialiser to handle ISO datetime output""" def default(self, o): if isinstance(o, datetime): return dt.as_local(o).isoformat() if isinstance(o, timedelta): return round(o.total_seconds()) return str(o) class IUBase: """Irrigation Unlimited base class""" def __init__(self, index: int) -> None: # Private variables self._uid: int = uuid.uuid4().int self._index: int = index def __eq__(self, other) -> bool: return isinstance(other, IUBase) and self._uid == other._uid def __hash__(self) -> int: return self._uid @property def uid(self) -> int: """Return the unique id""" return self._uid @property def index(self) -> int: """Return position within siblings""" return self._index @property def id1(self) -> int: """Return the 1's based index""" return self._index + 1 @staticmethod def ids(obj: "IUBase", default: str = "", offset: int = 0) -> str: """Return the index as a str""" if isinstance(obj, IUBase): return str(obj.index + offset) return default @staticmethod def idl(obj: "list[IUBase]", default: str = "", offset: int = 0) -> "list[str]": """Return a list of indexes""" result = [] for item in obj: result.append(IUBase.ids(item, default, offset)) return result class IUAdjustment: """Irrigation Unlimited class to handle run time adjustment""" def __init__(self, adjustment: str = None) -> None: self._method: str = None self._time_adjustment = None self._minimum: timedelta = None self._maximum: timedelta = None if ( adjustment is not None and isinstance(adjustment, str) and len(adjustment) >= 2 ): method = adjustment[0] adj = adjustment[1:] if method == "=": self._method = CONF_ACTUAL self._time_adjustment = wash_td(str_to_td(adj)) elif method == "%": self._method = CONF_PERCENTAGE self._time_adjustment = float(adj) elif method == "+": self._method = CONF_INCREASE self._time_adjustment = wash_td(str_to_td(adj)) elif method == "-": self._method = CONF_DECREASE self._time_adjustment = wash_td(str_to_td(adj)) def __str__(self) -> str: """Return the adjustment as a string notation""" if self._method == CONF_ACTUAL: return f"={self._time_adjustment}" if self._method == CONF_PERCENTAGE: return f"%{self._time_adjustment}" if self._method == CONF_INCREASE: return f"+{self._time_adjustment}" if self._method == CONF_DECREASE: return f"-{self._time_adjustment}" return "" @property def has_adjustment(self) -> bool: """Return True if an adjustment is in play""" return self._method is not None def load(self, data: MappingProxyType) -> bool: """Read the adjustment configuration. Return true if anything has changed""" # Save current settings old_method = self._method old_time_adjustment = self._time_adjustment old_minimum = self._minimum old_maximum = self._maximum if CONF_ACTUAL in data: self._method = CONF_ACTUAL self._time_adjustment = wash_td(data.get(CONF_ACTUAL)) elif CONF_PERCENTAGE in data: self._method = CONF_PERCENTAGE self._time_adjustment = data.get(CONF_PERCENTAGE) elif CONF_INCREASE in data: self._method = CONF_INCREASE self._time_adjustment = wash_td(data.get(CONF_INCREASE)) elif CONF_DECREASE in data: self._method = CONF_DECREASE self._time_adjustment = wash_td(data.get(CONF_DECREASE)) elif CONF_RESET in data: self._method = None self._time_adjustment = None self._minimum = None self._maximum = None self._minimum = wash_td(data.get(CONF_MINIMUM, None)) if self._minimum is not None: self._minimum = max(self._minimum, granularity_time()) # Set floor self._maximum = wash_td(data.get(CONF_MAXIMUM, None)) return ( self._method != old_method or self._time_adjustment != old_time_adjustment or self._minimum != old_minimum or self._maximum != old_maximum ) def adjust(self, stime: timedelta) -> timedelta: """Return the adjusted time""" new_time: timedelta if self._method is None: new_time = stime elif self._method == CONF_ACTUAL: new_time = self._time_adjustment elif self._method == CONF_PERCENTAGE: new_time = round_td(stime * self._time_adjustment / 100) elif self._method == CONF_INCREASE: new_time = stime + self._time_adjustment elif self._method == CONF_DECREASE: new_time = stime - self._time_adjustment else: new_time = stime if self._minimum is not None: new_time = max(new_time, self._minimum) if self._maximum is not None: new_time = min(new_time, self._maximum) return new_time def to_str(self) -> str: """Return this adjustment as string or None if empty""" if self._method is not None: return str(self) return "None" def to_dict(self) -> dict: """Return this adjustment as a dict""" result = {} if self._method is not None: result[self._method] = self._time_adjustment if self._minimum is not None: result[CONF_MINIMUM] = self._minimum if self._maximum is not None: result[CONF_MAXIMUM] = self._maximum return result class IURQStatus(Flag): """Define the return status of the run queues""" NONE = 0 CLEARED = auto() EXTENDED = auto() REDUCED = auto() SORTED = auto() UPDATED = auto() CANCELED = auto() CHANGED = auto() def is_empty(self) -> bool: """Return True if there are no flags set""" return self.value == 0 def has_any(self, other: "IURQStatus") -> bool: """Return True if the intersect is not empty""" return other.value & self.value != 0 class IUUser(dict): """Class to hold arbitrary static user information""" def load(self, config: OrderedDict, all_zones: OrderedDict): """Load info data from the configuration""" def load_params(config: OrderedDict) -> None: if config is None: return for key, data in config.items(): self[f"{CONF_USER}_{key}"] = data self.clear() if all_zones is not None: load_params(all_zones.get(CONF_USER)) load_params(config.get(CONF_USER)) class IUSchedule(IUBase): """Irrigation Unlimited Schedule class. Schedules are not actual points in time but describe a future event i.e. next Monday at sunrise""" # pylint: disable=too-many-instance-attributes def __init__( self, hass: HomeAssistant, coordinator: "IUCoordinator", schedule_index: int, ) -> None: super().__init__(schedule_index) # Passed parameters self._hass = hass self._coordinator = coordinator # Config parameters self._schedule_id: str = None self._time = None self._duration: timedelta = None self._name: str = None self._weekdays: list[int] = None self._months: list[int] = None self._days = None self._anchor: str = None self._from: date = None self._until: date = None self._enabled = True # Private variables @property def schedule_id(self) -> str: """Return the unique id for this schedule""" return self._schedule_id @property def name(self) -> str: """Return the friendly name of this schedule""" return self._name @property def is_setup(self) -> bool: """Return True if this schedule is setup""" return True @property def duration(self) -> timedelta: """Return the duration""" return self._duration @property def enabled(self) -> bool: """Return enabled status""" return self._enabled def load(self, config: OrderedDict, update: bool = False) -> "IUSchedule": """Load schedule data from config""" if not update: self._schedule_id = None self._time = None self._duration = None self._name = f"Schedule {self.index + 1}" self._weekdays = None self._months = None self._days = None self._anchor = None self._from = None self._until = None self._schedule_id = config.get(CONF_SCHEDULE_ID, self.schedule_id) self._time = config.get(CONF_TIME, self._time) self._anchor = config.get(CONF_ANCHOR, self._anchor) self._duration = wash_td(config.get(CONF_DURATION, self._duration)) self._name = config.get(CONF_NAME, self._name) self._enabled = config.get(CONF_ENABLED, self._enabled) if CONF_WEEKDAY in config: self._weekdays = [] for i in config[CONF_WEEKDAY]: self._weekdays.append(WEEKDAYS.index(i)) if CONF_MONTH in config: self._months = [] for i in config[CONF_MONTH]: self._months.append(MONTHS.index(i) + 1) self._days = config.get(CONF_DAY, self._days) self._from = config.get(CONF_FROM, self._from) self._until = config.get(CONF_UNTIL, self._until) return self def as_dict(self) -> OrderedDict: """Return the schedule as a dict""" result = OrderedDict() result[CONF_TIME] = self._time result[CONF_ANCHOR] = self._anchor result[CONF_DURATION] = self._duration result[CONF_NAME] = self._name result[CONF_ENABLED] = self._enabled if self._weekdays is not None: result[CONF_WEEKDAY] = [WEEKDAYS[d] for d in self._weekdays] if self._months is not None: result[CONF_MONTH] = [MONTHS[m - 1] for m in self._months] if self._days is not None: result[CONF_DAY] = self._days return result def get_next_run( self, stime: datetime, ftime: datetime, adjusted_duration: timedelta, is_running: bool, ) -> datetime: # pylint: disable=too-many-branches # pylint: disable=too-many-statements # pylint: disable=too-many-locals """ Determine the next start time. Date processing in this routine is done in local time and returned as UTC. stime is the current time and ftime is the farthest time we are interested in. adjusted_duration is the total time this schedule will run for, used to work backwards when the anchor is finish """ local_time = dt.as_local(stime) final_time = dt.as_local(ftime) current_time: datetime = None next_run: datetime = None advancement = timedelta(days=1) while True: if current_time is None: # Initialise on first pass current_time = local_time else: current_time += advancement # Advance to next day next_run = current_time # Sanity check. Note: Astral events like sunrise might be months # away i.e. Antarctica winter if next_run > final_time: return None # DOW filter if self._weekdays is not None and next_run.weekday() not in self._weekdays: continue # Month filter if self._months is not None and next_run.month not in self._months: continue # Day filter if self._days is not None: if self._days == CONF_ODD: if next_run.day % 2 == 0: continue elif self._days == CONF_EVEN: if next_run.day % 2 != 0: continue elif isinstance(self._days, dict) and CONF_EVERY_N_DAYS in self._days: n_days: int = self._days[CONF_EVERY_N_DAYS] start_date: date = self._days[CONF_START_N_DAYS] if (next_run.date() - start_date).days % n_days != 0: continue elif next_run.day not in self._days: continue # From/Until filter if self._from is not None and self._until is not None: dts = datetime.combine( self._from.replace(year=next_run.year), datetime.min.time(), next_run.tzinfo, ) dte = datetime.combine( self._until.replace(year=next_run.year), datetime.max.time(), next_run.tzinfo, ) if dte < dts: if next_run >= dts: dte = dte.replace(year=dte.year + 1) else: dts = dts.replace(year=dts.year - 1) if not dts <= next_run <= dte: continue # Adjust time component if isinstance(self._time, time): next_run = datetime.combine( next_run.date(), self._time, next_run.tzinfo ) elif isinstance(self._time, dict) and CONF_SUN in self._time: sun_event = sun.get_astral_event_date( self._hass, self._time[CONF_SUN], next_run ) if sun_event is None: continue # Astral event did not occur today next_run = dt.as_local(sun_event) if CONF_AFTER in self._time: next_run += self._time[CONF_AFTER] if CONF_BEFORE in self._time: next_run -= self._time[CONF_BEFORE] elif isinstance(self._time, dict) and CONF_CRON in self._time: try: cron = CronTab(self._time[CONF_CRON]) cron_event = cron.next( now=local_time, return_datetime=True, default_utc=True ) if cron_event is None: return None next_run = dt.as_local(cron_event) except ValueError as error: self._coordinator.logger.log_invalid_crontab( stime, self, self._time[CONF_CRON], error ) self._enabled = False # Shutdown this schedule return None else: # Some weird error happened return None if self._anchor == CONF_FINISH: next_run -= adjusted_duration next_run = wash_dt(next_run) if (is_running and next_run > local_time) or ( not is_running and next_run + adjusted_duration > local_time ): break return dt.as_utc(next_run) class IUSwitch: """Manager for the phsical switch entity""" # pylint: disable=too-many-instance-attributes def __init__( self, hass: HomeAssistant, coordinator: "IUCoordinator", controller: "IUController", zone: "IUZone", ) -> None: # Passed paramaters self._hass = hass self._coordinator = coordinator self._controller = controller self._zone = zone # Config parameters self._switch_entity_id: list[str] self._check_back_states = "all" self._check_back_delay = timedelta(seconds=30) self._check_back_retries: int = 3 self._check_back_resync: bool = True self._check_back_entity_id: str = None self._check_back_toggle: bool = False # private variables self._state: bool = None # This parameter should mirror IUZone._is_on self._check_back_time: timedelta = None self._check_back_resync_count: int = 0 @property def switch_entity_id(self) -> list[str] | None: """Return the switch entity""" return self._switch_entity_id def _state_name(self, entity_id: str, state: bool) -> str: domain = split_entity_id(entity_id)[0] match domain: case Platform.VALVE | Platform.COVER: return STATE_OPEN if state else STATE_CLOSED case _: return STATE_ON if state else STATE_OFF def _set_switch(self, entity_id: str | list[str], state: bool) -> None: """Make the HA call to physically turn the switch on/off""" def make_call(entity: str) -> None: domain = split_entity_id(entity)[0] match domain: case Platform.VALVE: service = SERVICE_OPEN_VALVE if state else SERVICE_CLOSE_VALVE case Platform.COVER: service = SERVICE_OPEN_COVER if state else SERVICE_CLOSE_COVER case _: domain = HADOMAIN service = SERVICE_TURN_ON if state else SERVICE_TURN_OFF self._hass.async_create_task( self._hass.services.async_call( domain, service, {ATTR_ENTITY_ID: entity}, ) ) if isinstance(entity_id, list): for ent in entity_id: make_call(ent) else: make_call(entity_id) def _check_back(self, atime: datetime) -> None: """Recheck the switch in HA to see if state concurs""" if ( self._check_back_resync_count >= self._check_back_retries or not self._check_back_resync ): if entities := self.check_switch(atime, False, False): for entity in entities: state = self._hass.states.get(entity) found = state.state if state else None expected = self._state_name(entity, self._state) self._coordinator.logger.log_switch_error( atime, expected, found, entity ) self._coordinator.notify_switch( EVENT_SWITCH_ERROR, found, expected, entity, self._controller, self._zone, ) self._check_back_time = None else: if entities := self.check_switch(atime, self._check_back_resync, True): self._check_back_resync_count += 1 self._check_back_time = atime + self._check_back_delay else: self._check_back_time = None def next_event(self) -> datetime: """Return the next time of interest""" if self._check_back_time is not None: return self._check_back_time return utc_eot() def clear(self) -> None: """Reset this object""" self._state = False def load(self, config: OrderedDict, all_zones: OrderedDict) -> "IUSwitch": """Load switch data from the configuration""" def load_params(config: OrderedDict) -> None: if config is None: return self._check_back_states = config.get(CONF_STATES, self._check_back_states) self._check_back_retries = config.get( CONF_RETRIES, self._check_back_retries ) self._check_back_resync = config.get(CONF_RESYNC, self._check_back_resync) delay = config.get(CONF_DELAY, self._check_back_delay.total_seconds()) self._check_back_delay = wash_td(timedelta(seconds=delay)) self._check_back_entity_id = config.get( CONF_ENTITY_ID, self._check_back_entity_id ) self._check_back_toggle = config.get(CONF_TOGGLE, self._check_back_toggle) self.clear() self._switch_entity_id = config.get(CONF_ENTITY_ID) if all_zones is not None: load_params(all_zones.get(CONF_CHECK_BACK)) load_params(config.get(CONF_CHECK_BACK)) return self def muster(self, stime: datetime) -> int: """Muster this switch""" if self._check_back_time is not None and stime >= self._check_back_time: self._check_back(stime) def check_switch(self, stime: datetime, resync: bool, log: bool) -> list[str]: """Check the linked entity is in sync. Returns a list of entities that are not in sync""" result: list[str] = [] def _check_entity(entity_id: str, expected: str) -> bool: state = self._hass.states.get(entity_id) found = state.state if state else None is_valid = state is not None and state.state == expected if not is_valid: result.append(entity_id) if log: self._coordinator.logger.log_sync_error( stime, expected, found, entity_id ) self._coordinator.notify_switch( EVENT_SYNC_ERROR, found, expected, entity_id, self._controller, self._zone, ) return is_valid def do_resync(entity_id: str) -> None: if self._check_back_toggle: self._set_switch(entity_id, not self._state) self._set_switch(entity_id, self._state) if self._switch_entity_id is not None: if self._check_back_entity_id is None: for entity_id in self._switch_entity_id: expected = self._state_name(entity_id, self._state) if not _check_entity(entity_id, expected): if resync: do_resync(entity_id) else: expected = self._state_name(self._check_back_entity_id, self._state) if not _check_entity(self._check_back_entity_id, expected): if resync and len(self._switch_entity_id) == 1: do_resync(self._switch_entity_id) return result def call_switch(self, state: bool, stime: datetime = None) -> None: """Turn the HA entity on or off""" # pylint: disable=too-many-boolean-expressions if self._switch_entity_id is not None: if self._check_back_time is not None: # Switch state was changed before the recheck. Check now. self.check_switch(stime, False, True) self._check_back_time = None self._state = state self._set_switch(self._switch_entity_id, state) if stime is not None and ( self._check_back_states == "all" or (self._check_back_states == "on" and state) or (self._check_back_states == "off" and not state) ): self._check_back_resync_count = 0 self._check_back_time = stime + self._check_back_delay class IUVolumeSensorError(Exception): """Error reading sensor""" class IUVolumeSensorReading(NamedTuple): """Irrigation Unlimited volume sensor reading class""" timestamp: datetime value: Decimal class IUVolume: """Irrigation Unlimited Volume class""" # pylint: disable=too-many-instance-attributes MAX_READINGS = 20 SMA_WINDOW = 10 listeners: int = 0 trackers: int = 0 def __init__( self, hass: HomeAssistant, coordinator: "IUCoordinator", zone: "IUZone" ) -> None: # Passed parameters self._hass = hass self._coordinator = coordinator self._zone = zone # Config parameters self._sensor_id: str = None self._volume_precision: int = 3 self._volume_scale: float = 1 self._flow_rate_precision: int = 3 self._flow_rate_scale: float = 3600 # Private variables self._callback_remove: CALLBACK_TYPE = None self._start_volume: Decimal = None self._total_volume: Decimal = None self._start_time: datetime = None self._end_time: datetime = None self._listeners: dict[ str, Callable[[datetime, "IUZone", Decimal, Decimal], None] ] = {} self._flow_rates: list[Decimal] = [] self._flow_rate_sum = Decimal(0) self._flow_rate_sma: Decimal = None self._sensor_readings: list[IUVolumeSensorReading] = [] @property def total(self) -> float | None: """Return the total value""" if self._total_volume is not None: return float(self._total_volume) return None @property def flow_rate(self) -> float | None: """Return the flow rate""" if self._flow_rate_sma is not None: return float(self._flow_rate_sma) return None def load(self, config: OrderedDict, all_zones: OrderedDict) -> "IUSwitch": """Load volume data from the configuration""" def load_params(config: OrderedDict) -> None: if config is None: return self._sensor_id = config.get(CONF_ENTITY_ID, self._sensor_id) self._volume_precision = config.get( CONF_VOLUME_PRECISION, self._volume_precision ) self._volume_scale = config.get(CONF_VOLUME_SCALE, self._volume_scale) self._flow_rate_precision = config.get( CONF_FLOW_RATE_PRECISION, self._flow_rate_precision ) self._flow_rate_scale = config.get( CONF_FLOW_RATE_SCALE, self._flow_rate_scale ) if all_zones is not None: load_params(all_zones.get(CONF_VOLUME)) load_params(config.get(CONF_VOLUME)) def read_sensor(self, stime: datetime) -> Decimal: """Read the sensor data""" sensor = self._hass.states.get(self._sensor_id) if sensor is None: raise IUVolumeSensorError(f"Sensor not found: {self._sensor_id}") value = float(sensor.state) if value < 0: raise ValueError(f"Negative sensor value: {sensor.state}") volume = Decimal(f"{value * self._volume_scale:.{self._volume_precision}f}") if len(self._sensor_readings) > 0: last_reading = self._sensor_readings[-1] volume_delta = volume - last_reading.value time_delta = Decimal( f"{(stime - last_reading.timestamp).total_seconds():.6f}" ) if time_delta == 0: raise ValueError(f"Sensor time has not advanced: {stime}") if time_delta < 0: raise ValueError( "Sensor time has gone backwards: " f"previous: {last_reading.timestamp}, " f"current: {stime}" ) if volume_delta < 0: raise ValueError( "Sensor value has gone backwards: " f"previous: {last_reading.value}, " f"current: {volume}" ) # Update moving average of the rate. Ignore initial reading. if len(self._sensor_readings) > 1: rate = volume_delta * Decimal(self._flow_rate_scale) / time_delta self._flow_rate_sum += rate self._flow_rates.append(rate) if len(self._flow_rates) > IUVolume.SMA_WINDOW: self._flow_rate_sum -= self._flow_rates.pop(0) self._flow_rate_sma = ( self._flow_rate_sum / len(self._flow_rates) ).quantize(Decimal(10) ** -self._flow_rate_precision) # Update bookkeeping self._sensor_readings.append(IUVolumeSensorReading(stime, volume)) if len(self._sensor_readings) > IUVolume.MAX_READINGS: self._sensor_readings.pop(0) return volume def event_hook(self, event: HAEvent) -> HAEvent: """A pass through place for testing to patch and update parameters in the event message""" return event def start_record(self, stime: datetime) -> None: """Start recording volume information""" def sensor_state_change(event: HAEvent): event = self.event_hook(event) try: value = self.read_sensor(event.time_fired) except ValueError as e: self._coordinator.logger.log_invalid_meter_value(stime, e) except IUVolumeSensorError: self._coordinator.logger.log_invalid_meter_id(stime, self._sensor_id) else: self._total_volume = value - self._start_volume # Notifiy our trackers for listener in list(self._listeners.values()): listener( event.time_fired, self._zone, self._total_volume, self._flow_rate_sma, ) if self._sensor_id is None: return self._start_volume = self._total_volume = None self._start_time = stime self._sensor_readings.clear() self._flow_rates.clear() self._flow_rate_sum = 0 self._flow_rate_sma = None try: self._start_volume = self.read_sensor(stime) except ValueError as e: self._coordinator.logger.log_invalid_meter_value(stime, e) except IUVolumeSensorError: self._coordinator.logger.log_invalid_meter_id(stime, self._sensor_id) else: self._callback_remove = async_track_state_change_event( self._hass, self._sensor_id, sensor_state_change ) IUVolume.trackers += 1 def end_record(self, stime: datetime) -> None: """Finish recording volume information""" self._end_time = stime if self._callback_remove is not None: self._callback_remove() self._callback_remove = None IUVolume.trackers -= 1 def track_volume_change( self, uid: int, action: Callable[[datetime, "IUZone", float, float], None] ) -> CALLBACK_TYPE: """Track the volume""" def remove_listener() -> None: del self._listeners[uid] IUVolume.listeners -= 1 self._listeners[uid] = action IUVolume.listeners += 1 return remove_listener class IURunStatus(Enum): """Flags for the status of IURun object""" UNKNOWN = 0 FUTURE = auto() RUNNING = auto() EXPIRED = auto() PAUSED = auto() @staticmethod def status( stime: datetime, start_time: datetime, end_time: datetime, paused: datetime ) -> "IURunStatus": """Determine the state of this object""" if paused is not None: return IURunStatus.PAUSED if start_time > stime: return IURunStatus.FUTURE if start_time <= stime < end_time: return IURunStatus.RUNNING if stime >= end_time: return IURunStatus.EXPIRED return IURunStatus.UNKNOWN class IURun(IUBase): """Irrigation Unlimited Run class. A run is an actual point in time. If schedule is None then it is a manual run. """ # pylint: disable=too-many-instance-attributes # pylint: disable=too-many-public-methods def __init__( self, stime: datetime, start_time: datetime, duration: timedelta, zone: "IUZone", schedule: "IUSchedule", sequence_run: "IUSequenceRun", zone_run: "IURun", ) -> None: # pylint: disable=too-many-arguments super().__init__(None) # Passed parameters self._start_time: datetime = start_time self._duration: timedelta = duration self._zone = zone self._schedule = schedule self._sequence_run = sequence_run self._zone_run = zone_run # Private variables self._end_time: datetime = self._start_time + self._duration self._remaining_time: timedelta = self._end_time - self._start_time self._percent_complete: int = 0 self._pause_time: datetime = None self._status = self._get_status(stime) self.master_run: "IURun" = None @property def expired(self) -> bool: """Indicate if run has expired""" return self._status == IURunStatus.EXPIRED @property def running(self) -> bool: """Indicate if run is running""" return self._status == IURunStatus.RUNNING @property def future(self) -> bool: """Indicate if run is in the future""" return self._status == IURunStatus.FUTURE @property def paused(self) -> bool: """Indicate if run is paused""" return self._status == IURunStatus.PAUSED @property def start_time(self) -> datetime: """Return the start time""" return self._start_time @start_time.setter def start_time(self, value: datetime) -> None: """Set the start time""" self._start_time = value @property def duration(self) -> timedelta: """Return the duration""" return self._duration @duration.setter def duration(self, value: timedelta) -> None: """Set the duration""" self._duration = value @property def zone(self) -> "IUZone": """Return the zone for this run""" return self._zone @property def schedule(self) -> "IUSchedule": """Return the schedule""" return self._schedule @property def schedule_name(self) -> str: """Return the name of the schedule""" if self._schedule is not None: return self._schedule.name return RES_MANUAL @property def adjustment(self) -> str: """Return the adjustment in play""" if self._schedule is None: return "" if self.sequence_has_adjustment(True): return self.sequence_adjustment() return str(self._zone.adjustment) @property def end_time(self) -> datetime: """Return the finish time""" return self._end_time @end_time.setter def end_time(self, value: datetime) -> None: """Set the end time""" self._end_time = value @property def time_remaining(self) -> timedelta: """Return the amount of time left to run""" return self._remaining_time @property def percent_complete(self) -> float: """Return the percentage completed""" return self._percent_complete @property def is_sequence(self) -> bool: """Return True if this run is a sequence""" return self._sequence_run is not None @property def sequence_run(self) -> "IUSequenceRun": """If a sequence then return the details""" return self._sequence_run @property def sequence(self) -> "IUSequence": """Return the associated sequence""" if self.is_sequence: return self._sequence_run.sequence return None @property def sequence_zone(self) -> "IUSequenceZone": """Return the sequence zone for this run""" if self.is_sequence: if self._zone_run is not None: return self._zone_run.sequence_zone return self._sequence_run.sequence_zone(self) return None @property def sequence_running(self) -> bool: """Return True if this run is a sequence and running""" return self.is_sequence and self._sequence_run.running @property def crumbs(self) -> str: """Return the debugging details for this run""" return self._crumbs() def _crumbs(self) -> str: def get_index(obj: IUBase) -> int: if obj is not None: return obj.index + 1 return 0 if self.is_sequence: sidx = self.sequence_run.run_index(self) + 1 else: sidx = 0 return ( f"{get_index(self._zone)}" f".{get_index(self._schedule)}" f".{get_index(self.sequence)}" f".{get_index(self.sequence_zone)}" f".{sidx}" ) def sequence_has_adjustment(self, deep: bool) -> bool: """Return True if this run is a sequence and has an adjustment""" if self.is_sequence: return self.sequence.has_adjustment(deep) return False def sequence_adjustment(self) -> str: """Return the adjustment for the sequence""" if self.is_sequence: result = str(self._sequence_run.sequence.adjustment) sequence_zone = self._sequence_run.sequence_zone(self) if sequence_zone is not None and sequence_zone.has_adjustment: result = f"{result},{str(sequence_zone.adjustment)}" return result return None def is_manual(self) -> bool: """Check if this is a manual run""" return self._schedule is None def _get_status(self, stime: datetime) -> IURunStatus: """Determine the state of this run""" return IURunStatus.status( stime, self._start_time, self._end_time, self._pause_time ) def update_status(self, stime: datetime) -> None: """Update the status of the run""" self._status = self._get_status(stime) def update_time_remaining(self, stime: datetime) -> bool: """Update the count down timers""" if self.running: self._remaining_time = self._end_time - stime total_duration: timedelta = self._end_time - self._start_time time_elapsed: timedelta = stime - self._start_time self._percent_complete = int((time_elapsed / total_duration) * 100) return True return False def pause(self, stime: datetime) -> None: """Change the pause status of the run""" if self._pause_time is not None: return self._pause_time = stime self.update_status(stime) def resume(self, stime: datetime) -> None: """Resume a paused run""" if self._pause_time is None: return delta = stime - self._pause_time self._start_time += delta self._end_time += delta self._pause_time = None self.update_status(stime) def as_dict(self) -> OrderedDict: """Return this run as a dict""" result = OrderedDict() result[TIMELINE_START] = self._start_time result[TIMELINE_END] = self._end_time result[TIMELINE_SCHEDULE_NAME] = self.schedule_name result[TIMELINE_ADJUSTMENT] = self.adjustment return result def calc_on_time(runs: list[IURun]) -> timedelta: """Return the total time this list of runs is on. Accounts for overlapping time periods""" result = timedelta(0) period_start: datetime = None period_end: datetime = None for run in runs: if period_end is None or run.start_time > period_end: if period_end is not None: result += period_end - period_start period_start = run.start_time period_end = run.end_time else: period_end = max(period_end, run.end_time) if period_end is not None: result += period_end - period_start return result class IURunQueue(list[IURun]): """Irrigation Unlimited class to hold the upcoming runs""" # pylint: disable=too-many-public-methods DAYS_SPAN: int = 3 def __init__(self) -> None: super().__init__() # Config parameters self._future_span = wash_td(timedelta(days=self.DAYS_SPAN)) # Private variables self._current_run: IURun = None self._next_run: IURun = None self._sorted: bool = False self._cancel_request: datetime = None self._next_event: datetime = None @property def current_run(self) -> IURun: """Return the current run""" return self._current_run @property def next_run(self) -> IURun: """Return the next run""" return self._next_run @property def in_sequence(self) -> bool: """Return True if this run is part of a sequence""" for run in self: if run.sequence_running: return True return False def add( self, stime: datetime, start_time: datetime, duration: timedelta, zone: "IUZone", schedule: "IUSchedule", sequence_run: "IUSequenceRun", zone_run: IURun, ) -> IURun: # pylint: disable=too-many-arguments """Add a run to the queue""" run = IURun(stime, start_time, duration, zone, schedule, sequence_run, zone_run) self.append(run) self._sorted = False return run def cancel(self, stime: datetime) -> None: """Flag the current run to be cancelled""" self._cancel_request = stime def clear_all(self) -> None: """Clear out all runs""" self._current_run = None self._next_run = None super().clear() def clear_runs(self, include_sequence: bool) -> bool: """Clear out the queue except for manual and running schedules""" modified = False i = len(self) - 1 while i >= 0: run = self[i] if (include_sequence or not run.is_sequence) and not ( run.running or run.is_manual() ): self.pop_run(i) modified = True i -= 1 if modified: self._next_run = None return modified def find_last_index(self, schedule: IUSchedule) -> int: """Return the index of the run that finishes last in the queue. This routine does not require the list to be sorted.""" result: int = None last_time: datetime = None for i, run in enumerate(self): if run.schedule is not None and run.schedule == schedule: if last_time is None or run.end_time > last_time: last_time = run.end_time result = i return result def find_last_run(self, schedule: IUSchedule) -> IURun: """Find the last run for the matching schedule""" i = self.find_last_index(schedule) if i is not None: return self[i] return None def last_time(self, stime: datetime) -> datetime: """Return the further most look ahead date""" return stime + self._future_span def load(self, config: OrderedDict, all_zones: OrderedDict): """Load the config settings""" fsd = IURunQueue.DAYS_SPAN if all_zones is not None: fsd = all_zones.get(CONF_FUTURE_SPAN, fsd) fsd = max(config.get(CONF_FUTURE_SPAN, fsd), 1) self._future_span = wash_td(timedelta(days=fsd)) return self def sort(self) -> bool: """Sort the run queue.""" def sorter(run: IURun) -> datetime: """Sort call back routine. Items are sorted by start_time.""" if run.is_manual(): start = datetime.min # Always put manual run at head else: start = run.start_time return start.replace(tzinfo=None).isoformat(timespec="seconds") modified: bool = False if not self._sorted: super().sort(key=sorter) self._current_run = None self._next_run = None self._sorted = True modified = True return modified def remove_expired(self, stime: datetime, postamble: timedelta) -> bool: """Remove any expired runs from the queue""" modified: bool = False if postamble is None: postamble = timedelta(0) i = len(self) - 1 while i >= 0: run = self[i] if not run.is_sequence: if run.expired and stime > run.end_time + postamble: self.pop_run(i) modified = True else: if ( run.sequence_run.expired and run.expired and stime > run.end_time + postamble ): self.pop_run(i) modified = True i -= 1 return modified def remove_current(self) -> bool: """Remove the current run or upcoming manual run""" modified: bool = False if self._current_run is not None or ( self._next_run is not None and self._next_run.is_manual() ): if len(self) > 0: self.pop_run(0) self._current_run = None self._next_run = None modified = True return modified def pop_run(self, index) -> "IURun": """Remove run from queue by index""" run = self.pop(index) if run == self._current_run: self._current_run = None self._next_run = None if run == self._next_run: self._next_run = None if run.master_run is not None: run.zone.controller.runs.remove_run(run.master_run) run.master_run = None return run def remove_run(self, run: IURun) -> "IURun": """Remove the run from the queue""" return self.pop_run(self.index(run)) def update_run_status(self, stime) -> None: """Update the status of the runs""" for run in self: run.update_status(stime) def update_queue(self) -> IURQStatus: """Update the run queue. Sort the queue, remove expired runs and set current and next runs. This is the final operation after all additions and deletions. Returns a bit field of changes to queue. """ # pylint: disable=too-many-branches status = IURQStatus(0) if self.sort(): status |= IURQStatus.SORTED if self._cancel_request is not None: if self.remove_current(): status |= IURQStatus.CANCELED self._cancel_request = None # Try to find a running schedule if self._current_run is not None and self._current_run.expired: self._current_run = None status |= IURQStatus.UPDATED if self._current_run is None: for run in self: if run.running and run.duration != timedelta(0): self._current_run = run self._next_run = None status |= IURQStatus.UPDATED break # Try to find the next schedule if self._next_run is not None and self._next_run.expired: self._next_run = None status |= IURQStatus.UPDATED if self._next_run is None: for run in self: if run.future and run.duration != timedelta(0): self._next_run = run status |= IURQStatus.UPDATED break # Figure out the next state change dates: list[datetime] = [utc_eot()] for run in self: if not (run.expired or run.paused): if run.running: dates.append(run.end_time) else: dates.append(run.start_time) self._next_event = min(dates) return status def update_sensor(self, stime: datetime) -> bool: """Update the count down timers""" if self._current_run is None: return False return self._current_run.update_time_remaining(stime) def next_event(self) -> datetime: """Return the time of the next state change""" dates: list[datetime] = [self._next_event] dates.append(self._cancel_request) return min(d for d in dates if d is not None) def as_list(self) -> list: """Return a list of runs""" result = [] for run in self: result.append(run.as_dict()) return result class IUScheduleQueue(IURunQueue): """Class to hold the upcoming schedules to run""" def __init__(self) -> None: super().__init__() # Config variables self._minimum: timedelta = None self._maximum: timedelta = None def constrain(self, duration: timedelta) -> timedelta: """Impose constraints on the duration""" if self._minimum is not None: duration = max(duration, self._minimum) if self._maximum is not None: duration = min(duration, self._maximum) return duration def clear_runs(self) -> bool: """Clear out the queue except for manual and running schedules""" # pylint: disable=arguments-differ return super().clear_runs(False) def add( self, stime: datetime, start_time: datetime, duration: timedelta, zone: "IUZone", schedule: "IUSchedule", sequence_run: "IUSequenceRun", ) -> IURun: """Add a new run to the queue""" # pylint: disable=arguments-differ # pylint: disable=too-many-arguments return super().add( stime, start_time, self.constrain(duration), zone, schedule, sequence_run, None, ) def add_manual( self, stime: datetime, start_time: datetime, duration: timedelta, zone: "IUZone", queue: bool, ) -> IURun: """Add a manual run to the queue. Cancel any existing manual or running schedule""" if self._current_run is not None: self.pop_run(0) # Remove any existing manual schedules if not queue: for manual in (run for run in self if run.is_manual()): self.remove_run(manual) self._current_run = None self._next_run = None duration = max(duration, granularity_time()) return self.add(stime, start_time, duration, zone, None, None) def merge_one( self, stime: datetime, zone: "IUZone", schedule: IUSchedule, adjustment: IUAdjustment, ) -> bool: """Extend the run queue. Return True if it was extended""" modified: bool = False # See if schedule already exists in run queue. If so get # the finish time of the last entry. last_run = self.find_last_run(schedule) if last_run is not None: next_time = last_run.end_time + granularity_time() is_running = last_run.running else: next_time = stime is_running = False duration = self.constrain(adjustment.adjust(schedule.duration)) next_run = schedule.get_next_run( next_time, self.last_time(stime), duration, is_running ) if next_run is not None: self.add(stime, next_run, duration, zone, schedule, None) modified = True return modified def merge_fill( self, stime: datetime, zone: "IUZone", schedule: IUSchedule, adjustment: IUAdjustment, ) -> bool: """Merge the schedule into the run queue. Add as many until the span is reached. Return True if the schedule was added.""" modified: bool = False while self.merge_one(stime, zone, schedule, adjustment): modified = True return modified def load(self, config: OrderedDict, all_zones: OrderedDict) -> "IUScheduleQueue": """Load the configuration settings""" self._minimum = None self._maximum = None super().load(config, all_zones) if all_zones is not None: self._minimum = wash_td(all_zones.get(CONF_MINIMUM, self._minimum)) self._maximum = wash_td(all_zones.get(CONF_MAXIMUM, self._maximum)) self._minimum = wash_td(config.get(CONF_MINIMUM, self._minimum)) self._maximum = wash_td(config.get(CONF_MAXIMUM, self._maximum)) return self class IUZone(IUBase): """Irrigation Unlimited Zone class""" # pylint: disable=too-many-instance-attributes # pylint: disable=too-many-public-methods def __init__( self, hass: HomeAssistant, coordinator: "IUCoordinator", controller: "IUController", zone_index: int, ) -> None: super().__init__(zone_index) # Passed parameters self._hass = hass self._coordinator = coordinator self._controller = controller # Config parameters self._zone_id: str = None self._enabled: bool = True self._allow_manual: bool = False self._name: str = None self._show_config: bool = False self._show_timeline: bool = False self._duration: timedelta = None # Private variables self._initialised: bool = False self._finalised: bool = False self._schedules: list[IUSchedule] = [] self._run_queue = IUScheduleQueue() self._adjustment = IUAdjustment() self._zone_sensor: Entity = None self._is_on: bool = False self._sensor_update_required: bool = False self._sensor_last_update: datetime = None self._suspend_until: datetime = None self._dirty: bool = True self._switch = IUSwitch(hass, coordinator, controller, self) self._volume = IUVolume(hass, coordinator, self) self._user = IUUser() @property def controller(self) -> "IUController": """Return our parent (controller)""" return self._controller @property def unique_id(self) -> str: """Return the HA unique_id for the zone""" return f"c{self._controller.index + 1}_z{self.index + 1}" @property def entity_base(self) -> str: """Return the base of the entity_id""" if self._coordinator.rename_entities: return f"{self._controller.controller_id}_{self._zone_id}" return self.unique_id @property def entity_id(self) -> str: """Return the HA entity_id for the zone""" return f"{BINARY_SENSOR}.{DOMAIN}_{self.entity_base}" @property def schedules(self) -> "list[IUSchedule]": """Return a list of schedules associated with this zone""" return self._schedules @property def runs(self) -> IUScheduleQueue: """Return the run queue for this zone""" return self._run_queue @property def adjustment(self) -> IUAdjustment: """Return the adjustment for this zone""" return self._adjustment @property def volume(self) -> IUVolume: """Return the volume for this zone""" return self._volume @property def zone_id(self) -> str: """Return the zone_id. Should match the zone_id used in sequences""" return self._zone_id @property def name(self) -> str: """Return the friendly name for this zone""" if self._name is not None: return self._name return f"Zone {self._index + 1}" @property def is_on(self) -> bool: """Indicate if zone is on or off""" return self._is_on @property def is_setup(self) -> bool: """Return True if this zone is setup and ready to go""" return self._is_setup() @property def is_enabled(self) -> bool: """Return true is this zone is enabled and not suspended""" return self._enabled and self._suspend_until is None @property def enabled(self) -> bool: """Return true if this zone is enabled""" return self._enabled @enabled.setter def enabled(self, value: bool) -> None: """Enable/disable zone""" if value != self._enabled: self._enabled = value self._dirty = True self.request_update() @property def suspended(self) -> datetime: """Return the suspend date""" return self._suspend_until @suspended.setter def suspended(self, value: datetime) -> None: """Set the suspend time""" if value != self._suspend_until: self._suspend_until = value self._dirty = True self.request_update() @property def allow_manual(self) -> bool: """Return True if manual overide allowed""" return self._allow_manual @property def zone_sensor(self) -> Entity: """Return the HA entity associated with this zone""" return self._zone_sensor @zone_sensor.setter def zone_sensor(self, value: Entity) -> None: self._zone_sensor = value @property def status(self) -> str: """Return the state of the zone""" return self._status() @property def show_config(self) -> bool: """Indicate if the config show be shown as an attribute""" return self._show_config @property def show_timeline(self) -> bool: """Indicate if the timeline (run queue) should be shown as an attribute""" return self._show_timeline @property def today_total(self) -> timedelta: """Return the total on time for today""" return self._coordinator.history.today_total(self.entity_id) @property def icon(self) -> str: """Return the icon to use in the frontend.""" if self._controller.is_enabled: if self.enabled: if self.suspended is None: if self.is_on: return ICON_ZONE_ON return ICON_ZONE_OFF return ICON_SUSPENDED return ICON_DISABLED return ICON_BLOCKED @property def configuration(self) -> str: """Return this zone as JSON""" return json.dumps(self.as_dict(), cls=IUJSONEncoder) @property def user(self) -> dict: """Return the arbitrary user information""" return self._user def _is_setup(self) -> bool: """Check if this object is setup""" self._initialised = self._zone_sensor is not None if self._initialised: for schedule in self._schedules: self._initialised = self._initialised and schedule.is_setup return self._initialised def _status(self) -> str: """Return status of zone""" if self._initialised: if self._controller.is_enabled: if self.enabled: if self.suspended is None: if self.is_on: return STATE_ON return STATE_OFF return STATUS_SUSPENDED return STATUS_DISABLED return STATUS_BLOCKED return STATUS_INITIALISING def service_edt( self, data: MappingProxyType, stime: datetime, service: str ) -> bool: """Handler for enable/disable/toggle service call""" # pylint: disable=unused-argument if service == SERVICE_ENABLE: new_state = True elif service == SERVICE_DISABLE: new_state = False else: new_state = not self.enabled # Must be SERVICE_TOGGLE result = self.enabled != new_state if result: self.enabled = new_state return result def service_suspend(self, data: MappingProxyType, stime: datetime) -> bool: """Handler for the suspend service call""" sequence_id = data.get(CONF_SEQUENCE_ID) if sequence_id is not None: self._coordinator.logger.log_sequence_entity(stime) suspend_time = suspend_until_date(data, stime) if suspend_time != self._suspend_until: self.suspended = suspend_time return True return False def service_adjust_time(self, data: MappingProxyType, stime: datetime) -> bool: """Adjust the scheduled run times. Return true if adjustment changed""" sequence_id = data.get(CONF_SEQUENCE_ID) if sequence_id is not None: self._coordinator.logger.log_sequence_entity(stime) return self._adjustment.load(data) def service_manual_run(self, data: MappingProxyType, stime: datetime) -> None: """Add a manual run.""" if self._allow_manual or (self.is_enabled and self._controller.is_enabled): duration = wash_td(data.get(CONF_TIME)) delay = wash_td(data.get(CONF_DELAY, timedelta(0))) queue = data.get(CONF_QUEUE, self._controller.queue_manual) if duration is None or duration == timedelta(0): duration = self._duration if duration is None: return duration = self._adjustment.adjust(duration) self._run_queue.add_manual( stime, self._controller.manual_run_start(stime, delay, queue), duration, self, queue, ) def service_cancel(self, data: MappingProxyType, stime: datetime) -> None: """Cancel the current running schedule""" # pylint: disable=unused-argument self._run_queue.cancel(stime) def add(self, schedule: IUSchedule) -> IUSchedule: """Add a new schedule to the zone""" self._schedules.append(schedule) return schedule def find_add(self, index: int) -> IUSchedule: """Look for and add if necessary a new schedule""" if index >= len(self._schedules): return self.add(IUSchedule(self._hass, self._coordinator, index)) return self._schedules[index] def clear(self) -> None: """Reset this zone""" self._schedules.clear() self.controller.clear_zones([self]) def load(self, config: OrderedDict, all_zones: OrderedDict) -> "IUZone": """Load zone data from the configuration""" self.clear() if all_zones is not None: self._allow_manual = all_zones.get(CONF_ALLOW_MANUAL, self._allow_manual) self._duration = all_zones.get(CONF_DURATION, self._duration) if CONF_SHOW in all_zones: self._show_config = all_zones[CONF_SHOW].get( CONF_CONFIG, self._show_config ) self._show_timeline = all_zones[CONF_SHOW].get( CONF_TIMELINE, self._show_timeline ) self._zone_id = config.get(CONF_ZONE_ID, str(self.index + 1)) self._enabled = config.get(CONF_ENABLED, self._enabled) self._allow_manual = config.get(CONF_ALLOW_MANUAL, self._allow_manual) self._duration = config.get(CONF_DURATION, self._duration) self._name = config.get(CONF_NAME, None) self._run_queue.load(config, all_zones) if CONF_SHOW in config: self._show_config = config[CONF_SHOW].get(CONF_CONFIG, self._show_config) self._show_timeline = config[CONF_SHOW].get( CONF_TIMELINE, self._show_timeline ) if CONF_SCHEDULES in config: for sidx, schedule_config in enumerate(config[CONF_SCHEDULES]): self.find_add(sidx).load(schedule_config) self._switch.load(config, all_zones) self._volume.load(config, all_zones) self._user.load(config, all_zones) self._dirty = True return self def finalise(self, turn_off: bool) -> None: """Shutdown the zone""" if not self._finalised: if turn_off and self._is_on: self.call_switch(False) self.clear() self._finalised = True def as_dict(self, extended=False) -> OrderedDict: """Return this zone as a dict""" result = OrderedDict() result[CONF_INDEX] = self._index result[CONF_NAME] = self.name result[CONF_STATE] = STATE_ON if self.is_on else STATE_OFF result[CONF_ENABLED] = self.enabled result[ATTR_SUSPENDED] = self.suspended result[ATTR_ADJUSTMENT] = str(self._adjustment) if extended: if self.runs.current_run is not None: current_duration = self.runs.current_run.duration else: current_duration = timedelta(0) result[CONF_ICON] = self.icon result[CONF_ZONE_ID] = self._zone_id result[CONF_ENTITY_BASE] = self.entity_base result[ATTR_STATUS] = self.status result[ATTR_CURRENT_DURATION] = current_duration result[CONF_SCHEDULES] = [sch.as_dict() for sch in self._schedules] result[ATTR_SWITCH_ENTITIES] = self._switch.switch_entity_id return result def timeline(self) -> list: """Return the on/off timeline. Merge history and future and add the status""" run_list = self._coordinator.history.timeline(self.entity_id) for run in run_list: run[TIMELINE_STATUS] = RES_TIMELINE_HISTORY for run in self._run_queue: dct = run.as_dict() if run.running: dct[TIMELINE_STATUS] = RES_TIMELINE_RUNNING elif run == self._run_queue.next_run: dct[TIMELINE_STATUS] = RES_TIMELINE_NEXT else: dct[TIMELINE_STATUS] = RES_TIMELINE_SCHEDULED run_list.append(dct) run_list.reverse() return run_list def muster(self, stime: datetime) -> IURQStatus: """Muster this zone""" # pylint: disable=unused-argument status = IURQStatus(0) if self._dirty: self.controller.clear_zones([self]) status |= IURQStatus.CLEARED if self._suspend_until is not None and stime >= self._suspend_until: self._suspend_until = None status |= IURQStatus.CHANGED self._switch.muster(stime) self._dirty = False return status def muster_schedules(self, stime: datetime) -> IURQStatus: """Calculate run times for this zone""" status = IURQStatus(0) for schedule in self._schedules: if not schedule.enabled: continue if self._run_queue.merge_fill(stime, self, schedule, self._adjustment): status |= IURQStatus.EXTENDED if not status.is_empty(): self.request_update() return status def check_run(self, parent_enabled: bool) -> bool: """Update the run status""" is_running: bool = False state_changed: bool = False is_running = parent_enabled and ( ( self.is_enabled and self._run_queue.current_run is not None and self._run_queue.current_run.running ) or ( self._allow_manual and self._run_queue.current_run is not None and self._run_queue.current_run.is_manual() ) ) state_changed = is_running ^ self._is_on if state_changed: self._is_on = not self._is_on self.request_update() return state_changed def request_update(self) -> None: """Flag the sensor needs an update""" self._sensor_update_required = True def update_sensor(self, stime: datetime, do_on: bool) -> bool: """Lazy sensor updater""" updated: bool = False do_update: bool = False if self._zone_sensor is not None: if do_on is False: updated |= self._run_queue.update_sensor(stime) if not self._is_on: # Force a refresh at midnight for the total_today attribute if ( self._sensor_last_update is not None and dt.as_local(self._sensor_last_update).toordinal() != dt.as_local(stime).toordinal() ): do_update = True do_update |= self._sensor_update_required else: if self._is_on: # If we are running then update sensor according to refresh_interval if self._run_queue.current_run is not None: do_update = ( self._sensor_last_update is None or stime - self._sensor_last_update >= self._coordinator.refresh_interval ) do_update |= self._sensor_update_required else: do_update = False if do_update: self._zone_sensor.schedule_update_ha_state() self._sensor_update_required = False self._sensor_last_update = stime updated = True return updated def next_awakening(self) -> datetime: """Return the next event time""" dates: list[datetime] = [ self._run_queue.next_event(), self._switch.next_event(), ] if self._is_on and self._sensor_last_update is not None: dates.append(self._sensor_last_update + self._coordinator.refresh_interval) dates.append(self._suspend_until) return min(d for d in dates if d is not None) def check_switch(self, resync: bool, stime: datetime) -> list[str]: """Check the linked entity is in sync""" return self._switch.check_switch(stime, resync, True) def call_switch(self, state: bool, stime: datetime = None) -> None: """Turn the HA entity on or off""" self._switch.call_switch(state, stime) self._coordinator.status_changed(stime, self._controller, self, state) class IUZoneQueue(IURunQueue): """Class to hold the upcoming zones to run""" def add_zone( self, stime: datetime, zone_run: IURun, preamble: timedelta, postamble: timedelta, ) -> IURun: """Add a new master run to the queue""" start_time = zone_run.start_time duration = zone_run.duration if preamble is not None: start_time -= preamble duration += preamble if postamble is not None: duration += postamble run = self.add( stime, start_time, duration, zone_run.zone, zone_run.schedule, zone_run.sequence_run, zone_run, ) return run def rebuild_schedule( self, stime: datetime, zones: list[IUZone], preamble: timedelta, postamble: timedelta, ) -> IURQStatus: """Create a superset of all the zones.""" status = IURQStatus(0) for zone in zones: for run in zone.runs: if run.master_run is None: run.master_run = self.add_zone(stime, run, preamble, postamble) status |= IURQStatus.EXTENDED | IURQStatus.REDUCED return status class IUSequenceZone(IUBase): """Irrigation Unlimited Sequence Zone class""" # pylint: disable=too-many-instance-attributes ZONE_OFFSET: int = 1 def __init__( self, controller: "IUController", sequence: "IUSequence", zone_index: int, ) -> None: super().__init__(zone_index) # Passed parameters self._controller = controller self._sequence = sequence # Config parameters self._zone_ids: list[str] = None self._zones: list[IUZone] = [] self._delay: timedelta = None self._duration: timedelta = None self._repeat: int = None self._enabled: bool = True self._volume: float = None # Private variables self._adjustment = IUAdjustment() self._suspend_until: datetime = None @property def zone_ids(self) -> list[str]: """Returns a list of zone_id's""" return self._zone_ids @property def zones(self) -> list[IUZone]: """Return the list of associated zones""" return self._zones @property def duration(self) -> timedelta: """Returns the duration for this sequence""" return self._duration @property def delay(self) -> timedelta: """ "Returns the post delay for this sequence""" return self._delay @property def repeat(self) -> int: """Returns the number of repeats for this sequence""" return self._repeat @property def volume(self) -> float: """Return the volume limit for this sequence""" return self._volume @property def is_enabled(self) -> bool: """Return true if this sequence_zone is enabled and not suspended""" return self._enabled and self._suspend_until is None @property def enabled(self) -> bool: """Return if this sequence zone is enabled""" return self._enabled @enabled.setter def enabled(self, value: bool) -> None: """Set the enabled state""" self._enabled = value @property def suspended(self) -> datetime: """Return the suspend date""" return self._suspend_until @suspended.setter def suspended(self, value: datetime) -> None: self._suspend_until = value @property def adjustment(self) -> IUAdjustment: """Returns the adjustment for this sequence""" return self._adjustment @property def has_adjustment(self) -> bool: """Returns True if this sequence has an active adjustment""" return self._adjustment.has_adjustment @property def is_on(self) -> bool: """Return if this sequence zone is running""" active_zone = self._sequence.runs.active_zone return active_zone is not None and active_zone.sequence_zone == self def icon(self, is_on: bool = None) -> str: """Return the icon to use in the frontend.""" if self._controller.is_enabled: if self._sequence.is_enabled: if self.suspended is None: if self._sequence.zone_enabled(self): if is_on is None: is_on = self.is_on if is_on: return ICON_SEQUENCE_ZONE_ON return ICON_SEQUENCE_ZONE_OFF return ICON_DISABLED return ICON_SUSPENDED return ICON_BLOCKED def status(self, is_on: bool = None) -> str: """Return status of the sequence zone""" if self._controller.is_enabled: if self._sequence.is_enabled: if self.suspended is None: if self._sequence.zone_enabled(self): if is_on is None: is_on = self.is_on if is_on: return STATE_ON return STATE_OFF return STATUS_DISABLED return STATUS_SUSPENDED return STATUS_BLOCKED def load(self, config: OrderedDict) -> "IUSequenceZone": """Load sequence zone data from the configuration""" def build_zones() -> None: """Construct a local list of IUZones""" self._zones.clear() for zone_id in self._zone_ids: if (zone := self._controller.find_zone_by_zone_id(zone_id)) is not None: self._zones.append(zone) self._zone_ids = config[CONF_ZONE_ID] self._delay = wash_td(config.get(CONF_DELAY)) self._duration = wash_td(config.get(CONF_DURATION)) self._repeat = config.get(CONF_REPEAT, 1) self._enabled = config.get(CONF_ENABLED, self._enabled) self._volume = config.get(CONF_VOLUME, self._volume) build_zones() return self def as_dict( self, duration_factor: float, extended=False, sqr: "IUSequenceRun" = None ) -> dict: """Return this sequence zone as a dict""" result = OrderedDict() result[CONF_INDEX] = self._index result[CONF_STATE] = STATE_ON if self.is_on else STATE_OFF result[CONF_ENABLED] = self.enabled result[ATTR_SUSPENDED] = self.suspended result[ATTR_ADJUSTMENT] = str(self._adjustment) if extended: result[CONF_ICON] = self.icon() result[ATTR_STATUS] = self.status() result[CONF_DELAY] = self._sequence.zone_delay(self, sqr) result[ATTR_BASE_DURATION] = self._sequence.zone_duration_base(self, sqr) result[ATTR_ADJUSTED_DURATION] = self._sequence.zone_duration(self, sqr) result[ATTR_FINAL_DURATION] = self._sequence.zone_duration_final( self, duration_factor, sqr ) result[CONF_ZONES] = list( zone.index + self.ZONE_OFFSET for zone in self._zones ) result[ATTR_CURRENT_DURATION] = self._sequence.runs.active_zone_duration( self ) return result def ha_attr(self) -> dict: """Return the HA attributes""" def zone_runs() -> list[IURun]: result: list[IURun] = [] if (sqr := self._sequence.runs.current_run) is None: sqr = self._sequence.runs.next_run if sqr is not None: for run in sqr.zone_runs(self): if not run.expired: result.append(run) return result result = {} result[ATTR_INDEX] = self.index result[ATTR_ENABLED] = self.enabled result[ATTR_SUSPENDED] = self.suspended result[ATTR_STATUS] = self.status() result[ATTR_ICON] = self.icon() result[ATTR_ADJUSTMENT] = str(self.adjustment) result[ATTR_ZONE_IDS] = self.zone_ids result[ATTR_DURATION] = str(calc_on_time(zone_runs())) return result def muster(self, stime: datetime) -> IURQStatus: """Muster this sequence zone""" status = IURQStatus(0) if self._suspend_until is not None and stime >= self._suspend_until: self._suspend_until = None status |= IURQStatus.CHANGED return status def next_awakening(self) -> datetime: """Return the next event time""" result = utc_eot() if self._suspend_until is not None: result = min(self._suspend_until, result) return result class IUSequenceZoneRun(NamedTuple): """Irrigation Unlimited sequence zone run class""" sequence_zone: IUSequenceZone sequence_repeat: int zone_repeat: int class IUSequenceRunAllocation(NamedTuple): """Irrigation Unlimited sequence zone allocation class""" start: datetime duration: timedelta zone: IUZone sequence_zone_run: IUSequenceZoneRun class IUSequenceRun(IUBase): """Irrigation Unlimited sequence run manager class. Ties together the individual sequence zones""" # pylint: disable=too-many-instance-attributes # pylint: disable=too-many-public-methods def __init__( self, coordinator: "IUCoordinator", controller: "IUController", sequence: "IUSequence", schedule: IUSchedule, ) -> None: # pylint: disable=too-many-arguments super().__init__(None) # Passed parameters self._coordinator = coordinator self._controller = controller self._sequence = sequence self._schedule = schedule # Private variables self._runs_pre_allocate: list[IUSequenceRunAllocation] = [] self._runs: dict[IURun, IUSequenceZoneRun] = weakref.WeakKeyDictionary() self._active_zone: IUSequenceZoneRun = None self._current_zone: IUSequenceZoneRun = None self._start_time: datetime = None self._end_time: datetime = None self._accumulated_duration = timedelta(0) self._first_zone: IUZone = None self._status = IURunStatus.UNKNOWN self._paused: datetime = None self._last_pause: datetime = None self._volume_trackers: list[CALLBACK_TYPE] = [] self._volume_stats: dict[IUSequenceZoneRun, dict[IUZone, Decimal]] = {} self._remaining_time = timedelta(0) self._percent_complete: int = 0 @property def sequence(self) -> "IUSequence": """Return the sequence associated with this run""" return self._sequence @property def schedule(self) -> IUSchedule: """Return the schedule associated with this run""" return self._schedule @property def start_time(self) -> datetime: """Return the start time for this sequence""" return self._start_time @property def end_time(self) -> datetime: """Return the end time for this sequence""" return self._end_time @property def total_time(self) -> timedelta: """Return the total run time for this sequence""" return self._end_time - self._start_time @property def running(self) -> bool: """Indicate if this sequence is running""" return self._status == IURunStatus.RUNNING @property def expired(self) -> bool: """Indicate if this sequence has expired""" return self._status == IURunStatus.EXPIRED @property def future(self) -> bool: """Indicate if this sequence starts in the future""" return self._status == IURunStatus.FUTURE @property def paused(self) -> bool: """Indicate if this sequence is paused""" return self._status == IURunStatus.PAUSED @property def active_zone(self) -> IUSequenceZoneRun: """Return the active zone in the sequence""" return self._active_zone @property def runs(self) -> dict[IURun, IUSequenceZoneRun]: """Return the runs""" return self._runs @property def time_remaining(self) -> timedelta: """Return the amount of time left to run""" return self._remaining_time @property def percent_complete(self) -> float: """Return the percentage completed""" return self._percent_complete def is_manual(self) -> bool: """Check if this is a manual run""" return self._schedule is None def zone_enabled(self, zone: IUZone) -> bool: """Return true if the zone is enabled""" return zone is not None and ( zone.is_enabled or (self.is_manual() and zone.allow_manual) ) def calc_total_time(self, total_time: timedelta) -> timedelta: """Calculate the total duration of the sequence""" if total_time is None: if self._schedule is not None and self._schedule.duration is not None: return self._sequence.total_time_final(self._schedule.duration, self) return self.sequence.total_time_final(None, self) if self._schedule is not None: return self._sequence.total_time_final(total_time, self) return total_time def build(self, duration_factor: float) -> timedelta: """Build out the sequence. Pre allocate runs and determine the duration""" # pylint: disable=too-many-nested-blocks next_run = self._start_time = self._end_time = wash_dt(dt.utcnow()) for sequence_repeat in range(self._sequence.repeat): for sequence_zone in self._sequence.zones: if not self._sequence.zone_enabled(sequence_zone, self): continue duration = self._sequence.zone_duration_final( sequence_zone, duration_factor, self ) duration_max = timedelta(0) delay = self._sequence.zone_delay(sequence_zone, self) for zone in sequence_zone.zones: if self.zone_enabled(zone): # Don't adjust manual run and no adjustment on adjustment # This code should not really be here. It would be a breaking # change if removed. if not self.is_manual() and not self._sequence.has_adjustment( True ): duration_adjusted = zone.adjustment.adjust(duration) duration_adjusted = zone.runs.constrain(duration_adjusted) else: duration_adjusted = duration zone_run_time = next_run for zone_repeat in range(sequence_zone.repeat): self._runs_pre_allocate.append( IUSequenceRunAllocation( zone_run_time, duration_adjusted, zone, IUSequenceZoneRun( sequence_zone, sequence_repeat, zone_repeat ), ) ) if self._first_zone is None: self._first_zone = zone if zone_run_time + duration_adjusted > self._end_time: self._end_time = zone_run_time + duration_adjusted zone_run_time += duration_adjusted + delay duration_max = max(duration_max, zone_run_time - next_run) next_run += duration_max self._remaining_time = self._end_time - self._start_time return self._remaining_time def allocate_runs(self, stime: datetime, start_time: datetime) -> None: """Allocate runs""" delta = start_time - self._start_time self._start_time += delta self._end_time += delta for item in self._runs_pre_allocate: zone = item.zone run = zone.runs.add( stime, item.start + delta, item.duration, zone, self._schedule, self ) self._runs[run] = item.sequence_zone_run self._accumulated_duration += run.duration zone.request_update() self._runs_pre_allocate.clear() self._status = IURunStatus.status( stime, self.start_time, self.end_time, self._paused ) def first_zone(self) -> IUZone: """Return the first zone""" return self._first_zone def on_time(self, include_expired=False) -> timedelta: """Return the total time this run is on""" return calc_on_time( run for run in self._runs if include_expired or not run.expired ) def zone_runs(self, sequence_zone: IUSequenceZone) -> list[IURun]: """Get the list of runs associated with the sequence zone""" return [ run for run, sqz in self._runs.items() if sqz is not None and sqz.sequence_zone == sequence_zone ] def run_index(self, run: IURun) -> int: """Extract the index from the supplied run""" for i, key in enumerate(self._runs): if key == run: return i return None def sequence_zone(self, run: IURun) -> IUSequenceZone: """Extract the sequence zone from the run""" szr = self._runs.get(run, None) return szr.sequence_zone if szr is not None else None def next_sequence_zone( self, sequence_zone_run: IUSequenceZoneRun ) -> IUSequenceZoneRun: """Return the next sequence zone run in the run queue""" result: IUSequenceZoneRun = None found = False for szr in self.runs.values(): if szr is None: continue if not found and szr == sequence_zone_run: found = True if found and szr.sequence_zone != sequence_zone_run.sequence_zone: result = szr break return result def sequence_zone_runs(self, sequence_zone_run: IUSequenceZoneRun) -> list[IURun]: """Return all the run associated with the sequence zone""" result: list[IURun] = [] found = False for run, szr in self.runs.items(): if not found and szr == sequence_zone_run: found = True if found and szr.sequence_zone == sequence_zone_run.sequence_zone: result.append(run) if found and szr.sequence_zone != sequence_zone_run.sequence_zone: break return result def advance( self, stime: datetime, duration: timedelta, runs: list[IURun] = None, shift_start: bool = False, ) -> None: """Advance the sequence run. If duration is positive runs will be extended if running or delayed if in the future. If duration is negative runs will shortened or even skipped. The system will require a full muster as the status of runs, zones and sequences could have altered.""" def update_run(stime: datetime, duration: timedelta, run: IURun) -> None: if run.expired: return if duration > timedelta(0): if run.running: if shift_start: run.start_time += duration run.end_time += duration elif run.future: run.start_time += duration run.end_time += duration else: if run.running: run.end_time = max(run.end_time + duration, run.start_time) elif run.future: run.start_time = max(run.start_time + duration, stime) run.end_time = max(run.end_time + duration, run.start_time) run.duration = run.end_time - run.start_time run.update_status(stime) run.update_time_remaining(stime) if self.running: if runs is None: runs = self.runs for run in runs: if not run.expired: update_run(stime, duration, run) if run.master_run is not None: update_run(stime, duration, run.master_run) end_time: datetime = None for run in self.runs: if end_time is None or run.end_time > end_time: end_time = run.end_time self._end_time = end_time self.update() def skip(self, stime: datetime) -> None: """Skip to the next sequence zone""" current_start: datetime = None current_end: datetime = None current_runs = self.sequence_zone_runs(self._current_zone) for run in current_runs: if current_start is None or run.start_time < current_start: current_start = run.start_time if current_end is None or run.end_time > current_end: current_end = run.end_time next_start: datetime = None next_end: datetime = None nsz = self.next_sequence_zone(self._current_zone) if nsz is not None: for run in self.sequence_zone_runs(nsz): if next_start is None or run.start_time < next_start: next_start = run.start_time if next_end is None or run.end_time > next_end: next_end = run.end_time duration = next_start - stime if self._active_zone is not None: delay = max(next_start - current_end, timedelta(0)) else: delay = max(current_start - stime, timedelta(0)) else: duration = current_end - stime delay = timedelta(0) # Next zone is overlapped with current if next_start is not None and next_start < current_end: self.advance(stime, -(current_end - stime), current_runs) self.advance(stime, -(duration - delay), self._runs) def pause(self, stime: datetime) -> None: """Pause the sequence run""" def pause_run(stime: datetime, runs: list[IURun]) -> None: """Put the run and master into pause state""" for run in runs: run.pause(stime) if run.master_run is not None: run.master_run.pause(stime) def run_state(run: IURun) -> int: """Return the state of the run. 1 = expired 2 = preamble (positive) 3 = running 4 = postamble (positive) 5 = preamble (negative) 6 = postamble (negative) 7 = future """ if run.expired and run.master_run.expired: return 1 if run.future and run.master_run.running: return 2 if run.expired and run.master_run.running: return 4 if run.running and run.master_run.future: return 5 if run.running and run.master_run.expired: return 6 if run.future and run.master_run.future: return 7 if run.running: return 3 return 0 def split_run(run: IURun, start: datetime, duration=timedelta(0)) -> None: split = run.zone.runs.add( stime, start, duration, run.zone, run.schedule, self, ) self._runs[split] = None if self._paused is not None: return runs = self._runs.copy() pause_list = self._runs.copy() over_run = timedelta(0) for run in runs: state = run_state(run) if state == 2: split_run(run, stime - self._controller.postamble) elif state == 3: # Create a master postamble run out if ( self._controller.postamble is not None and self._controller.postamble < timedelta(0) ): over_run = -self._controller.postamble run.master_run.start_time = stime + over_run run.start_time = stime split_run(run, stime, over_run) elif state == 6: pause_list.pop(run) elif state == 5: split_run(run, stime) run.start_time = stime run.master_run.start_time = stime - self._controller.preamble elif state == 4: split_run(run, run.master_run.end_time - self._controller.postamble) if over_run != timedelta(0): self.advance(stime, -over_run, runs) pause_run(stime, pause_list) self._paused = stime self._status = IURunStatus.PAUSED def resume(self, stime: datetime) -> None: """Resume the sequence run""" def resume_run(stime: datetime, runs: list[IURun]) -> None: """Take the run and master out of pause state""" for run in runs: run.resume(stime) if run.master_run is not None: run.master_run.resume(stime) if self._paused is None: return resume_run(stime, self._runs) self._end_time += stime - self._paused self._paused = None self._status = IURunStatus.status( stime, self._start_time, self._end_time, self._paused ) next_start = min( (run.start_time for run in self._runs if not run.expired), default=None ) if next_start is not None: offset = stime - next_start if ( self._controller.preamble is not None and self._controller.preamble > timedelta(0) ): offset += self._controller.preamble self.advance(stime, offset, self._runs, shift_start=True) def cancel(self, stime: datetime) -> None: """Cancel the sequence run""" self.advance(stime, -(self._end_time - stime)) def update(self) -> bool: """Update the status of the sequence""" def update_volume( stime: datetime, zone: IUZone, volume: Decimal, rate: Decimal ) -> None: # pylint: disable=unused-argument if self._active_zone not in self._volume_stats: self._volume_stats[self._active_zone] = {} self._volume_stats[self._active_zone][zone] = volume self._sequence.volume = sum( sum(sta.values()) for sta in self._volume_stats.values() ) if (limit := self._active_zone.sequence_zone.volume) is not None: current_vol = sum(self._volume_stats[self._active_zone].values()) if current_vol >= limit: self._coordinator.service_call( SERVICE_SKIP, self._controller, None, self._sequence, {} ) def enable_trackers(sequence_zone: IUSequenceZone) -> None: for zone in sequence_zone.zones: self._volume_trackers.append( zone.volume.track_volume_change(self.uid, update_volume) ) def remove_trackers() -> None: for tracker in self._volume_trackers: tracker() self._volume_trackers.clear() if self.paused: return False result = False for run, sequence_zone_run in self._runs.items(): if sequence_zone_run is None: continue if run.running and not self.running: # Sequence/sequence zone is starting self._status = IURunStatus.RUNNING self._active_zone = sequence_zone_run self._current_zone = sequence_zone_run self._coordinator.notify_sequence( EVENT_START, self._controller, self._sequence, self._schedule, self, ) enable_trackers(sequence_zone_run.sequence_zone) self._sequence.volume = None result |= True elif run.running and sequence_zone_run != self._active_zone: # Sequence zone is changing self._active_zone = sequence_zone_run self._current_zone = sequence_zone_run remove_trackers() enable_trackers(sequence_zone_run.sequence_zone) result |= True elif not run.running and sequence_zone_run == self._active_zone: # Sequence zone is finishing self._active_zone = None remove_trackers() self._current_zone = self.next_sequence_zone(sequence_zone_run) if self.run_index(run) == len(self._runs) - 1: # Sequence is finishing self._status = IURunStatus.EXPIRED self._coordinator.notify_sequence( EVENT_FINISH, self._controller, self._sequence, self._schedule, self, ) result |= True return result def update_time_remaining(self, stime: datetime) -> bool: """Update the count down timers""" if not self.running: return False self._remaining_time = self._end_time - stime elapsed = stime - self._start_time duration = self._end_time - self._start_time self._percent_complete = ( int((elapsed / duration) * 100) if duration > timedelta(0) else 0 ) return True def as_dict(self, include_expired=False) -> dict: """Return this sequence run as a dict""" result = {} result[ATTR_INDEX] = self._sequence.index result[ATTR_NAME] = self._sequence.name result[ATTR_ENABLED] = self._sequence.enabled result[ATTR_SUSPENDED] = self._sequence.suspended result[ATTR_STATUS] = self._sequence.status result[ATTR_ICON] = self._sequence.icon result[ATTR_START] = dt.as_local(self._start_time) result[ATTR_DURATION] = to_secs(self.on_time(include_expired)) result[ATTR_ADJUSTMENT] = str(self._sequence.adjustment) if not self.is_manual(): result[ATTR_SCHEDULE] = { ATTR_INDEX: self._schedule.index, ATTR_NAME: self._schedule.name, } else: result[ATTR_SCHEDULE] = { ATTR_INDEX: None, ATTR_NAME: RES_MANUAL, } result[ATTR_ZONES] = [] for zone in self._sequence.zones: runs = ( run for run in self.zone_runs(zone) if include_expired or not run.expired ) sqr = {} sqr[ATTR_INDEX] = zone.index sqr[ATTR_ENABLED] = zone.enabled sqr[ATTR_SUSPENDED] = zone.suspended sqr[ATTR_STATUS] = zone.status() sqr[ATTR_ICON] = zone.icon() sqr[ATTR_DURATION] = to_secs(calc_on_time(runs)) sqr[ATTR_ADJUSTMENT] = str(zone.adjustment) sqr[ATTR_ZONE_IDS] = zone.zone_ids result[ATTR_ZONES].append(sqr) return result @staticmethod def skeleton(sequence: "IUSequence") -> dict: """Return a skeleton dict for when no sequence run is active. Must match the as_dict method""" result = {} result[ATTR_INDEX] = sequence.index result[ATTR_NAME] = sequence.name result[ATTR_ENABLED] = sequence.enabled result[ATTR_SUSPENDED] = sequence.suspended result[ATTR_STATUS] = sequence.status result[ATTR_ICON] = sequence.icon result[ATTR_START] = None result[ATTR_DURATION] = 0 result[ATTR_ADJUSTMENT] = str(sequence.adjustment) result[ATTR_SCHEDULE] = { ATTR_INDEX: None, ATTR_NAME: None, } result[ATTR_ZONES] = [] for zone in sequence.zones: sqr = {} sqr[ATTR_INDEX] = zone.index sqr[ATTR_ENABLED] = zone.enabled sqr[ATTR_SUSPENDED] = zone.suspended sqr[ATTR_STATUS] = zone.status(False) sqr[ATTR_ICON] = zone.icon(False) sqr[ATTR_DURATION] = 0 sqr[ATTR_ADJUSTMENT] = str(zone.adjustment) sqr[ATTR_ZONE_IDS] = zone.zone_ids result[ATTR_ZONES].append(sqr) return result class IUSequenceQueue(list[IUSequenceRun]): """Irrigation Unlimited class to hold the upcoming sequences""" DAYS_SPAN: int = 3 def __init__(self) -> None: super().__init__() # Config parameters self._future_span = timedelta(days=self.DAYS_SPAN) # Private variables self._current_run: IUSequenceRun = None self._next_run: IUSequenceRun = None self._sorted: bool = False self._next_event: datetime = None @property def current_run(self) -> IUSequenceRun | None: """Return the current sequence run""" return self._current_run @property def next_run(self) -> IUSequenceRun | None: """Return the next sequence run""" return self._next_run @property def current_duration(self) -> timedelta: """Return the current active duration""" if self._current_run is not None: return self._current_run.total_time return timedelta(0) @property def active_zone(self) -> IUSequenceZoneRun | None: """Return the current active sequence zone""" if self._current_run is not None: return self._current_run.active_zone return None def active_zone_duration(self, sequence_zone: IUSequenceZone) -> timedelta: """Return the current duration for the specified sequence zone""" if self._current_run is not None: for run in self._current_run.zone_runs(sequence_zone): return run.duration return timedelta(0) def add(self, run: IUSequenceRun) -> IUSequenceRun: """Add a sequence run to the queue""" self.append(run) self._sorted = False return run def clear_all(self) -> None: """Clear out all runs""" self._current_run = None super().clear() def clear_runs(self) -> bool: """Clear out the queue except for manual and running schedules.""" modified = False i = len(self) - 1 while i >= 0: sqr = self[i] if not (sqr.is_manual() or sqr.running): for run in sqr.runs: run.zone.runs.remove_run(run) self.pop(i) modified = True i -= 1 if modified: self._next_run = None return modified def sort(self) -> bool: """Sort the run queue""" def sorter(run: IUSequenceRun) -> datetime: """Sort call back routine. Items are sorted by start_time""" if run.is_manual(): return datetime.min.replace(tzinfo=dt.UTC) return run.start_time if self._sorted: return False super().sort(key=sorter) self._current_run = None self._next_run = None self._sorted = True return True def remove_expired(self, stime: datetime, postamble: timedelta) -> bool: """Remove any expired sequence runs from the queue""" if postamble is None: postamble = timedelta(0) modified: bool = False i = len(self) - 1 while i >= 0: sqr = self[i] if sqr.expired and stime > sqr.end_time + postamble: self._current_run = None self._next_run = None self.pop(i) modified = True i -= 1 return modified def update_queue(self) -> IURQStatus: """Update the run queue""" # pylint: disable=too-many-branches status = IURQStatus(0) if self.sort(): status |= IURQStatus.SORTED for run in self: if run.update(): self._current_run = None self._next_run = None status |= IURQStatus.CHANGED if self._current_run is None: for run in self: if run.running and run.on_time() != timedelta(0): self._current_run = run self._next_run = None status |= IURQStatus.UPDATED break if self._next_run is None: for run in self: if not run.running and run.on_time() != timedelta(0): self._next_run = run status |= IURQStatus.UPDATED break dates: list[datetime] = [utc_eot()] for run in self: if run.running: dates.append(run.end_time) else: dates.append(run.start_time) self._next_event = min(dates) return status def update_sensor(self, stime: datetime) -> bool: """Update the count down timers""" result = False for run in self: result |= run.update_time_remaining(stime) return result def as_list(self) -> list: """Return a list of runs""" return [run.as_dict() for run in self] class IUSequence(IUBase): """Irrigation Unlimited Sequence class""" # pylint: disable=too-many-instance-attributes # pylint: disable=too-many-public-methods def __init__( self, hass: HomeAssistant, coordinator: "IUCoordinator", controller: "IUController", sequence_index: int, ) -> None: super().__init__(sequence_index) # Passed parameters self._hass = hass self._coordinator = coordinator self._controller = controller # Config parameters self._name: str = None self._delay: timedelta = None self._duration: timedelta = None self._repeat: int = None self._enabled: bool = True # Private variables self._is_on = False self._is_in_delay = False self._paused = False self._run_queue = IUSequenceQueue() self._schedules: list[IUSchedule] = [] self._zones: list[IUSequenceZone] = [] self._adjustment = IUAdjustment() self._suspend_until: datetime = None self._sensor_update_required: bool = False self._sensor_last_update: datetime = None self._initialised: bool = False self._finalised: bool = False self._sequence_sensor: Entity = None self._volume: Decimal = None self._dirty = True @property def unique_id(self) -> str: """Return the HA unique_id for the sequence""" return f"c{self._controller.index + 1}_s{self.index + 1}" @property def entity_base(self) -> str: """Return the base of the entity_id. Entity rename not currently supported""" return self.unique_id @property def entity_id(self) -> str: """Return the HA entity_id for the sequence""" return f"{BINARY_SENSOR}.{DOMAIN}_{self.entity_base}" @property def runs(self) -> IUSequenceQueue: """Return the run queue for this sequence""" return self._run_queue @property def sequence_sensor(self) -> Entity: """Return the HA entity associated with this sequence""" return self._sequence_sensor @sequence_sensor.setter def sequence_sensor(self, value: Entity) -> None: self._sequence_sensor = value @property def volume(self) -> float: """Return the volume consumption""" if self._volume is not None: return float(self._volume) return None @volume.setter def volume(self, value: Decimal) -> None: self._volume = value @property def is_setup(self) -> bool: """Return True if this sequence is setup and ready to go""" self._initialised = self._sequence_sensor is not None if self._initialised: for schedule in self._schedules: self._initialised = self._initialised and schedule.is_setup return self._initialised @property def schedules(self) -> list[IUSchedule]: """Return the list of schedules for this sequence""" return self._schedules @property def zones(self) -> list[IUSequenceZone]: """Return the list of sequence zones""" return self._zones @property def name(self) -> str: """Return the friendly name of this sequence""" return self._name @property def delay(self) -> timedelta: """Returns the default inter zone delay""" return self._delay @property def duration(self) -> timedelta: """Returns the default zone duration""" return self._duration @property def repeat(self) -> int: """Returns the number of times to repeat this sequence""" return self._repeat @property def is_enabled(self) -> bool: """Return true is this sequence is enabled and not suspended""" return self._enabled and self._suspend_until is None @property def enabled(self) -> bool: """Return if this sequence is enabled""" return self._enabled @enabled.setter def enabled(self, value: bool) -> None: """Set the enabled state""" if value != self._enabled: self._enabled = value self._dirty = True self.request_update() @property def suspended(self) -> datetime: """Return the suspend date""" return self._suspend_until @suspended.setter def suspended(self, value: datetime) -> None: """Set the suspend date for this sequence""" if value != self._suspend_until: self._suspend_until = value self._dirty = True self.request_update() @property def adjustment(self) -> IUAdjustment: """Returns the sequence adjustment""" return self._adjustment @property def is_on(self) -> bool: """Return if the sequence is on or off""" return self._is_on @property def is_in_delay(self) -> bool: """Return is the sequence is waiting between zones""" return self._is_in_delay @property def is_paused(self) -> bool: """Return true is the sequence is paused""" return self._paused @property def icon(self) -> str: """Return the icon to use in the frontend.""" if self._controller.is_enabled: if self.enabled: if self.suspended is None: if self.is_on: if self.is_paused: return ICON_SEQUENCE_PAUSED if self.is_in_delay: return ICON_SEQUENCE_DELAY return ICON_SEQUENCE_ON return ICON_SEQUENCE_OFF return ICON_SUSPENDED return ICON_DISABLED return ICON_BLOCKED @property def status(self) -> str: """Return status of the sequence""" if self._controller.is_enabled: if self.enabled: if self.suspended is None: if self.is_on: if self.is_paused: return STATUS_PAUSED if self.is_in_delay: return STATUS_DELAY return STATE_ON return STATE_OFF return STATUS_SUSPENDED return STATUS_DISABLED return STATUS_BLOCKED def has_adjustment(self, deep: bool) -> bool: """Indicates if this sequence has an active adjustment""" if self._adjustment.has_adjustment: return True if deep: for sequence_zone in self._zones: if sequence_zone.is_enabled and sequence_zone.adjustment.has_adjustment: return True return False def zone_enabled( self, sequence_zone: IUSequenceZone, sqr: IUSequenceRun = None ) -> bool: """Return True if at least one real zone referenced by the sequence_zone is enabled""" if ( (self._controller.is_enabled or (sqr is not None and sqr.is_manual())) and self.is_enabled and sequence_zone.is_enabled ): for zone in sequence_zone.zones: if zone.is_enabled or (sqr is not None and sqr.zone_enabled(zone)): return True return False def constrain( self, sequence_zone: IUSequenceZone, duration: timedelta ) -> timedelta: """Apply the zone entity constraints""" for zone in sequence_zone.zones: duration = zone.runs.constrain(duration) return duration def zone_delay_config(self, sequence_zone: IUSequenceZone) -> timedelta: """Return the configured (static) delay""" if sequence_zone.delay is not None: delay = sequence_zone.delay else: delay = self._delay if delay is None: delay = timedelta(0) return delay def zone_delay( self, sequence_zone: IUSequenceZone, sqr: IUSequenceRun ) -> timedelta: """Return the delay for the specified zone""" if self.zone_enabled(sequence_zone, sqr): return self.zone_delay_config(sequence_zone) return timedelta(0) def total_delay(self, sqr: IUSequenceRun) -> timedelta: """Return the total delay for all the zones""" delay = timedelta(0) last_zone: IUSequenceZone = None if len(self._zones) > 0: for zone in self._zones: if self.zone_enabled(zone, sqr): delay += self.zone_delay(zone, sqr) * zone.repeat last_zone = zone delay *= self._repeat if last_zone is not None: delay -= self.zone_delay(last_zone, sqr) return delay def zone_duration_config(self, sequence_zone: IUSequenceZone) -> timedelta: """Return the configured (static) duration for the specified zone""" if sequence_zone.duration is not None: duration = sequence_zone.duration else: duration = self._duration if duration is None: duration = granularity_time() return duration def zone_duration_base( self, sequence_zone: IUSequenceZone, sqr: IUSequenceRun ) -> timedelta: """Return the base duration for the specified zone""" if self.zone_enabled(sequence_zone, sqr): return self.zone_duration_config(sequence_zone) return timedelta(0) def zone_duration( self, sequence_zone: IUSequenceZone, sqr: IUSequenceRun ) -> timedelta: """Return the duration for the specified zone including adjustments and constraints""" if self.zone_enabled(sequence_zone, sqr): duration = self.zone_duration_base(sequence_zone, sqr) duration = sequence_zone.adjustment.adjust(duration) return self.constrain(sequence_zone, duration) return timedelta(0) def zone_duration_final( self, sequence_zone: IUSequenceZone, duration_factor: float, sqr: IUSequenceRun, ) -> timedelta: """Return the final zone time after the factor has been applied""" duration = self.zone_duration(sequence_zone, sqr) * duration_factor duration = self.constrain(sequence_zone, duration) return round_td(duration) def total_duration(self, sqr: IUSequenceRun) -> timedelta: """Return the total duration for all the zones""" duration = timedelta(0) for zone in self._zones: duration += self.zone_duration(zone, sqr) * zone.repeat duration *= self._repeat return duration def total_duration_adjusted(self, total_duration, sqr: IUSequenceRun) -> timedelta: """Return the adjusted duration""" if total_duration is None: total_duration = self.total_duration(sqr) if self.has_adjustment(False): total_duration = self.adjustment.adjust(total_duration) total_duration = max(total_duration, timedelta(0)) return total_duration def total_time_final(self, total_time: timedelta, sqr: IUSequenceRun) -> timedelta: """Return the adjusted total time for the sequence""" if total_time is not None and self.has_adjustment(False): total_delay = self.total_delay(sqr) total_duration = self.total_duration_adjusted(total_time - total_delay, sqr) return total_duration + total_delay if total_time is None: return self.total_duration_adjusted(None, sqr) + self.total_delay(sqr) return total_time def duration_factor(self, total_time: timedelta, sqr: IUSequenceRun) -> float: """Given a new total run time, calculate how much to shrink or expand each zone duration. Final time will be approximate as the new durations must be rounded to internal boundaries""" total_duration = self.total_duration(sqr) if total_time is not None and total_duration != timedelta(0): total_delay = self.total_delay(sqr) if total_time > total_delay: return (total_time - total_delay) / total_duration return 0.0 return 1.0 def clear(self) -> None: """Reset this sequence""" self._schedules.clear() def add_schedule(self, schedule: IUSchedule) -> IUSchedule: """Add a new schedule to the sequence""" self._schedules.append(schedule) return schedule def find_add_schedule(self, index: int) -> IUSchedule: """Look for and create if required a schedule""" if index >= len(self._schedules): return self.add_schedule(IUSchedule(self._hass, self._coordinator, index)) return self._schedules[index] def add_zone(self, zone: IUSequenceZone) -> IUSequenceZone: """Add a new zone to the sequence""" self._zones.append(zone) return zone def get_zone(self, index: int) -> IUSequenceZone: """Return the specified zone object""" if index is not None and index >= 0 and index < len(self._zones): return self._zones[index] return None def find_add_zone(self, index: int) -> IUSequenceZone: """Look for and create if required a zone""" result = self.get_zone(index) if result is None: result = self.add_zone(IUSequenceZone(self._controller, self, index)) return result def zone_list(self) -> Iterator[list[IUZone]]: """Generator to return all referenced zones""" result: set[IUZone] = set() for sequence_zone in self._zones: result.update(sequence_zone.zones) for zone in result: yield zone def load(self, config: OrderedDict) -> "IUSequence": """Load sequence data from the configuration""" self.clear() self._name = config.get(CONF_NAME, f"Run {self.index + 1}") self._delay = wash_td(config.get(CONF_DELAY)) self._duration = wash_td(config.get(CONF_DURATION)) self._repeat = config.get(CONF_REPEAT, 1) self._enabled = config.get(CONF_ENABLED, self._enabled) zidx: int = 0 for zidx, zone_config in enumerate(config[CONF_ZONES]): self.find_add_zone(zidx).load(zone_config) while zidx < len(self._zones) - 1: self._zones.pop() if CONF_SCHEDULES in config: for sidx, schedule_config in enumerate(config[CONF_SCHEDULES]): self.find_add_schedule(sidx).load(schedule_config) self._dirty = True return self def as_dict(self, extended=False, sqr: IUSequenceRun = None) -> OrderedDict: """Return this sequence as a dict""" total_delay = self.total_delay(sqr) total_duration = self.total_duration(sqr) total_duration_adjusted = self.total_duration_adjusted(total_duration, sqr) duration_factor = self.duration_factor( total_duration_adjusted + total_delay, sqr ) result = OrderedDict() result[CONF_INDEX] = self._index result[CONF_NAME] = self._name result[CONF_STATE] = STATE_ON if self.is_on else STATE_OFF result[CONF_ENABLED] = self._enabled result[ATTR_SUSPENDED] = self.suspended result[ATTR_ADJUSTMENT] = str(self._adjustment) result[CONF_SEQUENCE_ZONES] = [ szn.as_dict(duration_factor, extended) for szn in self._zones ] if extended: result[ATTR_ICON] = self.icon result[ATTR_STATUS] = self.status result[ATTR_DEFAULT_DURATION] = self._duration result[ATTR_DEFAULT_DELAY] = self._delay result[ATTR_DURATION_FACTOR] = duration_factor result[ATTR_TOTAL_DELAY] = total_delay result[ATTR_TOTAL_DURATION] = total_duration result[ATTR_ADJUSTED_DURATION] = total_duration_adjusted result[ATTR_CURRENT_DURATION] = self.runs.current_duration result[CONF_SCHEDULES] = [sch.as_dict() for sch in self._schedules] return result def ha_zone_attr(self) -> list[dict]: """Return the HA zone attributes""" return [szn.ha_attr() for szn in self._zones] def muster(self, stime: datetime) -> IURQStatus: """Muster this sequence""" status = IURQStatus(0) if self._dirty: self._run_queue.clear_all() status |= IURQStatus.CLEARED if self._suspend_until is not None and stime >= self._suspend_until: self._suspend_until = None status |= IURQStatus.CHANGED for sequence_zone in self._zones: status |= sequence_zone.muster(stime) self._dirty = False return status def check_run(self, stime: datetime, parent_enabled: bool) -> bool: """Update the run status""" # pylint: disable=unused-argument is_running = parent_enabled and ( self.is_enabled and self._run_queue.current_run is not None and ( self._run_queue.current_run.running or self._run_queue.current_run.paused ) ) state_changed = is_running ^ self._is_on if state_changed: self._is_on = not self._is_on self.request_update() is_in_delay = is_running and self._run_queue.current_run.active_zone is None if is_in_delay ^ self._is_in_delay: self._is_in_delay = not self._is_in_delay self.request_update() is_paused = is_running and self._run_queue.current_run.paused if is_paused ^ self._paused: self._paused = not self._paused self.request_update() return state_changed def request_update(self) -> None: """Flag the sensor needs an update""" self._sensor_update_required = True def update_sensor(self, stime: datetime, do_on: bool) -> bool: """Lazy sensor updater""" updated: bool = False do_update: bool = False if self._sequence_sensor is not None: if do_on is False: updated |= self._run_queue.update_sensor(stime) do_update = not self.is_on and self._sensor_update_required else: if self.is_on: # If we are running then update sensor according to refresh_interval do_update = ( self._sensor_last_update is None or stime - self._sensor_last_update >= self._coordinator.refresh_interval ) do_update |= self._sensor_update_required else: do_update = False if do_update: self._sequence_sensor.schedule_update_ha_state() self._sensor_update_required = False self._sensor_last_update = stime updated = True return updated def next_awakening(self) -> datetime: """Return the next event time""" dates: list[datetime] = [utc_eot()] dates.append(self._suspend_until) dates.extend(sqz.next_awakening() for sqz in self._zones) return min(d for d in dates if d is not None) def finalise(self) -> None: """Shutdown the sequence""" if not self._finalised: self._finalised = True def service_edt( self, data: MappingProxyType, stime: datetime, service: str ) -> bool: """Service handler for enable/disable/toggle""" # pylint: disable=unused-argument changed = False zone_list: list[int] = data.get(CONF_ZONES) if zone_list is None: new_state = s2b(self.enabled, service) if self.enabled != new_state: self.enabled = new_state changed = True else: for sequence_zone in self.zones: if check_item(sequence_zone.index, zone_list): new_state = s2b(sequence_zone.enabled, service) if sequence_zone.enabled != new_state: sequence_zone.enabled = new_state changed = True if changed: self._run_queue.clear_runs() return changed def service_suspend(self, data: MappingProxyType, stime: datetime) -> bool: """Service handler for suspend""" changed = False suspend_time = suspend_until_date(data, stime) zone_list: list[int] = data.get(CONF_ZONES) if zone_list is None: if self.suspended != suspend_time: self.suspended = suspend_time changed = True else: for sequence_zone in self.zones: if check_item(sequence_zone.index, zone_list): if sequence_zone.suspended != suspend_time: sequence_zone.suspended = suspend_time changed = True if changed: self._run_queue.clear_runs() return changed def service_adjust_time(self, data: MappingProxyType, stime: datetime) -> bool: """Service handler for adjust_time""" # pylint: disable=unused-argument changed = False zone_list: list[int] = data.get(CONF_ZONES) if zone_list is None: changed = self.adjustment.load(data) else: for sequence_zone in self.zones: if check_item(sequence_zone.index, zone_list): changed |= sequence_zone.adjustment.load(data) if changed: self._run_queue.clear_runs() return changed def service_manual_run(self, data: MappingProxyType, stime: datetime) -> None: """Service handler for manual_run""" duration = wash_td(data.get(CONF_TIME)) delay = wash_td(data.get(CONF_DELAY, timedelta(0))) queue = data.get(CONF_QUEUE, self._controller.queue_manual) if duration is not None and duration == timedelta(0): duration = None self._controller.muster_sequence( self._controller.manual_run_start(stime, delay, queue), self, None, duration ) def service_cancel(self, data: MappingProxyType, stime: datetime) -> bool: """Cancel the sequence""" # pylint: disable=unused-argument changed = False for sqr in self._run_queue: if sqr.running: sqr.cancel(stime) changed = True return changed def service_skip(self, data: MappingProxyType, stime: datetime) -> bool: """Skip to the next sequence zone""" # pylint: disable=unused-argument changed = False for sqr in self._run_queue: if sqr.running: sqr.skip(stime) changed = True return changed def service_pause(self, data: MappingProxyType, stime: datetime) -> bool: """Pause the sequence""" # pylint: disable=unused-argument def is_preamble(sqr: IUSequenceRun) -> bool: return ( self._controller.preamble is not None and sqr.future and stime > sqr.start_time - self._controller.preamble ) changed = False for sqr in self._run_queue: if not sqr.paused and ( is_preamble(sqr) or (sqr.end_time >= stime and sqr.running) ): sqr.pause(stime) changed = True return changed def service_resume(self, data: MappingProxyType, stime: datetime) -> bool: """Resume the sequence""" # pylint: disable=unused-argument changed = False for sqr in self._run_queue: if sqr.paused: sqr.resume(stime) changed = True return changed class IUController(IUBase): """Irrigation Unlimited Controller (Master) class""" # pylint: disable=too-many-instance-attributes # pylint: disable=too-many-public-methods def __init__( self, hass: HomeAssistant, coordinator: "IUCoordinator", controller_index: int ) -> None: # Passed parameters super().__init__(controller_index) self._hass = hass self._coordinator = coordinator # Parent # Config parameters self._enabled: bool = True self._name: str = None self._controller_id: str = None self._preamble: timedelta = None self._postamble: timedelta = None self._queue_manual: bool = False # Private variables self._initialised: bool = False self._finalised: bool = False self._zones: list[IUZone] = [] self._sequences: list[IUSequence] = [] self._run_queue = IUZoneQueue() self._switch = IUSwitch(hass, coordinator, self, None) self._master_sensor: Entity = None self._is_on: bool = False self._sensor_update_required: bool = False self._sensor_last_update: datetime = None self._suspend_until: datetime = None self._volume = IUVolume(hass, coordinator, None) self._user = IUUser() self._dirty: bool = True @property def controller_id(self) -> str: """Return the controller_id for the controller entity""" return self._controller_id @property def unique_id(self) -> str: """Return the HA unique_id for the controller entity""" return f"c{self.index + 1}_m" @property def entity_base(self) -> str: """Return the base entity_id""" if self._coordinator.rename_entities: return self._controller_id return self.unique_id @property def entity_id(self) -> str: """Return the HA entity_id for the controller entity""" return f"{BINARY_SENSOR}.{DOMAIN}_{self.entity_base}" @property def zones(self) -> "list[IUZone]": """Return a list of children zones""" return self._zones @property def sequences(self) -> "list[IUSequence]": """Return a list of children sequences""" return self._sequences @property def runs(self) -> IUZoneQueue: """Return the master run queue""" return self._run_queue @property def name(self) -> str: """Return the friendly name for the controller""" return self._name @property def is_on(self) -> bool: """Return if the controller is on or off""" return self._is_on @property def is_setup(self) -> bool: """Indicate if the controller is setup and ready to go""" return self._is_setup() @property def is_enabled(self) -> bool: """Return true if this controller is enabled and not suspended""" return self._enabled and self._suspend_until is None @property def enabled(self) -> bool: """Return true is this controller is enabled""" return self._enabled @enabled.setter def enabled(self, value: bool) -> None: """Enable/disable this controller""" if value != self._enabled: self._enabled = value self._dirty = True self.request_update(True) @property def suspended(self) -> datetime: """Return the suspend date""" return self._suspend_until @suspended.setter def suspended(self, value: datetime) -> None: """Set the suspend date for this controller""" if value != self._suspend_until: self._suspend_until = value self._dirty = True self.request_update(True) @property def master_sensor(self) -> Entity: """Return the associated HA entity""" return self._master_sensor @master_sensor.setter def master_sensor(self, value: Entity) -> None: self._master_sensor = value @property def preamble(self) -> timedelta: """Return the preamble time for the controller""" return self._preamble @property def postamble(self) -> timedelta: """Return the postamble time for the controller""" return self._postamble @property def queue_manual(self) -> bool: """Return if manual runs should be queue""" return self._queue_manual @property def status(self) -> str: """Return the state of the controller""" return self._status() @property def is_paused(self) -> bool: """Returns True if the controller is running a sequence. The controller may be off because of a delay between sequence zones""" return self._run_queue.in_sequence @property def icon(self) -> str: """Return the icon to use in the frontend.""" if self.enabled: if self.suspended is None: if self.is_on: return ICON_CONTROLLER_ON if self.is_paused: return ICON_CONTROLLER_DELAY return ICON_CONTROLLER_OFF return ICON_SUSPENDED return ICON_DISABLED @property def user(self) -> dict: """Return the arbitrary user information""" return self._user @property def volume(self) -> IUVolume: """Return the volume for this zone""" return self._volume def _status(self) -> str: """Return status of the controller""" if self._initialised: if self.enabled: if self.suspended is None: if self._is_on: return STATE_ON if self._run_queue.in_sequence: return STATUS_DELAY return STATE_OFF return STATUS_SUSPENDED return STATUS_DISABLED return STATUS_INITIALISING def _is_setup(self) -> bool: self._initialised = self._master_sensor is not None if self._initialised: for zone in self._zones: self._initialised = self._initialised and zone.is_setup for sequence in self._sequences: self._initialised = self._initialised and sequence.is_setup return self._initialised def add_zone(self, zone: IUZone) -> IUZone: """Add a new zone to the controller""" self._zones.append(zone) return zone def get_zone(self, index: int) -> IUZone: """Return the zone by index""" if index is not None and index >= 0 and index < len(self._zones): return self._zones[index] return None def find_add_zone( self, coordinator: "IUCoordinator", controller: "IUController", index: int ) -> IUZone: """Locate and create if required the zone object""" if index >= len(self._zones): return self.add_zone(IUZone(self._hass, coordinator, controller, index)) return self._zones[index] def add_sequence(self, sequence: IUSequence) -> IUSequence: """Add a new sequence to the controller""" self._sequences.append(sequence) return sequence def get_sequence(self, index: int) -> IUSequence: """Locate the sequence by index""" if index is not None and index >= 0 and index < len(self._sequences): return self._sequences[index] return None def find_add_sequence(self, index: int) -> IUSequence: """Locate and create if required a sequence""" if index >= len(self._sequences): return self.add_sequence( IUSequence(self._hass, self._coordinator, self, index) ) return self._sequences[index] def find_zone_by_zone_id(self, zone_id: str) -> IUZone: """Find the real zone from a zone_id""" for zone in self._zones: if zone.zone_id == zone_id: return zone return None def clear(self) -> None: """Clear out the controller""" # Don't clear zones # self._zones.clear() self.clear_zones(None) def clear_zones(self, zones: list[IUZone]) -> None: """Clear out the specified zone run queues""" if zones is None: self.runs.clear_all() for sequence in self._sequences: sequence.runs.clear_all() for zone in self._zones: zone.runs.clear_all() else: for run in self.runs: if run.zone in zones: self.runs.remove(run) for sequence in self._sequences: for zone in sequence.zone_list(): if zone in zones: sequence.runs.clear_all() for zone in zones: zone.runs.clear_all() def clear_zone_runs(self, zone: IUZone) -> None: """Clear out zone run queues""" zone.runs.clear_runs() for sequence in self._sequences: if zone in sequence.zone_list(): sequence.runs.clear_runs() def load(self, config: OrderedDict) -> "IUController": """Load config data for the controller""" self.clear() self._enabled = config.get(CONF_ENABLED, self._enabled) self._name = config.get(CONF_NAME, f"Controller {self.index + 1}") self._controller_id = config.get(CONF_CONTROLLER_ID, str(self.index + 1)) self._preamble = wash_td(config.get(CONF_PREAMBLE)) self._postamble = wash_td(config.get(CONF_POSTAMBLE)) self._queue_manual = config.get(CONF_QUEUE_MANUAL, self._queue_manual) all_zones = config.get(CONF_ALL_ZONES_CONFIG) zidx: int = 0 for zidx, zone_config in enumerate(config[CONF_ZONES]): self.find_add_zone(self._coordinator, self, zidx).load( zone_config, all_zones ) while zidx < len(self._zones) - 1: self._zones.pop().finalise(True) if CONF_SEQUENCES in config: qidx: int = 0 for qidx, sequence_config in enumerate(config[CONF_SEQUENCES]): self.find_add_sequence(qidx).load(sequence_config) while qidx < len(self._sequences) - 1: self._sequences.pop() else: self._sequences.clear() self._switch.load(config, None) self._volume.load(config, None) self._user.load(config, None) self._dirty = True return self def finalise(self, turn_off: bool) -> None: """Shutdown this controller""" if not self._finalised: for zone in self._zones: zone.finalise(turn_off) if turn_off and self._is_on: self.call_switch(False) self.clear() self._finalised = True def as_dict(self, extended=False) -> OrderedDict: """Return this controller as a dict""" result = OrderedDict() result[CONF_INDEX] = self._index result[CONF_NAME] = self._name result[CONF_ENABLED] = self._enabled result[ATTR_SUSPENDED] = self.suspended result[CONF_ZONES] = [zone.as_dict(extended) for zone in self._zones] result[CONF_SEQUENCES] = [seq.as_dict(extended) for seq in self._sequences] result[CONF_STATE] = STATE_ON if self.is_on else STATE_OFF if extended: result[CONF_CONTROLLER_ID] = self._controller_id result[CONF_ENTITY_BASE] = self.entity_base result[CONF_ICON] = self.icon result[ATTR_STATUS] = self.status return result def sequence_runs(self) -> list[IUSequenceRun]: """Gather all the sequence runs""" result: list[IUSequenceRun] = [] for sequence in self._sequences: for run in sequence.runs: result.append(run) return result def up_next(self) -> dict[IUSequence, IUSequenceRun]: """Return a list of sequences and their next start times filtered""" sequences: dict[IUSequence, IUSequenceRun] = {} for run in self.sequence_runs(): if not run.expired: sample = sequences.get(run.sequence) if sample is None or run.start_time < sample.start_time: sequences[run.sequence] = run return sequences def sequence_status(self, include_expired=False) -> list[dict]: """Return the sequence status or run information""" result: list[dict] = [] runs = self.up_next() for sequence in self._sequences: run = runs.get(sequence) if run is not None: result.append(run.as_dict(include_expired)) else: result.append(IUSequenceRun.skeleton(sequence)) return result def muster_sequence( self, stime: datetime, sequence: IUSequence, schedule: IUSchedule, total_time: timedelta = None, ) -> IURQStatus: # pylint: disable=too-many-locals, too-many-statements """Muster the sequences for the controller""" def init_run_time( stime: datetime, sequence: IUSequence, schedule: IUSchedule, zone: IUZone, total_duration: timedelta, ) -> datetime: def is_running(sequence: IUSequence, schedule: IUSchedule) -> bool: """Return True is this sequence & schedule is currently running""" for srn in sequence.runs: if srn.schedule == schedule and srn.running: return True return False def find_last_run(sequence: IUSequence, schedule: IUSchedule) -> IURun: result: IUSequenceRun = None next_time: datetime = None for sqr in sequence.runs: if sqr.schedule == schedule: for run in sqr.runs: if next_time is None or run.end_time > next_time: next_time = run.start_time result = run return result if schedule is not None: last_run = find_last_run(sequence, schedule) if last_run is not None: next_time = last_run.sequence_run.end_time else: next_time = stime next_run = schedule.get_next_run( next_time, zone.runs.last_time(stime), total_duration, is_running(sequence, schedule), ) else: next_run = stime return next_run status = IURQStatus(0) sequence_run = IUSequenceRun(self._coordinator, self, sequence, schedule) total_time = sequence_run.calc_total_time(total_time) duration_factor = sequence.duration_factor(total_time, sequence_run) total_time = sequence_run.build(duration_factor) if total_time > timedelta(0): start_time = init_run_time( stime, sequence, schedule, sequence_run.first_zone(), total_time ) if start_time is not None: sequence_run.allocate_runs(stime, start_time) sequence.runs.add(sequence_run) status |= IURQStatus.EXTENDED return status def muster(self, stime: datetime, force: bool) -> IURQStatus: """Calculate run times for this controller. This is where most of the hard yakka is done.""" # pylint: disable=too-many-branches status = IURQStatus(0) if self._dirty or force: self.clear_zones(None) status |= IURQStatus.CLEARED else: for zone in self._zones: zone.runs.update_run_status(stime) self._run_queue.update_run_status(stime) if self._suspend_until is not None and stime >= self._suspend_until: self._suspend_until = None status |= IURQStatus.CHANGED self._switch.muster(stime) zone_status = IURQStatus(0) # Handle initialisation for zone in self._zones: zone_status |= zone.muster(stime) for sequence in self._sequences: sms = sequence.muster(stime) if not sms.is_empty(): sequence.runs.clear_runs() zone_status |= sms if not self._coordinator.tester.enabled or self._coordinator.tester.is_testing: # pylint: disable=too-many-nested-blocks # Process sequence schedules for sequence in self._sequences: if sequence.is_enabled: for schedule in sequence.schedules: if not schedule.enabled: continue next_time = stime while True: if self.muster_sequence( next_time, sequence, schedule, None ).is_empty(): break zone_status |= IURQStatus.EXTENDED # Process zone schedules for zone in self._zones: if zone.is_enabled: zone_status |= zone.muster_schedules(stime) # Post processing for sequence in self._sequences: zone_status |= sequence.runs.update_queue() for zone in self._zones: zts = zone.runs.update_queue() if IURQStatus.CANCELED in zts: zone.request_update() zone_status |= zts if zone_status.has_any( IURQStatus.CLEARED | IURQStatus.EXTENDED | IURQStatus.SORTED | IURQStatus.CANCELED | IURQStatus.CHANGED ): status |= self._run_queue.rebuild_schedule( stime, self._zones, self._preamble, self._postamble ) status |= self._run_queue.update_queue() # Purge expired runs for sequence in self._sequences: if sequence.runs.remove_expired(stime, self._postamble): zone_status |= IURQStatus.REDUCED for zone in self._zones: if zone.runs.remove_expired(stime, self._postamble): status |= IURQStatus.REDUCED if not status.is_empty(): self.request_update(False) self._dirty = False return status | zone_status def check_run(self, stime: datetime) -> bool: """Check the run status and update sensors. Return flag if anything has changed.""" for sequence in self._sequences: sequence.check_run(stime, self.is_enabled) zones_changed: list[int] = [] run = self._run_queue.current_run is_enabled = self.is_enabled or (run is not None and run.is_manual()) is_running = is_enabled and run is not None state_changed = is_running ^ self._is_on # Gather zones that have changed status for zone in self._zones: if zone.check_run(is_enabled): zones_changed.append(zone.index) # Handle off zones before master for zone in (self._zones[i] for i in zones_changed): if not zone.is_on: zone.call_switch(zone.is_on, stime) zone.volume.end_record(stime) # Check if master has changed and update if state_changed: self._is_on = not self._is_on self.request_update(False) self.call_switch(self._is_on, stime) if self._is_on: self._volume.start_record(stime) else: self._volume.end_record(stime) # Handle on zones after master for zone in (self._zones[i] for i in zones_changed): if zone.is_on: zone.call_switch(zone.is_on, stime) zone.volume.start_record(stime) return state_changed def check_links(self) -> bool: """Check inter object links""" result = True zone_ids = set() for zone in self._zones: if zone.zone_id in zone_ids: self._coordinator.logger.log_duplicate_id(self, zone, None) result = False else: zone_ids.add(zone.zone_id) for sequence in self._sequences: for sequence_zone in sequence.zones: for zone_id in sequence_zone.zone_ids: if zone_id not in zone_ids: self._coordinator.logger.log_orphan_id( self, sequence, sequence_zone, zone_id ) result = False return result def request_update(self, deep: bool) -> None: """Flag the sensor needs an update. The actual update is done in update_sensor""" self._sensor_update_required = True if deep: for zone in self._zones: zone.request_update() for sequence in self._sequences: sequence.request_update() def update_sensor(self, stime: datetime) -> None: """Lazy sensor updater.""" self._run_queue.update_sensor(stime) for zone in self._zones: zone.update_sensor(stime, False) for sequence in self._sequences: sequence.update_sensor(stime, False) if self._master_sensor is not None: do_update: bool = self._sensor_update_required # If we are running then update sensor according to refresh_interval if self._run_queue.current_run is not None: do_update = ( do_update or self._sensor_last_update is None or stime - self._sensor_last_update >= self._coordinator.refresh_interval ) if do_update: self._master_sensor.schedule_update_ha_state() self._sensor_update_required = False self._sensor_last_update = stime for zone in self._zones: zone.update_sensor(stime, True) for sequence in self._sequences: sequence.update_sensor(stime, True) def next_awakening(self) -> datetime: """Return the next event time""" dates: list[datetime] = [ self._run_queue.next_event(), self._switch.next_event(), self._suspend_until, ] dates.extend(zone.next_awakening() for zone in self._zones) dates.extend(seq.next_awakening() for seq in self._sequences) if self._is_on and self._sensor_last_update is not None: dates.append(self._sensor_last_update + self._coordinator.refresh_interval) return min(d for d in dates if d is not None) def check_switch(self, resync: bool, stime: datetime) -> list[str]: """Check the linked entity is in sync""" result = self._switch.check_switch(stime, resync, True) for zone in self._zones: result.extend(zone.check_switch(resync, stime)) return result def call_switch(self, state: bool, stime: datetime = None) -> None: """Update the linked entity if enabled""" self._switch.call_switch(state, stime) self._coordinator.status_changed(stime, self, None, state) def decode_sequence_id( self, stime: datetime, sequence_id: int | None ) -> list[int] | None: """Convert supplied 1's based id into a list of sequence indexes and validate""" if sequence_id is None: return None sequence_list: list[int] = [] if sequence_id == 0: sequence_list.extend(sequence.index for sequence in self._sequences) else: if self.get_sequence(sequence_id - 1) is not None: sequence_list.append(sequence_id - 1) else: self._coordinator.logger.log_invalid_sequence(stime, self, sequence_id) return sequence_list def manual_run_start( self, stime: datetime, delay: timedelta, queue: bool ) -> datetime: """Determine the next available start time for a manual run""" nst = wash_dt(stime) if not self._is_on: nst += granularity_time() if ( self.preamble is not None and self.preamble > timedelta(0) and not self.is_on ): nst += self.preamble if queue: end_times: list[datetime] = [] for zone in self._zones: end_times.extend(run.end_time for run in zone.runs if run.is_manual()) if len(end_times) > 0: nst = max(end_times) + delay return nst def service_edt( self, data: MappingProxyType, stime: datetime, service: str ) -> bool: """Handler for enable/disable/toggle service call""" # pylint: disable=too-many-branches, too-many-nested-blocks changed = False sequence_list = self.decode_sequence_id(stime, data.get(CONF_SEQUENCE_ID)) if sequence_list is None: new_state = s2b(self.enabled, service) if self.enabled != new_state: self.enabled = new_state changed = True else: for sequence in (self.get_sequence(sqid) for sqid in sequence_list): changed |= sequence.service_edt(data, stime, service) if changed: self.request_update(True) return changed def service_suspend(self, data: MappingProxyType, stime: datetime) -> bool: """Handler for the suspend service call""" # pylint: disable=too-many-nested-blocks changed = False suspend_time = suspend_until_date(data, stime) sequence_list = self.decode_sequence_id(stime, data.get(CONF_SEQUENCE_ID)) if sequence_list is None: if suspend_time != self._suspend_until: self.suspended = suspend_time changed = True else: for sequence in (self.get_sequence(sqid) for sqid in sequence_list): changed |= sequence.service_suspend(data, stime) if changed: self.request_update(True) return changed def service_adjust_time(self, data: MappingProxyType, stime: datetime) -> bool: """Handler for the adjust_time service call""" # pylint: disable=too-many-nested-blocks changed = False zone_list: list[int] = data.get(CONF_ZONES) sequence_list = self.decode_sequence_id(stime, data.get(CONF_SEQUENCE_ID)) if sequence_list is None: for zone in self._zones: if check_item(zone.index, zone_list): if zone.service_adjust_time(data, stime): self.clear_zone_runs(zone) changed = True else: for sequence in (self.get_sequence(sqid) for sqid in sequence_list): changed |= sequence.service_adjust_time(data, stime) if changed: self.request_update(True) return changed def service_manual_run(self, data: MappingProxyType, stime: datetime) -> None: """Handler for the manual_run service call""" sequence_id = data.get(CONF_SEQUENCE_ID, None) if sequence_id is None: zone_list: list[int] = data.get(CONF_ZONES, None) for zone in self._zones: if zone_list is None or zone.index + 1 in zone_list: zone.service_manual_run(data, stime) else: if (sequence := self.get_sequence(sequence_id - 1)) is not None: sequence.service_manual_run(data, stime) else: self._coordinator.logger.log_invalid_sequence(stime, self, sequence_id) def service_cancel(self, data: MappingProxyType, stime: datetime) -> bool: """Handler for the cancel service call""" changed = False zone_list: list[int] = data.get(CONF_ZONES, None) sequence_list = self.decode_sequence_id(stime, data.get(CONF_SEQUENCE_ID)) if sequence_list is None: for zone in self._zones: if zone_list is None or zone.index + 1 in zone_list: zone.service_cancel(data, stime) changed = True else: for sequence in (self.get_sequence(sqid) for sqid in sequence_list): changed |= sequence.service_cancel(data, stime) if changed: self.request_update(True) return changed def service_pause(self, data: MappingProxyType, stime: datetime) -> bool: """Handler for the pause service call""" changed = False sequence_list = self.decode_sequence_id(stime, data.get(CONF_SEQUENCE_ID)) if sequence_list is not None: for sequence in (self.get_sequence(sqid) for sqid in sequence_list): changed |= sequence.service_pause(data, stime) return changed def service_resume(self, data: MappingProxyType, stime: datetime) -> bool: """Handler for the resume service call""" changed = False sequence_list = self.decode_sequence_id(stime, data.get(CONF_SEQUENCE_ID)) if sequence_list is not None: for sequence in (self.get_sequence(sqid) for sqid in sequence_list): changed |= sequence.service_resume(data, stime) return changed class IUEvent: """This class represents a single event""" def __init__(self) -> None: # Private variables self._time: datetime = None self._controller: int = None self._zone: int = None self._state: bool = None self._crumbs: str = None def __eq__(self, other: "IUEvent") -> bool: return ( self._time == other._time and self._controller == other.controller and self._zone == other.zone and self._state == other.state ) def __str__(self) -> str: return ( f"- {{t: '{dt2lstr(self._time)}', " f"c: {self._controller}, " f"z: {self._zone}, " f"s: {str(int(self._state))}}}" ) @property def time(self) -> datetime: """Return the time property""" return self._time @property def controller(self) -> int: """Return the controller property""" return self._controller @property def zone(self) -> int: """Return the zone property""" return self._zone @property def state(self) -> bool: """Return the state property""" return self._state @property def crumbs(self) -> str: """Return the tracking information""" return self._crumbs def load(self, config: OrderedDict) -> "IUEvent": """Initialise from a config""" self._time: datetime = wash_dt(dt.as_utc(config["t"])) self._controller: int = config["c"] self._zone: int = config["z"] self._state: bool = config["s"] return self def load2( self, stime: datetime, controller: int, zone: int, state: bool, crumbs: str ) -> "IUEvent": """Initialise from individual components""" # pylint: disable=too-many-arguments self._time = stime self._controller = controller self._zone = zone self._state = state self._crumbs = crumbs return self class IUTest(IUBase): """This class represents a single test. Contains a list of expected results.""" # pylint: disable=too-many-instance-attributes def __init__(self, test_index: int, speed: float) -> None: # Passed parameters super().__init__(test_index) self._speed = speed # Config parameters self._name: str = None self._start: datetime = None self._end: datetime = None self._results: list[IUEvent] = [] # Private variables self._current_result: int = 0 self._events: int = 0 self._checks: int = 0 self._errors: int = 0 self._perf_mon: int = 0 self._delta: timedelta = None self._test_time: float = 0 @property def name(self) -> str: """Return the friendly name for this test""" return self._name @property def start(self) -> datetime: """Return the start time for this test""" return self._start @property def end(self) -> datetime: """Return the end time for this test""" return self._end @property def events(self) -> int: """Return the number of events received""" return self._events @property def checks(self) -> int: """Return the number of checks performed""" return self._checks @property def errors(self) -> int: """Return the number of errors identified""" return self._errors @property def test_time(self) -> float: """Return the test run time""" return self._test_time @property def virtual_duration(self) -> timedelta: """Return the real duration""" return (self._end - self._start) / self._speed @property def total_results(self) -> int: """Return the number of expected results from the test""" return len(self._results) def is_finished(self, atime) -> bool: """Indicate if this test has finished""" return self.virtual_time(atime) >= self._end def next_result(self) -> IUEvent: """Return the next result""" if self._current_result < len(self._results): result = self._results[self._current_result] self._current_result += 1 return result return None def check_result(self, result: IUEvent, event: IUEvent) -> bool: """Compare the expected result and the event""" self._events += 1 if result is not None: self._checks += 1 if result != event: self._errors += 1 return False else: return False return True def clear(self) -> None: """Remove all the results""" self._results.clear() def load(self, config: OrderedDict): """Load the configuration""" self.clear() self._start = wash_dt(dt.as_utc(config[CONF_START])) self._end = wash_dt(dt.as_utc(config[CONF_END])) self._name = config.get(CONF_NAME, None) if CONF_RESULTS in config: for result in config[CONF_RESULTS]: self._results.append(IUEvent().load(result)) return self def begin_test(self, atime: datetime) -> None: """Start test""" self._delta = atime - self._start self._perf_mon = tm.perf_counter() self._current_result = 0 self._events = 0 self._checks = 0 self._errors = 0 self._test_time = 0 def end_test(self) -> None: """Finalise test""" self._test_time = tm.perf_counter() - self._perf_mon def virtual_time(self, atime: datetime) -> datetime: """Return the virtual clock. For testing we can speed up time. This routine will return a virtual time based on the real time and the duration from start. It is in effect a test warp speed""" virtual_start: datetime = atime - self._delta actual_duration: float = (virtual_start - self._start).total_seconds() virtual_duration: float = actual_duration * self._speed vtime = self._start + timedelta(seconds=virtual_duration) # The conversion may not be exact due to the internal precision # of the compiler particularly at high speed values. Compensate # by rounding if the value is very close to an internal boundary vtime_rounded = round_dt(vtime) if abs(vtime - vtime_rounded) < timedelta(microseconds=100000): return vtime_rounded return vtime def actual_time(self, stime: datetime) -> datetime: """Return the actual time from the virtual time""" virtual_duration = (stime - self._start).total_seconds() actual_duration = virtual_duration / self._speed return self._start + self._delta + timedelta(seconds=actual_duration) class IUTester: """Irrigation Unlimited testing class""" # pylint: disable=too-many-instance-attributes, too-many-public-methods def __init__(self, coordinator: "IUCoordinator") -> None: # Passed parameters self._coordinator = coordinator # Config parameters self._enabled: bool = False self._speed: float = None self._output_events: bool = None self._show_log: bool = None self._autoplay: bool = None # Private variables self._tests: list[IUTest] = [] self._test_initialised = False self._running_test: int = None self._last_test: int = None self._autoplay_initialised: bool = False self._ticker: datetime = None self._tests_completed: set[int] = set() self.load(None) @property def enabled(self) -> bool: """Return the enabled property""" return self._enabled @property def speed(self) -> float: """Return the test speed""" return self._speed @property def is_testing(self) -> bool: """Indicate if this test is running""" return self._is_testing() @property def tests(self) -> "list[IUTest]": """Return the list of tests to perform""" return self._tests @property def current_test(self) -> IUTest: """Returns the current test""" if self._running_test is not None and self._running_test < len(self._tests): return self._tests[self._running_test] return None @property def last_test(self) -> IUTest: """Returns the last test that was run""" if self._last_test is not None and self._last_test < len(self._tests): return self._tests[self._last_test] return None @property def total_events(self) -> int: """Returns the total number of events received""" result: int = 0 for test in self._tests: result += test.events return result @property def total_checks(self) -> int: """Returns the total number of checks performed""" result: int = 0 for test in self._tests: result += test.checks return result @property def total_errors(self) -> int: """Returns the number of errors detected""" result: int = 0 for test in self._tests: result += test.errors return result @property def total_time(self) -> float: """Returns the total amount of time to run tests""" result: float = 0 for test in self._tests: result += test.test_time return result @property def total_tests(self) -> int: """Returns the number of tests to run""" return len(self._tests) @property def total_virtual_duration(self) -> timedelta: """Returns the real time duration of the tests""" result = timedelta(0) for test in self._tests: result += test.virtual_duration return result @property def total_results(self) -> int: """Returns the total number of results expected""" result: int = 0 for test in self._tests: result += test.total_results return result @property def tests_completed(self) -> int: """Return the number of tests completed""" return len(self._tests_completed) @property def ticker(self) -> datetime: """Return the tester clock""" return self._ticker @ticker.setter def ticker(self, value: datetime) -> None: """Set the tester clock""" self._ticker = value def virtual_time(self, atime: datetime) -> datetime: """Convert actual time to virtual time""" if self.is_testing: return self.current_test.virtual_time(atime) return atime def actual_time(self, stime: datetime) -> datetime: """Convert virtual time to actual time""" if self.is_testing: return self.current_test.actual_time(stime) return stime def start_test(self, test_no: int, atime: datetime) -> IUTest: """Start the test""" self._ticker = atime if 0 < test_no <= len(self._tests): self._running_test = test_no - 1 # 0-based test = self._tests[self._running_test] test.begin_test(atime) if self._show_log: self._coordinator.logger.log_test_start( test.virtual_time(atime), test, INFO ) else: self._coordinator.logger.log_test_start(test.virtual_time(atime), test) self._test_initialised = False else: self._running_test = None return self.current_test def end_test(self, atime: datetime) -> None: """Finish the test""" test = self.current_test if test is not None: test.end_test() if self._show_log: self._coordinator.logger.log_test_end( test.virtual_time(atime), test, INFO ) else: self._coordinator.logger.log_test_end(test.virtual_time(atime), test) self._tests_completed.add(self._running_test) self._last_test = self._running_test self._running_test = None def next_test(self, atime: datetime) -> IUTest: """Run the next test""" current = self._running_test # This is 0-based self.end_test(atime) return self.start_test(current + 2, atime) # This takes 1-based def _is_testing(self) -> bool: return self._enabled and self._running_test is not None def clear(self) -> None: """Reset the tester""" # Private variables self._tests.clear() self._test_initialised = False self._running_test = None self._last_test = None self._autoplay_initialised = False self._ticker: datetime = None self._tests_completed.clear() def load(self, config: OrderedDict) -> "IUTester": """Load config data for the tester""" # Config parameters self.clear() if config is None: config = {} self._enabled: bool = config.get(CONF_ENABLED, False) self._speed: float = config.get(CONF_SPEED, DEFAULT_TEST_SPEED) self._output_events: bool = config.get(CONF_OUTPUT_EVENTS, False) self._show_log: bool = config.get(CONF_SHOW_LOG, True) self._autoplay: bool = config.get(CONF_AUTOPLAY, True) if CONF_TIMES in config: for tidx, test in enumerate(config[CONF_TIMES]): self._tests.append(IUTest(tidx, self._speed).load(test)) return self def poll_test(self, atime: datetime, poll_func) -> None: """Polling is diverted here when testing is enabled. atime is the actual time but is converted to a virtual time for testing. The main polling function is then called with the modified time""" if self._autoplay and not self._autoplay_initialised: self.start_test(1, atime) self._autoplay_initialised = True test = self.current_test if test is not None: if not self._test_initialised: poll_func(test.start, True) self._test_initialised = True elif test.is_finished(atime): # End of current test poll_func(test.end, False) if self._autoplay: test = self.next_test(atime) if test is not None: poll_func(test.start, True) self._test_initialised = True else: # All tests finished if self._show_log: self._coordinator.logger.log_test_completed( self.total_checks, self.total_errors, INFO ) else: self._coordinator.logger.log_test_completed( self.total_checks, self.total_errors ) poll_func(atime, True) else: # End single test self.end_test(atime) else: # Continue existing test poll_func(test.virtual_time(atime)) else: # Out of tests to run poll_func(atime) def entity_state_changed(self, event: IUEvent) -> None: """Called when an entity has changed state""" def check_state(event: IUEvent): """Check the event against the next result""" test = self.current_test if test is not None: result = test.next_result() if not test.check_result(result, event): if not self._output_events: if self._show_log: self._coordinator.logger.log_test_error( test, event, result, ERROR ) else: self._coordinator.logger.log_test_error(test, event, result) if not self._output_events: if self._show_log: self._coordinator.logger.log_event(event, INFO) else: self._coordinator.logger.log_event(event) else: print(str(event)) check_state(event) class IULogger: """Irrigation Unlimited logger class""" # pylint: disable=too-many-public-methods def __init__(self, logger: Logger) -> None: # Passed parameters self._logger = logger # Config parameters # Private variables def load(self, config: OrderedDict) -> "IULogger": """Load config data for the tester""" if config is None: config = {} return self def _output(self, level: int, msg: str) -> None: """Send out the message""" self._logger.log(level, msg) def _format( self, level, area: str, stime: datetime = None, data: str = None ) -> None: """Format and send out message""" msg = area if stime is not None: msg += f" [{dt2lstr(stime)}]" if data is not None: msg += " " + data self._output(level, msg) def log_start(self, stime: datetime) -> None: """Message for system clock starting""" self._format(DEBUG, "START", stime) def log_stop(self, stime: datetime) -> None: """Message for system clock stopping""" self._format(DEBUG, "STOP", stime) def log_load(self, data: OrderedDict) -> None: """Message that the config is loaded""" # pylint: disable=unused-argument self._format(DEBUG, "LOAD") def log_initialised(self) -> None: """Message that the system is initialised and ready to go""" self._format(DEBUG, "INITIALISED") def log_event(self, event: IUEvent, level=DEBUG) -> None: """Message that an event has occurred - controller or zone turning on or off""" if len(event.crumbs) != 0: result = ( f"controller: {event.controller:d}, " f"zone: {event.zone:d}, " f"state: {str(int(event.state))}, " f"data: {event.crumbs}" ) else: result = ( f"controller: {event.controller:d}, " f"zone: {event.zone:d}, " f"state: {str(int(event.state))}" ) self._format(level, "EVENT", event.time, result) def log_service_call( self, service: str, stime: datetime, controller: IUController, zone: IUZone, sequence: IUSequence, data: MappingProxyType, level=INFO, ) -> None: """Message that we have received a service call""" # pylint: disable=too-many-arguments idl = IUBase.idl([controller, zone, sequence], "0", 1) self._format( level, "CALL", stime, f"service: {service}, " f"controller: {idl[0]}, " f"zone: {idl[1]}, " f"sequence: {idl[2]}, " f"data: {json.dumps(data, default=str)}", ) def log_register_entity( # pylint: disable=too-many-arguments self, stime: datetime, controller: IUController, zone: IUZone, sequence: IUSequence, entity: Entity, ) -> None: """Message that HA has registered an entity""" idl = IUBase.idl([controller, zone, sequence], "0", 1) self._format( DEBUG, "REGISTER", stime, f"controller: {idl[0]}, " f"zone: {idl[1]}, " f"sequence: {idl[2]}, " f"entity: {entity.entity_id}", ) def log_deregister_entity( # pylint: disable=too-many-arguments self, stime: datetime, controller: IUController, zone: IUZone, sequence: IUSequence, entity: Entity, ) -> None: """Message that HA is removing an entity""" idl = IUBase.idl([controller, zone, sequence], "0", 1) self._format( DEBUG, "DEREGISTER", stime, f"controller: {idl[0]}, " f"zone: {idl[1]}, " f"sequence: {idl[2]}, " f"entity: {entity.entity_id}", ) def log_test_start(self, vtime: datetime, test: IUTest, level=DEBUG) -> None: """Message that a test is starting""" self._format( level, "TEST_START", vtime, f"test: {test.index + 1:d}, " f"start: {dt2lstr(test.start)}, " f"end: {dt2lstr(test.end)}", ) def log_test_end(self, vtime: datetime, test: IUTest, level=DEBUG) -> None: """Message that a test has finished""" self._format(level, "TEST_END", vtime, f"test: {test.index + 1:d}") def log_test_error( self, test: IUTest, actual: IUEvent, expected: IUEvent, level=DEBUG ) -> None: """Message that an event did not meet expected result""" self._format( level, "TEST_ERROR", None, f"test: {test.index + 1:d}, " f"actual: {str(actual)}, " f"expected: {str(expected)}", ) def log_test_completed(self, checks: int, errors: int, level=DEBUG) -> None: """Message that all tests have been completed""" self._format( level, "TEST_COMPLETED", None, f"(Idle): checks: {checks:d}, errors: {errors:d}", ) def log_sequence_entity(self, vtime: datetime, level=WARNING) -> None: """Warn that a service call involved a sequence but was not directed at the controller""" self._format(level, "ENTITY", vtime, "Sequence specified but entity_id is zone") def log_invalid_sequence( self, vtime: datetime, controller: IUController, sequence_id: int, level=WARNING ) -> None: """Warn that a service call with a sequence_id is invalid""" self._format( level, "SEQUENCE_ID", vtime, f"Invalid sequence id: " f"controller: {IUBase.ids(controller, '0', 1)}, " f"sequence: {sequence_id}", ) def log_invalid_restore_data(self, msg: str, data: str, level=WARNING) -> None: """Warn invalid restore data""" self._format(level, "RESTORE", None, f"Invalid data: msg: {msg}, data: {data}") def log_incomplete_cycle( self, controller: IUController, zone: IUZone, sequence: IUSequence, sequence_zone: IUSequenceZone, level=WARNING, ) -> None: """Warn that a cycle did not complete""" # pylint: disable=too-many-arguments idl = IUBase.idl([controller, zone, sequence, sequence_zone], "-", 1) self._format( level, "INCOMPLETE", None, f"Cycle did not complete: " f"controller: {idl[0]}, " f"zone: {idl[1]}, " f"sequence: {idl[2]}, " f"sequence_zone: {idl[3]}", ) def log_sync_error( self, vtime: datetime, expected: str, found: str, switch_entity_id: str, level=WARNING, ) -> None: """Warn that switch and entity are out of sync""" self._format( level, "SYNCHRONISATION", vtime, f"Switch does not match current state: " f"expected: {expected}, " f"found: {found}, " f"switch: {switch_entity_id}", ) def log_switch_error( self, vtime: datetime, expected: str, found: str, switch_entity_id: str, level=ERROR, ) -> None: """Warn that switch(s) was unable to be set""" self._format( level, "SWITCH_ERROR", vtime, f"Unable to set switch state: " f"expected: {expected}, " f"found: {found}, " f"switch: {switch_entity_id}", ) def log_duplicate_id( self, controller: IUController, zone: IUZone, schedule: IUSchedule, level=WARNING, ) -> None: """Warn a controller/zone/schedule has a duplicate id""" idl = IUBase.idl([controller, zone, schedule], "0", 1) if not zone and not schedule: self._format( level, "DUPLICATE_ID", None, f"Duplicate Controller ID: " f"controller: {idl[0]}, " f"controller_id: {controller.controller_id}", ) elif zone and not schedule: self._format( level, "DUPLICATE_ID", None, f"Duplicate Zone ID: " f"controller: {idl[0]}, " f"controller_id: {controller.controller_id}, " f"zone: {idl[1]}, " f"zone_id: {zone.zone_id if zone else ''}", ) elif zone and schedule: self._format( level, "DUPLICATE_ID", None, f"Duplicate Schedule ID (zone): " f"controller: {idl[0]}, " f"controller_id: {controller.controller_id}, " f"zone: {idl[1]}, " f"zone_id: {zone.zone_id if zone else ''}, " f"schedule: {idl[2]}, " f"schedule_id: {schedule.schedule_id if schedule else ''}", ) else: # not zone and schedule self._format( level, "DUPLICATE_ID", None, f"Duplicate Schedule ID (sequence): " f"controller: {idl[0]}, " f"controller_id: {controller.controller_id}, " f"schedule: {idl[2]}, " f"schedule_id: {schedule.schedule_id if schedule else ''}", ) def log_orphan_id( self, controller: IUController, sequence: IUSequence, sequence_zone: IUSequenceZone, zone_id: str, level=WARNING, ) -> None: # pylint: disable=too-many-arguments """Warn a zone_id reference is orphaned""" idl = IUBase.idl([controller, sequence, sequence_zone], "0", 1) self._format( level, "ORPHAN_ID", None, f"Invalid reference ID: " f"controller: {idl[0]}, " f"sequence: {idl[1]}, " f"sequence_zone: {idl[2]}, " f"zone_id: {zone_id}", ) def log_invalid_crontab( self, stime: datetime, schedule: IUSchedule, expression: str, msg: str, level=ERROR, ) -> None: # pylint: disable=too-many-arguments """Warn that a crontab expression in the schedule is invalid""" self._format( level, "CRON", stime, f"schedule: {schedule.name}, " f"expression: {expression}, " f"error: {msg}", ) def log_invalid_meter_id( self, stime: datetime, entity_id: str, level=ERROR ) -> None: """Warn the volume meter is invalid""" self._format(level, "VOLUME_SENSOR", stime, f"entity_id: {entity_id}") def log_invalid_meter_value(self, stime: datetime, value: str, level=ERROR) -> None: """Warn the volume meter value is invalid""" self._format(level, "VOLUME_VALUE", stime, f"{value}") class IUClock: """Irrigation Unlimited Clock class""" # pylint: disable=too-many-instance-attributes def __init__( self, hass: HomeAssistant, coordinator: "IUCoordinator", action: Callable[[datetime], Awaitable[None]], ) -> None: # Pass parameters self._hass = hass self._coordinator = coordinator self._action = action # Private variables self._listener_job = HassJob(self._listener) self._remove_timer_listener: CALLBACK_TYPE = None self._tick_log = deque["datetime"](maxlen=DEFAULT_MAX_LOG_ENTRIES) self._next_tick: datetime = None self._fixed_clock = False self._show_log = False self._finalised = False @property def is_fixed(self) -> bool: """Return if the clock is fixed or variable""" return self._fixed_clock @property def next_tick(self) -> datetime: """Return the next anticipated scheduled tick. It may however be cancelled due to a service call""" return self._next_tick @property def tick_log(self) -> deque["datetime"]: """Return the tick history log""" return self._tick_log @property def show_log(self) -> bool: """Indicate if we should show the tick log""" return self._show_log def track_interval(self) -> timedelta: """Returns the system clock time interval""" track_time = SYSTEM_GRANULARITY / self._coordinator.tester.speed track_time *= 0.95 # Run clock slightly ahead of required to avoid skipping return min(timedelta(seconds=track_time), self._coordinator.refresh_interval) def start(self) -> None: """Start the system clock""" self.stop() now = dt.utcnow() self._schedule_next_poll(now) self._coordinator.logger.log_start(now) def stop(self) -> None: """Stop the system clock""" if self._remove_timer(): self._coordinator.logger.log_stop(dt.utcnow()) def next_awakening(self, atime: datetime) -> datetime: """Return the time for the next event""" if self._finalised: return utc_eot() if not self._coordinator.initialised: return atime + timedelta(seconds=5) if self._fixed_clock: return atime + self.track_interval() # Handle testing if self._coordinator.tester.is_testing: next_stime = self._coordinator.next_awakening() next_stime = min(next_stime, self._coordinator.tester.current_test.end) result = self._coordinator.tester.actual_time(next_stime) else: result = self._coordinator.next_awakening() # Midnight rollover if result == utc_eot() or ( dt.as_local(self._coordinator.tester.virtual_time(atime)).toordinal() != dt.as_local(self._coordinator.tester.virtual_time(result)).toordinal() ): local_tomorrow = dt.as_local( self._coordinator.tester.virtual_time(atime) ) + timedelta(days=1) local_midnight = local_tomorrow.replace( hour=0, minute=0, second=0, microsecond=0 ) result = dt.as_utc(self._coordinator.tester.actual_time(local_midnight)) # Sanity check if result < atime: result = atime + granularity_time() return result def _update_next_tick(self, atime: datetime) -> bool: """Update the next_tick variable""" if atime != self._next_tick: self._next_tick = atime if self._show_log: self._coordinator.request_update(False) return True return False def _add_to_log(self, atime: datetime) -> bool: """Add a time to the head of tick log""" if not self._fixed_clock and atime is not None: self._tick_log.appendleft(atime) if self._show_log: self._coordinator.request_update(False) return True return False def _remove_timer(self) -> None: """Remove the current timer. Return False if there is no timer currently active (clock is stopped)""" if self._remove_timer_listener is not None: self._remove_timer_listener() self._remove_timer_listener = None return True return False def _schedule_next_poll(self, atime: datetime) -> None: """Set the timer for the next update""" self._update_next_tick(self.next_awakening(atime)) self._remove_timer_listener = async_track_point_in_utc_time( self._hass, self._listener_job, self._next_tick ) async def _listener(self, atime: datetime) -> None: """Listener for the timer event""" self._add_to_log(atime) try: await self._action(atime) finally: self._schedule_next_poll(atime) if self._show_log: self._coordinator.update_sensor(atime, False) def test_ticker_update(self, atime: datetime) -> bool: """Interface for testing unit when starting tick""" if self._update_next_tick(atime) and self._show_log: self._coordinator.update_sensor(atime, False) return True return False def test_ticker_fired(self, atime: datetime) -> bool: """Interface for testing unit when finishing tick""" if self._add_to_log(atime) and self._show_log: self._coordinator.update_sensor(atime, False) return True return False def rearm(self, atime: datetime) -> None: """Rearm the timer""" if not self._fixed_clock and self._remove_timer(): self._schedule_next_poll(atime) if self._show_log: self._coordinator.update_sensor(atime, False) def load(self, config: OrderedDict) -> "IUClock": """Load config data""" if config is not None and CONF_CLOCK in config: clock_conf: dict = config[CONF_CLOCK] self._fixed_clock = clock_conf[CONF_MODE] == CONF_FIXED self._show_log = clock_conf[CONF_SHOW_LOG] if ( max_entries := clock_conf.get(CONF_MAX_LOG_ENTRIES) ) is not None and max_entries != self._tick_log.maxlen: self._tick_log = deque["datetime"](maxlen=max_entries) if not self._fixed_clock: global SYSTEM_GRANULARITY # pylint: disable=global-statement SYSTEM_GRANULARITY = 1 return self def finalise(self): """finalise this unit""" if not self._finalised: self._remove_timer() self._finalised = True class IUCoordinator: """Irrigation Unlimited Coordinator class""" # pylint: disable=too-many-instance-attributes, too-many-public-methods def __init__(self, hass: HomeAssistant) -> None: # Passed parameters self._hass = hass # Config parameters self._refresh_interval: timedelta = None self._sync_switches: bool = True self._rename_entities = False self._extended_config = False # Private variables self._controllers: list[IUController] = [] self._is_on: bool = False self._sensor_update_required: bool = False self._sensor_last_update: datetime = None self._dirty: bool = True self._component: Entity = None self._initialised: bool = False self._last_tick: datetime = None self._last_muster: datetime = None self._muster_required: bool = False self._remove_shutdown_listener: CALLBACK_TYPE = None self._logger = IULogger(_LOGGER) self._tester = IUTester(self) self._clock = IUClock(self._hass, self, self._async_timer) self._history = IUHistory(self._hass, self.service_history) self._restored_from_configuration: bool = False self._finalised = False @property def entity_id(self) -> str: """Return the entity_id for the coordinator""" return f"{DOMAIN}.{COORDINATOR}" @property def controllers(self) -> "list[IUController]": """Return the list of controllers""" return self._controllers @property def clock(self) -> IUClock: """Return the clock object""" return self._clock @property def tester(self) -> IUTester: """Return the tester object""" return self._tester @property def logger(self) -> IULogger: """Return the logger object""" return self._logger @property def history(self) -> IUHistory: """Return the history object""" return self._history @property def is_setup(self) -> bool: """Indicate if system is setup""" return self._is_setup() @property def component(self) -> Entity: """Return the HA integration entity""" return self._component @property def refresh_interval(self) -> timedelta: """Returns the refresh_interval property""" return self._refresh_interval @property def initialised(self) -> bool: """Return True if we are initialised""" return self._initialised @property def finalised(self) -> bool: """Return True if we have been finalised""" return self._finalised @property def configuration(self) -> str: """Return the system configuration as JSON""" return json.dumps(self.as_dict(self._extended_config), cls=IUJSONEncoder) @property def restored_from_configuration(self) -> bool: """Return if the system has been restored from coordinator date""" return self._restored_from_configuration @restored_from_configuration.setter def restored_from_configuration(self, value: bool) -> None: """Flag the system has been restored from coordinator data""" self._restored_from_configuration = value @property def rename_entities(self) -> bool: """Indicate if entity renaming is allowed""" return self._rename_entities def _is_setup(self) -> bool: """Wait for sensors to be setup""" all_setup: bool = self._hass.is_running and self._component is not None for controller in self._controllers: all_setup = all_setup and controller.is_setup return all_setup def initialise(self) -> None: """Flag we need to re-initialise. Called by the testing unit when starting a new test""" self._initialised = False def add(self, controller: IUController) -> IUController: """Add a new controller to the system""" self._controllers.append(controller) return controller def get(self, index: int) -> IUController: """Return the controller by index""" if index is not None and index >= 0 and index < len(self._controllers): return self._controllers[index] return None def find_add(self, coordinator: "IUCoordinator", index: int) -> IUController: """Locate and create if required a controller""" if index >= len(self._controllers): return self.add(IUController(self._hass, coordinator, index)) return self._controllers[index] def clear(self) -> None: """Reset the coordinator""" # Don't clear controllers # self._controllers.clear() self._is_on: bool = False def load(self, config: OrderedDict) -> "IUCoordinator": """Load config data for the system""" self.clear() global SYSTEM_GRANULARITY # pylint: disable=global-statement SYSTEM_GRANULARITY = config.get(CONF_GRANULARITY, DEFAULT_GRANULARITY) self._clock.load(config) self._refresh_interval = timedelta( seconds=config.get(CONF_REFRESH_INTERVAL, DEFAULT_REFRESH_INTERVAL) ) self._sync_switches = config.get(CONF_SYNC_SWITCHES, True) self._rename_entities = config.get(CONF_RENAME_ENTITIES, self._rename_entities) self._extended_config = config.get(CONF_EXTENDED_CONFIG, self._extended_config) cidx: int = 0 for cidx, controller_config in enumerate(config[CONF_CONTROLLERS]): self.find_add(self, cidx).load(controller_config) while cidx < len(self._controllers) - 1: self._controllers.pop().finalise(True) self._tester.load(config.get(CONF_TESTING)) self._logger.load(config.get(CONF_LOGGING)) self._dirty = True self._muster_required = True self.request_update(False) self._logger.log_load(config) self._history.load(config, self._clock.is_fixed) self.check_links() return self def as_dict(self, extended=False) -> OrderedDict: """Returns the coordinator as a dict""" result = OrderedDict() result[CONF_VERSION] = "1.0.1" result[CONF_CONTROLLERS] = [ctr.as_dict(extended) for ctr in self._controllers] return result def muster(self, stime: datetime, force: bool) -> IURQStatus: """Calculate run times for system""" status = IURQStatus(0) self._history.muster(stime, force) for controller in self._controllers: status |= controller.muster(stime, force) self._dirty = False return status def check_run(self, stime: datetime) -> bool: """Update run status. Return True if any entities in the tree have changed.""" status_changed: bool = False for controller in self._controllers: status_changed |= controller.check_run(stime) return status_changed def check_links(self) -> bool: """Check inter object links""" # pylint: disable=too-many-branches result = True controller_ids = set() for controller in self._controllers: if controller.controller_id in controller_ids: self._logger.log_duplicate_id(controller, None, None) result = False else: controller_ids.add(controller.controller_id) if not controller.check_links(): result = False schedule_ids = set() for controller in self._controllers: for zone in controller.zones: for schedule in zone.schedules: if schedule.schedule_id is not None: if schedule.schedule_id in schedule_ids: self._logger.log_duplicate_id(controller, zone, schedule) result = False else: schedule_ids.add(schedule.schedule_id) for sequence in controller.sequences: for schedule in sequence.schedules: if schedule.schedule_id is not None: if schedule.schedule_id in schedule_ids: self._logger.log_duplicate_id(controller, None, schedule) result = False else: schedule_ids.add(schedule.schedule_id) return result def request_update(self, deep: bool) -> None: """Flag the sensor needs an update. The actual update is done in update_sensor""" self._sensor_update_required = True if deep: for controller in self._controllers: controller.request_update(True) def update_sensor(self, stime: datetime, deep: bool = True) -> None: """Update home assistant sensors if required""" stime = wash_dt(stime, 1) if deep: for controller in self._controllers: controller.update_sensor(stime) if self._component is not None and self._sensor_update_required: self._component.schedule_update_ha_state() self._sensor_update_required = False self._sensor_last_update = stime def poll(self, vtime: datetime, force: bool = False) -> None: """Poll the system for changes, updates and refreshes. vtime is the virtual time if in testing mode, if not then it is the actual time""" wtime: datetime = wash_dt(vtime) if (wtime != self._last_muster) or self._muster_required or force: if not self.muster(wtime, force).is_empty(): self.check_run(wtime) self._muster_required = False self._last_muster = wtime self.update_sensor(vtime) def poll_main(self, atime: datetime, force: bool = False) -> None: """Post initialisation worker. Divert to testing unit if enabled. atime (actual time) is the real world clock""" if self._tester.enabled: self._tester.poll_test(atime, self.poll) else: self.poll(atime, force) def timer(self, atime: datetime) -> None: """System clock entry point""" self._last_tick = atime if self._initialised: self.poll_main(atime) else: self._initialised = self.is_setup if self._initialised: self._logger.log_initialised() self.check_switches(self._sync_switches, atime) self.request_update(True) self.poll_main(atime) async def _async_timer(self, atime: datetime) -> None: """Timer callback""" self.timer(atime) def finalise(self, turn_off: bool) -> None: """Tear down the system and clean up""" if not self._finalised: for controller in self._controllers: controller.finalise(turn_off) self._clock.finalise() self._history.finalise() self._finalised = True async def _async_shutdown_listener(self, event: HAEvent) -> None: """Home Assistant is shutting down. Attempting to turn off any running valves is unlikely to work as the underlying libraries are also in a state of shutdown (zwave, zigbee, WiFi). Should this situation change then set the following to True.""" # pylint: disable=unused-argument self.finalise(False) def listen(self) -> None: """Listen for events. This appears to be the earliest signal HA sends to tell us we are going down. It would be nice to have some sort of heads up while everything is still running. This would enable us to tidy up.""" self._remove_shutdown_listener = self._hass.bus.async_listen_once( EVENT_HOMEASSISTANT_STOP, self._async_shutdown_listener ) def _replay_last_timer(self, atime: datetime) -> None: """Update after a service call""" self.request_update(False) self._muster_required = True if self._tester.is_testing: tick = self._tester.ticker elif self._last_tick is not None: tick = self._last_tick else: return self.timer(tick) self._clock.rearm(atime) def next_awakening(self) -> datetime: """Return the next event time""" dates: list[datetime] = [utc_eot()] dates.extend(ctr.next_awakening() for ctr in self._controllers) return min(d for d in dates if d is not None) def check_switches(self, resync: bool, stime: datetime) -> list[str]: """Check if entities match current status""" result: list[str] = [] for controller in self._controllers: result.extend(controller.check_switch(resync, stime)) return result def notify_sequence( self, event_type: str, controller: IUController, sequence: IUSequence, schedule: IUSchedule, sequence_run: IUSequenceRun, ) -> None: """Send out a notification for start/finish to a sequence""" # pylint: disable=too-many-arguments data = { CONF_CONTROLLER: {CONF_INDEX: controller.index, CONF_NAME: controller.name}, CONF_SEQUENCE: {CONF_INDEX: sequence.index, CONF_NAME: sequence.name}, CONF_RUN: {CONF_DURATION: round(sequence_run.total_time.total_seconds())}, } if schedule is not None: data[CONF_SCHEDULE] = {CONF_INDEX: schedule.index, CONF_NAME: schedule.name} else: data[CONF_SCHEDULE] = {CONF_INDEX: None, CONF_NAME: RES_MANUAL} self._hass.bus.fire(f"{DOMAIN}_{event_type}", data) def notify_switch( self, event_type: str, found: str, expected: str, entity_id: str, controller: IUController, zone: IUZone, ) -> None: """Send out notification about switch resync event""" # pylint: disable=too-many-arguments data = { CONF_EXPECTED: expected, CONF_FOUND: found, CONF_ENTITY_ID: entity_id, CONF_CONTROLLER: {CONF_INDEX: controller.index, CONF_NAME: controller.name}, } if zone is not None: data[CONF_ZONE] = {CONF_INDEX: zone.index, CONF_NAME: zone.name} else: data[CONF_ZONE] = {CONF_INDEX: None, CONF_NAME: None} self._hass.bus.fire(f"{DOMAIN}_{event_type}", data) def register_entity( self, controller: IUController, zone: IUZone, sequence: IUSequence, entity: Entity, ) -> None: """A HA entity has been registered""" stime = self.service_time() if sequence is not None: sequence.sequence_sensor = entity elif zone is not None: zone.zone_sensor = entity elif controller is not None: controller.master_sensor = entity else: self._component = entity self._logger.log_register_entity(stime, controller, zone, sequence, entity) def deregister_entity( self, controller: IUController, zone: IUZone, sequence: IUSequence, entity: Entity, ) -> None: """A HA entity has been removed""" stime = self.service_time() if sequence is not None: sequence.finalise() sequence.sequence_sensor = None elif zone is not None: zone.finalise(True) zone.zone_sensor = None elif controller is not None: controller.finalise(True) controller.master_sensor = None else: self.finalise(True) self._component = None self._logger.log_deregister_entity(stime, controller, zone, sequence, entity) def service_time(self) -> datetime: """Return a time midway between last and next future tick""" if self._tester.is_testing: result = self._tester.ticker result = self._tester.virtual_time(result) elif self._clock.is_fixed and self._last_tick is not None: result = self._last_tick + self._clock.track_interval() / 2 else: result = dt.utcnow() return wash_dt(result) def service_load_schedule(self, data: MappingProxyType) -> None: """Handle the load_schedule service call""" # pylint: disable=too-many-nested-blocks for controller in self._controllers: for zone in controller.zones: for schedule in zone.schedules: if schedule.schedule_id == data[CONF_SCHEDULE_ID]: schedule.load(data, True) runs: list[IURun] = [] for run in zone.runs: if not run.is_sequence and run.schedule == schedule: runs.append(run) for run in runs: zone.runs.remove_run(run) return for sequence in controller.sequences: for schedule in sequence.schedules: if schedule.schedule_id == data[CONF_SCHEDULE_ID]: schedule.load(data, True) sequence.runs.clear_runs() return def service_call( self, service: str, controller: IUController, zone: IUZone, sequence: IUSequence, data: MappingProxyType, ) -> None: """Entry point for all service calls.""" # pylint: disable=too-many-branches,too-many-arguments,too-many-statements changed = True stime = self.service_time() data1 = dict(data) if service in [SERVICE_ENABLE, SERVICE_DISABLE, SERVICE_TOGGLE]: if sequence is not None: changed = sequence.service_edt(data1, stime, service) elif zone is not None: if changed := zone.service_edt(data1, stime, service): controller.clear_zone_runs(zone) else: changed = controller.service_edt(data1, stime, service) elif service == SERVICE_SUSPEND: render_positive_time_period(data1, CONF_FOR) if sequence is not None: changed = sequence.service_suspend(data1, stime) elif zone is not None: if changed := zone.service_suspend(data1, stime): controller.clear_zone_runs(zone) else: changed = controller.service_suspend(data1, stime) elif service == SERVICE_CANCEL: if sequence is not None: changed = sequence.service_cancel(data1, stime) elif zone is not None: zone.service_cancel(data1, stime) else: changed = controller.service_cancel(data1, stime) elif service == SERVICE_TIME_ADJUST: render_positive_time_period(data1, CONF_ACTUAL) render_positive_time_period(data1, CONF_INCREASE) render_positive_time_period(data1, CONF_DECREASE) render_positive_time_period(data1, CONF_MINIMUM) render_positive_time_period(data1, CONF_MAXIMUM) render_positive_float(self._hass, data1, CONF_PERCENTAGE) if sequence is not None: changed = sequence.service_adjust_time(data1, stime) elif zone is not None: if changed := zone.service_adjust_time(data1, stime): controller.clear_zone_runs(zone) else: changed = controller.service_adjust_time(data1, stime) elif service == SERVICE_MANUAL_RUN: render_positive_time_period(data1, CONF_TIME) if sequence is not None: sequence.service_manual_run(data1, stime) elif zone is not None: zone.service_manual_run(data1, stime) else: controller.service_manual_run(data1, stime) elif service == SERVICE_SKIP: if sequence is not None: sequence.service_skip(data1, stime) elif service == SERVICE_PAUSE: if sequence is not None: changed = sequence.service_pause(data1, stime) elif zone is not None: pass else: controller.service_pause(data1, stime) elif service == SERVICE_RESUME: if sequence is not None: changed = sequence.service_resume(data1, stime) elif zone is not None: pass else: controller.service_resume(data1, stime) elif service == SERVICE_LOAD_SCHEDULE: render_positive_time_period(data1, CONF_DURATION) self.service_load_schedule(data1) else: return if changed: self._last_tick = stime self._logger.log_service_call( service, stime, controller, zone, sequence, data1 ) self._replay_last_timer(stime) else: self._logger.log_service_call( service, stime, controller, zone, sequence, data1, DEBUG ) def service_history(self, entity_ids: set[str]) -> None: """History service call entry point. The history has changed and the sensors require an update""" for controller in self._controllers: if controller.entity_id in entity_ids: controller.request_update(False) for zone in controller.zones: if zone.entity_id in entity_ids: zone.request_update() self.update_sensor(self.service_time()) def start_test(self, test_no: int) -> datetime: """Main entry to start a test""" self._last_tick = None self._last_muster = None next_time = dt.utcnow() if self._tester.start_test(test_no, next_time) is not None: self.timer(next_time) return next_time return None def status_changed( self, stime: datetime, controller: IUController, zone: IUZone, state: bool ) -> None: """Collection point for entities that have changed state""" if stime is None: stime = self.service_time() crumbs: str = "" if zone is not None: zone_id = zone.index + 1 if state is True: crumbs = zone.runs.current_run.crumbs else: zone_id = 0 event = IUEvent().load2(stime, controller.index + 1, zone_id, state, crumbs) self._tester.entity_state_changed(event) self.request_update(False)