Files
homeassistant_config/config/custom_components/irrigation_unlimited/irrigation_unlimited.py
2024-05-31 09:39:52 +02:00

6569 lines
225 KiB
Python

"""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)