diff --git a/README.md b/README.md index 1fe5bca..0a70359 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,9 @@ Zones also have an associated sensor which, like the master, shows on/off status 1. Unlimited controllers. 2. Unlimited zones. 3. Unlimited schedules. Schedule by absolute time or sun events (sunrise/sunset). Select by days of the week (mon/tue/wed...). Select by days in the month (1/2/3.../odd/even). Select by months in the year (jan/feb/mar...). Overlapped schedules. -4. Hardware independant. Use your own switches/valve controllers. -5. Software independant. Pure play python. +4. Unlimited sequences. Operate zones one at a time in a particular order with a delay in between. +5. Hardware independant. Use your own switches/valve controllers. +6. Software independant. Pure play python. *Practicle limitations will depend on your hardware. @@ -113,6 +114,7 @@ This is the controller or master object and manages a collection of zones. There | Name | Type | Default | Description | | ---- | ---- | ------- | ----------- | | `zones` | list | _[Zone Objects](#zone-objects)_ | Zone details (Must have at least one) | +| `sequences` | list | _[Sequence Objects](#sequence-objects)_ | Sequence details | | `name` | string | Controller _N_ | Friendly name for the controller | | `enabled` | bool | true | Enable/disable the controller | | `preamble` | time | '00:00' | The time master turns on before any zone turns on | @@ -126,6 +128,7 @@ The zone object manages a collection of schedules. There must be at least one zo | Name | Type | Default | Description | | ---- | ---- | ------- | ----------- | | `schedules` | list | _[Schedule Objects](#schedule-objects)_ | Schedule details (Must have at least one) | +| `zone_id` | string | _N_ | Zone reference. Used for sequencing. | | `name` | string | Zone _N_ | Friendly name for the zone | | `enabled` | bool | true | Enable/disable the zone | | `minimum` | time | '00:01' | The minimum run time | @@ -157,6 +160,26 @@ Leave the time value in the _[Schedule Objects](#schedule-objects)_ blank and ad | `before` | time | '00:00' | Time before the event | | `after` | time | '00:00' | Time after the event | +### Sequence Objects + +Sequences allow zones to run one at a time in a particular order with a delay in between. + +| Name | Type | Default | Description | +| ---- | ---- | ------- | ----------- | +| `schedules` | list | _[Schedule Objects](#schedule-objects)_ | Schedule details (Must have at least one). Note: `duration` is ignored | +| `zones` | list | _[Sequence Zone Objects](#sequence-zone-objects)_ | Zone details (Must have at least one) | +| `delay` | time | **Required** | Delay between zones | +| `name` | string | Run _N_ | Friendly name for the sequence | + +### Sequence Zone Objects + +The sequence zone is a reference to the actual zone defined in the _[Zone Objects](#zone-objects)_. Ensure the `zone_id`'s match between this object and the zone object. The zone may appear more than once in the case of a split run. + +| Name | Type | Default | Description | +| ---- | ---- | ------- | ----------- | +| `zone_id` | string | **Required** | Zone reference. This must match the `zone_id` in the _[Zone Objects](#zone-objects)_ | +| `duration` | time | **Required** | The length of time to run | + ### Testing Object The testing object is useful for running through a predetermined regime. Note: the `speed` value does _not_ alter the system clock in any way. It is accomplished by an internal 'virtual clock'. @@ -208,6 +231,38 @@ irrigation_unlimited: duration: '00:30' ~~~ +### Sequence example + +~~~yaml +# Example configuration.yaml entry +irrigation_unlimited: + controllers: + zones: + - name: "Front lawn" + entity_id: "switch.my_switch_1" + - name: "Vege patch" + entity_id: "switch.my_switch_2" + - name: "Flower bed" + entity_id: "switch.my_switch_3" + sequences: + - delay: "00:01" + schedules: + - name: "Sunrise" + time: + sun: "sunrise" + - name: "After sunset" + time: + sun: "sunset" + after: "00:30" + zones: + - zone_id: 1 + duration: "00:10" + - zone_id: 2 + duration: "00:02" + - zone_id: 3 + duration: "00:01" +~~~ + For a more comprehensive example refer to [here](./examples/all_the_bells_and_whistles.yaml). ## Services diff --git a/custom_components/irrigation_unlimited/__init__.py b/custom_components/irrigation_unlimited/__init__.py index edf0879..4569193 100644 --- a/custom_components/irrigation_unlimited/__init__.py +++ b/custom_components/irrigation_unlimited/__init__.py @@ -5,6 +5,7 @@ https://github.com/rgc99/irrigation_unlimited """ import logging +from typing import Optional import voluptuous as vol from homeassistant.helpers.entity_component import EntityComponent from homeassistant.core import Config, HomeAssistant @@ -14,6 +15,8 @@ CONF_ENTITY_ID, CONF_NAME, CONF_WEEKDAY, + CONF_DELAY, + CONF_ID, ) from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import async_load_platform @@ -50,6 +53,12 @@ CONF_START, CONF_END, MONTHS, + CONF_SHOW, + CONF_CONFIG, + CONF_TIMELINE, + CONF_ZONE_ID, + CONF_SEQUENCES, + CONF_ALL_ZONES_CONFIG, ) _LOGGER: logging.Logger = logging.getLogger(__package__) @@ -61,6 +70,13 @@ def _list_is_not_empty(value): return value +SHOW_SCHEMA = vol.Schema( + { + vol.Optional(CONF_CONFIG, False): cv.boolean, + vol.Optional(CONF_TIMELINE, False): cv.boolean, + } +) + SUN_SCHEMA = vol.Schema( { vol.Required(CONF_SUN): cv.sun_event, @@ -88,14 +104,50 @@ def _list_is_not_empty(value): ZONE_SCHEMA = vol.Schema( { - vol.Required(CONF_SCHEDULES, default={}): vol.All( - cv.ensure_list, [SCHEDULE_SCHEMA], _list_is_not_empty - ), + vol.Optional(CONF_SCHEDULES): vol.All(cv.ensure_list, [SCHEDULE_SCHEMA]), + vol.Optional(CONF_ZONE_ID): cv.string, vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_ENTITY_ID): cv.entity_id, vol.Optional(CONF_ENABLED): cv.boolean, vol.Optional(CONF_MINIMUM): cv.positive_time_period, vol.Optional(CONF_MAXIMUM): cv.positive_time_period, + vol.Optional(CONF_SHOW): vol.All(SHOW_SCHEMA), + } +) + +ALL_ZONES_SCHEMA = vol.Schema( + { + vol.Optional(CONF_SHOW): vol.All(SHOW_SCHEMA), + } +) + +SEQUENCE_SCHEDULE_SCHEMA = vol.Schema( + { + vol.Required(CONF_TIME): time_event, + vol.Optional(CONF_DURATION): cv.positive_time_period, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_WEEKDAY): cv.weekdays, + vol.Optional(CONF_MONTH): month_event, + vol.Optional(CONF_DAY): day_event, + } +) + +SEQUENCE_ZONE_SCHEMA = vol.Schema( + { + vol.Required(CONF_ZONE_ID): cv.positive_int, + vol.Required(CONF_DURATION): cv.positive_time_period, + } +) +SEQUENCE_SCHEMA = vol.Schema( + { + vol.Required(CONF_SCHEDULES, default={}): vol.All( + cv.ensure_list, [SEQUENCE_SCHEDULE_SCHEMA], _list_is_not_empty + ), + vol.Required(CONF_ZONES, default={}): vol.All( + cv.ensure_list, [SEQUENCE_ZONE_SCHEMA], _list_is_not_empty + ), + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_DELAY): cv.positive_time_period, } ) @@ -104,11 +156,15 @@ def _list_is_not_empty(value): vol.Required(CONF_ZONES): vol.All( cv.ensure_list, [ZONE_SCHEMA], _list_is_not_empty ), + vol.Optional(CONF_SEQUENCES): vol.All( + cv.ensure_list, [SEQUENCE_SCHEMA], _list_is_not_empty + ), vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_ENTITY_ID): cv.entity_id, vol.Optional(CONF_PREAMBLE): cv.positive_time_period, vol.Optional(CONF_POSTAMBLE): cv.positive_time_period, vol.Optional(CONF_ENABLED): cv.boolean, + vol.Optional(CONF_ALL_ZONES_CONFIG): vol.All(ALL_ZONES_SCHEMA), } ) diff --git a/custom_components/irrigation_unlimited/binary_sensor.py b/custom_components/irrigation_unlimited/binary_sensor.py index 3bc73ed..068b138 100644 --- a/custom_components/irrigation_unlimited/binary_sensor.py +++ b/custom_components/irrigation_unlimited/binary_sensor.py @@ -4,6 +4,7 @@ import homeassistant.util.dt as dt from datetime import datetime, timedelta from homeassistant.components import history +from custom_components.irrigation_unlimited.irrigation_unlimited import IUSchedule, IUZone import json from homeassistant.const import ( @@ -98,7 +99,7 @@ class IUMasterEntity(IUEntity): @property def unique_id(self): """Return a unique ID.""" - return f"c{self._controller.controller_index + 1}_m" + return f"c{self._controller.index + 1}_m" @property def name(self): @@ -130,14 +131,15 @@ def icon(self): def device_state_attributes(self): """Return the state attributes of the device.""" attr = {} - attr["index"] = self._controller.controller_index + attr["index"] = self._controller.index attr["enabled"] = self._controller.enabled attr["zone_count"] = len(self._controller._zones) attr["zones"] = "" current = self._controller.runs.current_run if current is not None: - attr["current_zone"] = current.index + 1 - attr["current_name"] = current.zone.name + if isinstance(current.parent, IUZone): + attr["current_zone"] = current.parent.index + 1 + attr["current_name"] = current.parent.name attr["current_start"] = dt.as_local(current.start_time) attr["current_duration"] = str(current.duration) attr["time_remaining"] = str(current.time_remaining) @@ -148,8 +150,9 @@ def device_state_attributes(self): next = self._controller.runs.next_run if next is not None: - attr["next_zone"] = next.index + 1 - attr["next_name"] = next.zone.name + if isinstance(next.parent, IUZone): + attr["next_zone"] = next.parent.index + 1 + attr["next_name"] = next.parent.name attr["next_start"] = dt.as_local(next.start_time) attr["next_duration"] = str(next.duration) else: @@ -163,7 +166,7 @@ class IUZoneEntity(IUEntity): @property def unique_id(self): """Return a unique ID.""" - return f"c{self._zone.controller_index + 1}_z{self._zone.zone_index + 1}" + return f"c{self._controller.index + 1}_z{self._zone.index + 1}" @property def name(self): @@ -198,7 +201,8 @@ def icon(self): def device_state_attributes(self): """Return the state attributes of the device.""" attr = {} - attr["index"] = self._zone.zone_index + attr["zone_id"] = self._zone.zone_id + attr["index"] = self._zone.index attr["enabled"] = self._zone.enabled and self._controller.enabled attr["status"] = self._zone.status attr["schedule_count"] = len(self._zone.schedules) @@ -206,9 +210,9 @@ def device_state_attributes(self): attr["adjustment"] = self._zone.adjustment.as_string current = self._zone.runs.current_run if current is not None: - if current.schedule is not None: - attr["current_schedule"] = current.schedule.schedule_index + 1 - attr["current_name"] = current.schedule.name + if isinstance(current.parent, IUSchedule): + attr["current_schedule"] = current.parent.index + 1 + attr["current_name"] = current.parent.name else: attr["current_schedule"] = RES_MANUAL attr["current_name"] = RES_MANUAL @@ -222,9 +226,9 @@ def device_state_attributes(self): next = self._zone.runs.next_run if next is not None: - if next.schedule is not None: - attr["next_schedule"] = next.schedule.schedule_index + 1 - attr["next_name"] = next.schedule.name + if isinstance(next.parent, IUSchedule): + attr["next_schedule"] = next.parent.index + 1 + attr["next_name"] = next.parent.name else: attr["next_schedule"] = RES_MANUAL attr["next_name"] = RES_MANUAL @@ -237,5 +241,6 @@ def device_state_attributes(self): ) if self._zone.show_config: attr["configuration"] = json.dumps(self._zone.as_dict(), default=str) - attr["timeline"] = json.dumps(self._zone.runs.as_list(), default=str) + if self._zone.show_timeline: + attr["timeline"] = json.dumps(self._zone.runs.as_list(), default=str) return attr diff --git a/custom_components/irrigation_unlimited/const.py b/custom_components/irrigation_unlimited/const.py index 759afd2..08a368a 100644 --- a/custom_components/irrigation_unlimited/const.py +++ b/custom_components/irrigation_unlimited/const.py @@ -49,6 +49,12 @@ CONF_DAY = "day" CONF_ODD = "odd" CONF_EVEN = "even" +CONF_SHOW = "show" +CONF_CONFIG = "config" +CONF_TIMELINE = "timeline" +CONF_ZONE_ID = "zone_id" +CONF_SEQUENCES = "sequences" +CONF_ALL_ZONES_CONFIG = "all_zones_config" # Defaults DEFAULT_NAME = DOMAIN diff --git a/custom_components/irrigation_unlimited/entity.py b/custom_components/irrigation_unlimited/entity.py index bcb9cf7..8e102bf 100644 --- a/custom_components/irrigation_unlimited/entity.py +++ b/custom_components/irrigation_unlimited/entity.py @@ -31,12 +31,12 @@ def __init__( self._controller = controller self._zone = zone # This will be None if it belongs to a Master/Controller self.entity_id = ( - f"{BINARY_SENSOR}.{DOMAIN}_c{self._controller.controller_index + 1}" + f"{BINARY_SENSOR}.{DOMAIN}_c{self._controller.index + 1}" ) if self._zone is None: self.entity_id = self.entity_id + "_m" else: - self.entity_id = self.entity_id + f"_z{self._zone.zone_index + 1}" + self.entity_id = self.entity_id + f"_z{self._zone.index + 1}" return async def async_added_to_hass(self): diff --git a/custom_components/irrigation_unlimited/irrigation_unlimited.py b/custom_components/irrigation_unlimited/irrigation_unlimited.py index df4af71..1ee9715 100644 --- a/custom_components/irrigation_unlimited/irrigation_unlimited.py +++ b/custom_components/irrigation_unlimited/irrigation_unlimited.py @@ -10,29 +10,34 @@ import homeassistant.helpers.sun as sun import homeassistant.util.dt as dt import logging +import uuid from homeassistant.const import ( CONF_AFTER, CONF_BEFORE, + CONF_DELAY, CONF_ENTITY_ID, CONF_NAME, CONF_WEEKDAY, + CONF_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_OFF, STATE_ON, + STATE_PAUSED, WEEKDAYS, ATTR_ENTITY_ID, ) from .const import ( CONF_ACTUAL, + CONF_ALL_ZONES_CONFIG, CONF_DAY, CONF_DECREASE, CONF_INCREASE, CONF_PERCENTAGE, CONF_RESET, - CONF_SHOW_CONFIG, + CONF_SEQUENCES, DEFAULT_GRANULATITY, DEFAULT_TEST_SPEED, CONF_DURATION, @@ -56,6 +61,10 @@ MONTHS, CONF_ODD, CONF_EVEN, + CONF_SHOW, + CONF_CONFIG, + CONF_TIMELINE, + CONF_ZONE_ID, SERVICE_DISABLE, SERVICE_ENABLE, SERVICE_TOGGLE, @@ -121,6 +130,25 @@ def wash_t(time: time, granularity: int = None) -> time: return None +class IUBase: + """Irrigation Unlimited base class""" + + def __init__(self, index: int) -> None: + # Private variables + self._id: str = uuid.uuid4().hex.upper() + self._index: int = index + return + + @property + def id(self) -> str: + """Return our unique id""" + return self._id + + @property + def index(self) -> int: + return self._index + + class IUAdjustment: """Irrigation Unlimited class to handle run time adjustment""" @@ -216,24 +244,20 @@ def to_string(self) -> str: return s -class IUSchedule: +class IUSchedule(IUBase): """Irrigation Unlimited Schedule class. Schedules are not actual points in time but describe a future event i.e. next Monday""" def __init__( self, hass: HomeAssistant, - coordinator, - controller, - zone, schedule_index: int, + object: IUBase, ) -> None: + super().__init__(schedule_index) # Passed parameters - self.hass = hass - self._coordinator = coordinator # Great gandparent - self._controller = controller # Grandparent - self._zone = zone # Parent - self._schedule_index: int = schedule_index # Our position within siblings + self._hass = hass + self._object: IUBase = object # Config parameters self._time = None self._duration: timedelta = None @@ -244,11 +268,6 @@ def __init__( self._dirty: bool = True return - @property - def schedule_index(self) -> int: - """Return our sibling order""" - return self._schedule_index - @property def name(self) -> str: """Return the friendly name of this schedule""" @@ -274,8 +293,8 @@ def load(self, config: OrderedDict): self.clear() self._time = config[CONF_TIME] - self._duration = wash_td(config[CONF_DURATION]) - self._name = config.get(CONF_NAME, f"Schedule {self.schedule_index + 1}") + self._duration = wash_td(config.get(CONF_DURATION), None) + self._name = config.get(CONF_NAME, f"Schedule {self.index + 1}") if CONF_WEEKDAY in config: self._weekdays = [] for i in config[CONF_WEEKDAY]: @@ -356,7 +375,7 @@ def get_next_run(self, atime: datetime) -> datetime: ) elif isinstance(self._time, dict) and CONF_SUN in self._time: se = sun.get_astral_event_date( - self.hass, self._time[CONF_SUN], next_run + self._hass, self._time[CONF_SUN], next_run ) if se is None: continue # Astral event did not occur today @@ -378,22 +397,17 @@ def get_next_run(self, atime: datetime) -> datetime: class IURun: """Irrigation Unlimited Run class. A run is an actual point - in time. Controllers and Zones share this object. To determine - the object, if zone is not None then this belongs to a zone. If - zone is none and schedule is not None then the parent is a - schedule. If both zone and schedule are None then it is a manual run. + in time. If parent is None then it is a manual run. """ def __init__( self, - zone, - schedule: IUSchedule, start_time: datetime, duration: timedelta, + parent: IUBase, ) -> None: # Passed parameters - self._zone = zone # None indicates this is a scheule run - self._schedule = schedule # Could be None which is manual + self._parent = parent self._start_time: datetime = start_time self._duration: timedelta = duration self._end_time: datetime = self._start_time + self._duration @@ -402,26 +416,9 @@ def __init__( return @property - def schedule(self) -> IUSchedule: - """Return the associated schedule. Note: this could be None in - which case it is a manual run.""" - return self._schedule - - @property - def zone(self): - """Return the associated zone""" - return self._zone - - @property - def index(self) -> int: - """Return the index of the associated object""" - if self._zone is None: - if self._schedule is not None: - return self._schedule.schedule_index - else: - return None - else: - return self._zone.zone_index + def parent(self) -> IUBase: + """Return the parent""" + return self._parent @property def start_time(self) -> datetime: @@ -445,7 +442,7 @@ def percent_complete(self) -> float: def is_manual(self) -> bool: """Check if this is a manual run""" - return self._zone is None and self.schedule is None + return self._parent is None def is_running(self, time: datetime) -> bool: """Check if this schedule is running""" @@ -483,8 +480,7 @@ def as_dict(self) -> OrderedDict: class IURunQueue(list): - MAX_DEPTH: int = 6 - MIN_DEPTH: int = 2 + DAYS_DEPTH: int = 7 RQ_STATUS_CLEARED: int = 0x01 RQ_STATUS_EXTENDED: int = 0x02 @@ -507,14 +503,8 @@ def current_run(self) -> IURun: def next_run(self) -> IURun: return self._next_run - def add( - self, - zone, - schedule: IUSchedule, - start_time: datetime, - duration: timedelta, - ): - run = IURun(zone, schedule, start_time, duration) + def add(self, start_time: datetime, duration: timedelta, parent: IUBase): + run = IURun(start_time, duration, parent) self.append(run) self._sorted = False return self @@ -542,13 +532,13 @@ def clear(self, time: datetime) -> bool: self._sorted = True return modified - def find_last(self, index: int) -> IURun: + def find_last_by_id(self, id: str) -> IURun: """Return the run that finishes last in the queue. This routine does not require the list to be sorted.""" last_time: datetime = None last_index: int = None for i, run in enumerate(self): - if run.index == index: + if run.parent.id == id: if last_time is None or run.end_time > last_time: last_time = run.end_time last_index = i @@ -651,12 +641,14 @@ def add_schedule( schedule: IUSchedule, start_time: datetime, adjustment: IUAdjustment, + duration: timedelta, ): - """Add a new scheduled run to the queue""" - duration = schedule.run_time + """Add a new schedule run to the queue""" + if duration is None: + duration = schedule.run_time if adjustment is not None: duration = adjustment.adjust(duration) - self.add(None, schedule, start_time, duration) + self.add(start_time, duration, schedule) return self def add_manual(self, start_time: datetime, duration: timedelta): @@ -675,23 +667,30 @@ def add_manual(self, start_time: datetime, duration: timedelta): self.remove(run) duration = max(duration, granularity_time()) - self.add(None, None, start_time, duration) + self.add(start_time, duration, None) self._current_run = None self._next_run = None return self - def extend_queue(self, time: datetime, schedules, adjustment: IUAdjustment) -> bool: + def merge( + self, + time: datetime, + schedules, + adjustment: IUAdjustment, + duration: timedelta, + offset: timedelta, + ) -> bool: """Increase the items in the run queue""" modified: bool = False dates: list(datetime) = [] - while len(self) < self.MAX_DEPTH: + while True: dates.clear() for schedule in schedules: next_time = None # See if schedule already exists in run queue. If so get # the finish time of the last entry. - run = self.find_last(schedule.schedule_index) + run = self.find_last_by_id(schedule.id) if run is not None: next_time = run.end_time + granularity_time() else: @@ -702,44 +701,46 @@ def extend_queue(self, time: datetime, schedules, adjustment: IUAdjustment) -> b if len(dates) > 0: ns = min(dates) - - # There might be overlapping schedules. Add them all. - for i, d in enumerate(dates): - if d == ns: - self.add_schedule(schedules[i], ns, adjustment) - modified = True + if ns < time + timedelta(days=self.DAYS_DEPTH): + # There might be overlapping schedules. Add them all. + for i, d in enumerate(dates): + if d == ns: + if offset is not None: + d += offset + self.add_schedule(schedules[i], d, adjustment, duration) + modified = True + else: + break # Exceeded future span else: break # No schedule was able to produce a future run time return modified - def update_queue(self, time: datetime, schedules, adjustment: IUAdjustment) -> int: - status: int = 0 - - if len(self) <= self.MIN_DEPTH: - if self.extend_queue(time, schedules, adjustment): - status |= IURunQueue.RQ_STATUS_EXTENDED - - status |= super().update_queue(time) - return status + def update_queue( + self, + time: datetime, + ) -> int: + return super().update_queue(time) -class IUZone: +class IUZone(IUBase): """Irrigation Unlimited Zone class""" def __init__( self, hass: HomeAssistant, coordinator, controller, zone_index: int ) -> None: + super().__init__(zone_index) # Passed parameters self._hass = hass - self._coordinator = coordinator # Grandparent - self._controller = controller # Parent - self._zone_index: int = zone_index # Our position within siblings + self._coordinator = coordinator + self._controller = controller # Config parameters + self._zone_id: str = None self._is_enabled: bool = True self._name: str = None self._switch_entity_id: str = None self._show_config: bool = False + self._show_timeline: bool = False # Private variables self._initialised: bool = False self._schedules: list(IUSchedule) = [] @@ -752,14 +753,6 @@ def __init__( self._dirty: bool = True return - @property - def controller_index(self) -> int: - return self._controller.controller_index - - @property - def zone_index(self) -> int: - return self._zone_index - @property def schedules(self) -> list: return self._schedules @@ -772,12 +765,16 @@ def runs(self) -> IUScheduleQueue: def adjustment(self) -> IUAdjustment: return self._adjustment + @property + def zone_id(self) -> str: + return self._zone_id + @property def name(self) -> str: if self._name is not None: return self._name else: - return f"Zone {self._zone_index + 1}" + return f"Zone {self._index + 1}" @property def is_on(self) -> bool: @@ -818,6 +815,10 @@ def status(self) -> str: def show_config(self) -> bool: return self._show_config + @property + def show_timeline(self) -> bool: + return self._show_timeline + def _is_setup(self) -> bool: """Check if this object is setup""" if not self._initialised: @@ -865,11 +866,9 @@ def add(self, schedule: IUSchedule) -> IUSchedule: self._schedules.append(schedule) return schedule - def find_add(self, coordinator, controller, zone, index: int) -> IUSchedule: + def find_add(self, coordinator, controller, index: int) -> IUSchedule: if index >= len(self._schedules): - return self.add( - IUSchedule(self._hass, coordinator, controller, zone, index) - ) + return self.add(IUSchedule(self._hass, index, self)) else: return self._schedules[index] @@ -880,15 +879,25 @@ def clear(self) -> None: self._is_on = False return - def load(self, config: OrderedDict): + def load(self, config: OrderedDict, all_zones: OrderedDict): """ Load zone data from the configuration""" self.clear() - + self._zone_id = config.get(CONF_ID, str(self.index + 1)) self._is_enabled = config.get(CONF_ENABLED, True) self._name = config.get(CONF_NAME, None) self._switch_entity_id = config.get(CONF_ENTITY_ID) self._adjustment.load(config) - self._show_config = config.get(CONF_SHOW_CONFIG, False) + if all_zones is not None and CONF_SHOW in all_zones: + self._show_config = all_zones[CONF_SHOW].get(CONF_CONFIG, False) + self._show_timeline = all_zones[CONF_SHOW].get(CONF_TIMELINE, False) + if CONF_SHOW in config: + self._show_config = config[CONF_SHOW].get(CONF_CONFIG, False) + self._show_timeline = config[CONF_SHOW].get(CONF_TIMELINE, False) + if CONF_SCHEDULES in config: + for si, schedule_config in enumerate(config[CONF_SCHEDULES]): + self.find_add(self._coordinator, self._controller, si).load( + schedule_config + ) self._dirty = True return self @@ -904,7 +913,14 @@ def as_dict(self) -> OrderedDict: dict[CONF_SCHEDULES].append(schedule.as_dict()) return dict - def muster(self, time: datetime, force: bool) -> int: + def muster( + self, + time: datetime, + schedules, + duration: timedelta, + offset: timedelta, + force: bool, + ) -> int: """Calculate run times for this zone""" status: int = 0 @@ -912,7 +928,9 @@ def muster(self, time: datetime, force: bool) -> int: self._run_queue.clear_all() status |= IURunQueue.RQ_STATUS_CLEARED - status |= self._run_queue.update_queue(time, self._schedules, self._adjustment) + status |= self._run_queue.merge( + time, schedules, self._adjustment, duration, offset + ) if status != 0: self.request_update() @@ -1001,7 +1019,7 @@ def add_zone( duration += preamble if postamble is not None: duration += postamble - self.add(zone, None, start_time, duration) + self.add(start_time, duration, zone) return self def rebuild_schedule( @@ -1026,14 +1044,127 @@ def rebuild_schedule( return status -class IUController: +class IUSequenceZone: + """Irrigation Unlimited Sequence Zone class""" + + def __init__( + self, hass: HomeAssistant, coordinator, controller, sequence, zone_index: int + ) -> None: + # Passed parameters + self._hass = hass + self._coordinator = coordinator + self._controller = controller + self._sequence = sequence + self._zone_index: int = zone_index # Our position within siblings + # Config parameters + self._zone_id: str = None + self._duration: timedelta = None + # Private variables + return + + @property + def zone_id(self) -> str: + return self._zone_id + + @property + def duration(self) -> timedelta: + return self._duration + + def clear(self) -> None: + """Reset this sequence zone""" + return + + def load(self, config: OrderedDict): + """ Load sequence zone data from the configuration""" + self.clear() + self._zone_id = str(config[CONF_ZONE_ID]) + self._duration = wash_td(config.get(CONF_DURATION, None)) + return self + + +class IUSequence(IUBase): + """Irrigation Unlimited Sequence class""" + + def __init__( + self, hass: HomeAssistant, coordinator, controller, 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 + # Private variables + self._schedules = [] + self._zones = [] + return + + @property + def schedules(self) -> list: + return self._schedules + + @property + def zones(self) -> list: + return self._zones + + @property + def delay(self) -> timedelta: + return self._delay + + def clear(self) -> None: + """Reset this sequence""" + return + + 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, coordinator, controller, index: int) -> IUSchedule: + if index >= len(self._schedules): + return self.add_schedule(IUSchedule(self._hass, index, self)) + else: + 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 find_add_zone(self, coordinator, controller, index: int) -> IUSequenceZone: + if index >= len(self._zones): + return self.add_zone( + IUSequenceZone(self._hass, coordinator, controller, self, index) + ) + else: + return self._zones[index] + + def load(self, config: OrderedDict): + """ 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, None)) + for si, schedule_config in enumerate(config[CONF_SCHEDULES]): + self.find_add_schedule(self._coordinator, self._controller, si).load( + schedule_config + ) + for zi, zone_config in enumerate(config[CONF_ZONES]): + self.find_add_zone(self._coordinator, self._controller, zi).load( + zone_config + ) + return self + + +class IUController(IUBase): """Irrigation Unlimited Controller (Master) class""" def __init__(self, hass: HomeAssistant, coordinator, controller_index: int) -> None: # Passed parameters + super().__init__(controller_index) self._hass = hass self._coordinator = coordinator # Parent - self._controller_index: int = controller_index # Our position within siblings # Config parameters self._is_enabled: bool = True self._name: str = None @@ -1042,6 +1173,7 @@ def __init__(self, hass: HomeAssistant, coordinator, controller_index: int) -> N self._postamble: timedelta = None # Private variables self._zones: list(IUZone) = [] + self._sequences: list(IUSequence) = [] self._run_queue = IUZoneQueue() self._master_sensor: Entity = None self._is_on: bool = False @@ -1050,10 +1182,6 @@ def __init__(self, hass: HomeAssistant, coordinator, controller_index: int) -> N self._dirty: bool = True return - @property - def controller_index(self) -> int: - return self._controller_index - @property def zones(self) -> list: return self._zones @@ -1107,17 +1235,36 @@ def _is_setup(self) -> bool: all_setup = all_setup and zone.is_setup return all_setup - def add(self, zone: IUZone) -> IUZone: + def add_zone(self, zone: IUZone) -> IUZone: """Add a new zone to the controller""" self._zones.append(zone) return zone - def find_add(self, coordinator, controller, index: int) -> IUZone: + def find_add_zone(self, coordinator, controller, index: int) -> IUZone: if index >= len(self._zones): - return self.add(IUZone(self._hass, coordinator, controller, index)) + return self.add_zone(IUZone(self._hass, coordinator, controller, index)) else: 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 find_add_sequence(self, coordinator, controller, index: int) -> IUSequence: + if index >= len(self._sequences): + return self.add_sequence( + IUSequence(self._hass, coordinator, controller, index) + ) + else: + return self._sequences[index] + + def find_zone_by_zone_id(self, zone_id: str) -> IUZone: + for zone in self._zones: + if zone.zone_id == zone_id: + return zone + return None + def clear(self) -> None: # Don't clear zones # self._zones.clear() @@ -1128,10 +1275,20 @@ def load(self, config: OrderedDict): """Load config data for the controller""" self.clear() self._is_enabled = config.get(CONF_ENABLED, True) - self._name = config.get(CONF_NAME, f"Controller {self._controller_index + 1}") + self._name = config.get(CONF_NAME, f"Controller {self.index + 1}") self._switch_entity_id = config.get(CONF_ENTITY_ID) self._preamble = wash_td(config.get(CONF_PREAMBLE)) self._postamble = wash_td(config.get(CONF_POSTAMBLE)) + for zi, zone_config in enumerate(config[CONF_ZONES]): + self.find_add_zone(self._coordinator, self, zi).load( + zone_config, config.get(CONF_ALL_ZONES_CONFIG, None) + ) + if CONF_SEQUENCES in config: + for qi, sequence_config in enumerate(config[CONF_SEQUENCES]): + self.find_add_sequence(self._coordinator, self, qi).load( + sequence_config + ) + self._dirty = True return self @@ -1145,8 +1302,22 @@ def muster(self, time: datetime, force: bool) -> int: status |= IURunQueue.RQ_STATUS_CLEARED zone_status: int = 0 + + # Process sequence schedules + for sequence in self._sequences: + offset = timedelta() + for zone in sequence.zones: + zn: IUZone = self.find_zone_by_zone_id(zone.zone_id) + if zn is not None: + zone_status |= zn.muster( + time, sequence.schedules, zone.duration, offset, force + ) + offset += zone.duration + sequence.delay + + # Process zone schedules for zone in self._zones: - zone_status |= zone.muster(time, force) + zone_status |= zone.muster(time, zone.schedules, None, None, force) + zone_status |= zone.runs.update_queue(time) if ( zone_status @@ -1180,7 +1351,7 @@ def check_run(self, time: datetime) -> bool: # Gather zones that have changed status for zone in self._zones: if zone.check_run(time, self._is_enabled): - zones_changed.append(zone.zone_index) + zones_changed.append(zone.index) # Handle off zones before master for index in zones_changed: @@ -1338,14 +1509,8 @@ def load(self, config: OrderedDict): name = None self._test_times.append({"name": name, "start": start, "end": end}) - for ci, controller in enumerate(config[CONF_CONTROLLERS]): - Ctrl = self.find_add(self, ci).load(controller) - - for zi, zone in enumerate(controller[CONF_ZONES]): - Zn = Ctrl.find_add(self, Ctrl, zi).load(zone) - - for si, schedule in enumerate(zone[CONF_SCHEDULES]): - Zn.find_add(self, Ctrl, Zn, si).load(schedule) + for ci, controller_config in enumerate(config[CONF_CONTROLLERS]): + self.find_add(self, ci).load(controller_config) self._dirty = True self._muster_required = True @@ -1543,7 +1708,7 @@ def notify_children(controller: IUController) -> None: def write_status_to_log(time: datetime, controller: IUController, zone: IUZone) -> None: """Output the status of master or zone""" if zone is not None: - zm = f"Zone {zone.zone_index + 1}" + zm = f"Zone {zone.index + 1}" status = f"{STATE_ON if zone._is_on else STATE_OFF}" else: zm = "Master" @@ -1551,7 +1716,7 @@ def write_status_to_log(time: datetime, controller: IUController, zone: IUZone) _LOGGER.debug( "[%s] Controller %d %s is %s", datetime.strftime(dt.as_local(time), "%Y-%m-%d %H:%M:%S"), - controller._controller_index + 1, + controller.index + 1, zm, status, ) diff --git a/examples/all_the_bells_and_whistles.yaml b/examples/all_the_bells_and_whistles.yaml index b8f336f..70e8271 100644 --- a/examples/all_the_bells_and_whistles.yaml +++ b/examples/all_the_bells_and_whistles.yaml @@ -1,7 +1,7 @@ default_config: irrigation_unlimited: - granularity: 60 + granularity: 10 testing: enabled: false speed: 60.0 @@ -22,13 +22,13 @@ irrigation_unlimited: month: [jan, feb, mar] day: 'even' - name: 'Controller 2' -# entity_id: 'switch.my_master_switch' + entity_id: 'switch.my_master_switch' enabled: true preamble: '00:02' postamble: '00:02' zones: - name: 'Example schedules' -# entity_id: 'switch.my_zone_switch' + entity_id: 'switch.my_zone_switch' enabled: true minimum: '00:02' maximum: '01:00' @@ -91,3 +91,54 @@ irrigation_unlimited: - name: 'Overlaps with Zone 1' time: '05:50' duration: '00:20' + - name: "Fundos" + zones: + - name: "Gramado" + entity_id: "switch.irrigacao_grama" + - name: "Lateral" + entity_id: "switch.irrigacao_lateral" + - name: "Corredor" + entity_id: "switch.irrigacao_corredor" + - name: "Horta" + entity_id: "switch.irrigacao_horta" + sequences: + - name: "Run 1" + delay: "00:00:10" + schedules: + - name: "Alvorada" + time: + sun: "sunrise" + - name: "Por do Sol" + time: + sun: "sunset" + after: "00:30" + zones: + - zone_id: 1 + duration: "00:10" + - zone_id: 2 + duration: "00:02" + - zone_id: 3 + duration: "00:01" + - zone_id: 4 + duration: "00:06" + - name: "Run 2" + delay: "00:00:10" + schedules: + - name: "Midday" + time: "12:00" + zones: + - zone_id: 2 + duration: "00:02" + - zone_id: 3 + duration: "00:01" + - name: "Split soaker" + delay: "00:05" + schedules: + - name: "Morning" + time: "05:30" + zones: + - zone_id: 2 + duration: "00:02" + - zone_id: 2 + duration: "00:02" +