From b55090c99512998598dce79ac19d94d84df62612 Mon Sep 17 00:00:00 2001 From: kcofoni Date: Thu, 16 Nov 2023 21:41:52 +0100 Subject: [PATCH] Merge commit '1391acb78377ef964f81b162b96ae31b861982b0' --- .gitignore | 4 +- README.md | 18 +-- custom_components/netro_watering/__init__.py | 2 +- .../netro_watering/binary_sensor.py | 4 +- custom_components/netro_watering/calendar.py | 95 +++++++++++++++ .../netro_watering/coordinator.py | 110 +++++++++++++++++- custom_components/netro_watering/sensor.py | 12 +- custom_components/netro_watering/strings.json | 7 +- .../netro_watering/translations/en.json | 7 +- .../netro_watering/translations/es.json | 7 +- .../netro_watering/translations/fr.json | 7 +- 11 files changed, 248 insertions(+), 25 deletions(-) create mode 100644 custom_components/netro_watering/calendar.py diff --git a/.gitignore b/.gitignore index ad9c79e..e9f4aed 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ updatefromdev **/__pycache* -.venv \ No newline at end of file +.venv +pipfile +pipfile.lock diff --git a/README.md b/README.md index 7dd2d5c..247191e 100644 --- a/README.md +++ b/README.md @@ -41,24 +41,24 @@ Netro products *Sprite*, *Spark*, *Pixie* and *Whisperer* are actually supported Please repeat step 4. as mentioned above for each device you want to include, whatever it is a ground sensor, a multi-zone controller (Sprite or Spark) or a single-zone controler (Pixie). Each zone of a controller will be represented by separate device related to the controller it depends on. -![add a config entry](images/add_config_entry.png "Setup of a *Netro* device") -![device is created](images/device_created.png "Sucess of a *Netro* device setup") +![add a config entry](https://kcofoni.github.io/ha-netro-watering/images/add_config_entry.png "Setup of a *Netro* device") +![device is created](https://kcofoni.github.io/ha-netro-watering/images/device_created.png "Sucess of a *Netro* device setup") At this point, several devices may have been created related to ten's of entity. This latter are representing the humidity, temperature, illuminance of the sensors as well as the current/last/next status of each zone. Switches have been created allowing to start/stop watering and enable/disable controllers. Options may be changed related to polling refresh interval of sensors and controllers independently. Default watering duration and schedules options may also be changed specifically for the controllers. -![change controller options](images/controller_options.png "Controller options") -![change sensor options](images/sensor_options.png "Sensor options") +![change controller options](https://kcofoni.github.io/ha-netro-watering/images/controller_options.png "Controller options") +![change sensor options](https://kcofoni.github.io/ha-netro-watering/images/sensor_options.png "Sensor options") **IMPORTANT: to be effective, each time options have been changed, the related device must be reloaded.** ## Lovelace cards Here are some lovelace cards I am presently using to control my watering system with the help of this integration. -![watering](images/watering-controller-main.png "Controller") ![planning](images/planning-arrosage.png "Planning") -![sensors](images/ground-sensors.png "Sensors") ![start](images/start-watering.png "Start") -![charts](images/courbes-capteurs.png "Charts") ![weather](images/meteo.png "Weather") +![watering](https://kcofoni.github.io/ha-netro-watering/images/watering-controller-main.png "Controller") ![planning](https://kcofoni.github.io/ha-netro-watering/images/planning-arrosage.png "Planning") +![sensors](https://kcofoni.github.io/ha-netro-watering/images/ground-sensors.png "Sensors") ![start](https://kcofoni.github.io/ha-netro-watering/images/start-watering.png "Start") +![charts](https://kcofoni.github.io/ha-netro-watering/images/courbes-capteurs.png "Charts") ![weather](https://kcofoni.github.io/ha-netro-watering/images/meteo.png "Weather") ### Automation @@ -68,14 +68,14 @@ The Netro Watering entities may be integrated into automations. The following in - **Refresh data** - allows to update the data of the devices (controller, zones, sensors) when desired - **Report weather** - for reporting weather data, overriding system default weather data -![call service](images/service_call.png "Developer Tools") +![call service](https://kcofoni.github.io/ha-netro-watering/images/service_call.png "Developer Tools") ### Set moisture level The nominal functioning of the Netro ecosystem is based on irrigation planning algorithms that take into account the physiognomy of the areas to be irrigated, the plants that compose them and the properties of the soil, the weather forecast, as well as a certain number of other factors. In addition to this information, Netro needs to know at a given time the temperature and humidity of the areas to be watered in order to precisely determine the watering periods. Soil sensors supplied by Netro (Whisperer model) allow these measurements to be made. If you do not have these sensors which are an integral part of the ecosystem but other external sensors, you can provide Netro with the level of humidity given by these sensors so that it can apply its algorithms in the same way. The **Set moisture** service provided by the integration and applicable to a particular zone, allows this to be done. -![set moistures](images/set_moisture.png "Developer Tools") +![set moistures](https://kcofoni.github.io/ha-netro-watering/images/set_moisture.png "Developer Tools") ### Report weather Netro offers to obtain weather data, very useful for establishing automatic watering schedules, from a number of weather providers. In some cases, national services may be more relevant and more precise so that we will want to feed Netro with data from these services instead of the listed providers. The **Report weather** service is offered for this purpose. Each user will be able to establish his own Home Assistant script which will call on this service after having collected custom weather information. diff --git a/custom_components/netro_watering/__init__.py b/custom_components/netro_watering/__init__.py index 3e24e9e..2006182 100644 --- a/custom_components/netro_watering/__init__.py +++ b/custom_components/netro_watering/__init__.py @@ -67,7 +67,7 @@ # Here is the list of the platforms that we want to support. # sensor is for the netro ground sensors, switch is for the zones # PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.SWITCH] -PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.SWITCH, Platform.BINARY_SENSOR] +PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.SWITCH, Platform.BINARY_SENSOR, Platform.CALENDAR] _LOGGER = logging.getLogger(__name__) diff --git a/custom_components/netro_watering/binary_sensor.py b/custom_components/netro_watering/binary_sensor.py index 838e536..25b4899 100644 --- a/custom_components/netro_watering/binary_sensor.py +++ b/custom_components/netro_watering/binary_sensor.py @@ -127,7 +127,7 @@ def extra_state_attributes(self) -> dict[str, Any]: else None, "last active date": dt_util.as_local( self.coordinator.metadata.last_active_date.replace( - tzinfo=datetime.timezone.utc + tzinfo=datetime.UTC ) ) if self.coordinator.metadata is not None @@ -143,7 +143,7 @@ def extra_state_attributes(self) -> dict[str, Any]: else None, "token reset": dt_util.as_local( self.coordinator.metadata.token_reset_date.replace( - tzinfo=datetime.timezone.utc + tzinfo=datetime.UTC ) ) if self.coordinator.metadata is not None diff --git a/custom_components/netro_watering/calendar.py b/custom_components/netro_watering/calendar.py new file mode 100644 index 0000000..64f0600 --- /dev/null +++ b/custom_components/netro_watering/calendar.py @@ -0,0 +1,95 @@ +"""Support for Netro Watering system.""" +from __future__ import annotations + +import datetime +import logging + +from homeassistant.components.calendar import CalendarEntity, CalendarEvent +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import CONF_DEVICE_TYPE, CONTROLLER_DEVICE_TYPE, DOMAIN +from .coordinator import NetroControllerUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + +NETRO_CALENDAR_DESCRIPTION = EntityDescription( + key="schedules", + name="Schedules", + entity_registry_enabled_default=True, + translation_key="schedules", + icon="mdi:calendar-clock", +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up entry for the controller calendar.""" + if config_entry.data[CONF_DEVICE_TYPE] == CONTROLLER_DEVICE_TYPE: + _LOGGER.info("Adding calendar entity") + # add controller calendar + async_add_entities( + [ + NetroCalendar( + hass.data[DOMAIN][config_entry.entry_id], + NETRO_CALENDAR_DESCRIPTION, + ) + ] + ) + + +class NetroCalendar( + CoordinatorEntity[NetroControllerUpdateCoordinator], CalendarEntity +): + """A calendar implementation for Netro Controller device.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: NetroControllerUpdateCoordinator, + description: EntityDescription, + ) -> None: + """Initialize the Netro calendar.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.serial_number}-{description.key}" + self._attr_device_info = coordinator.device_info + + @property + def event(self) -> CalendarEvent | None: + """Return current or next upcoming schedule if any.""" + schedule = self.coordinator.current_calendar_schedule + return ( + CalendarEvent( + schedule["start"], + schedule["end"], + schedule["summary"], + schedule["description"], + ) + if schedule is not None + else None + ) + + async def async_get_events( + self, + hass: HomeAssistant, + start_date: datetime.datetime, + end_date: datetime.datetime, + ) -> list[CalendarEvent]: + """Get all schedules in a specific time frame.""" + return [ + CalendarEvent( + schedule["start"], + schedule["end"], + schedule["summary"], + schedule["description"], + ) + for schedule in self.coordinator.calendar_schedules(start_date, end_date) + ] diff --git a/custom_components/netro_watering/coordinator.py b/custom_components/netro_watering/coordinator.py index c70c297..3efc535 100644 --- a/custom_components/netro_watering/coordinator.py +++ b/custom_components/netro_watering/coordinator.py @@ -6,6 +6,7 @@ from datetime import timedelta import logging from time import gmtime, strftime +from typing import Optional from dateutil.relativedelta import relativedelta @@ -435,7 +436,9 @@ def device_info(self) -> DeviceInfo: # _schedules and _moistures are list of dict whose key = str and value = any # _active_zones is a dictionary indexed by the zone ith and whose value is a Zone object + # _coming_schedules_ordered is the coming schedules oredered as generated from _schedules _schedules = [] + _coming_schedules_ordered = [] _moistures = [] def __init__( @@ -495,6 +498,26 @@ def _update_from_schedules( Each list is ordered so that the first element return the most recent past schedule and coming schedule respectively. """ self._schedules = schedules + + # filtering current and coming schedules + coming_schedules_filtered = [ + schedule + for schedule in schedules + if schedule[NETRO_SCHEDULE_STATUS] + in [NETRO_SCHEDULE_VALID, NETRO_SCHEDULE_EXECUTING] + and schedule[NETRO_SCHEDULE_START_TIME] + > strftime("%Y-%m-%dT%H:%M:%S", gmtime()) + ] + + # sorting filtered coming schedules on start time ascending + coming_schedules_sorted = sorted( + coming_schedules_filtered, + key=(lambda schedule: schedule[NETRO_SCHEDULE_START_TIME]), + reverse=False, + ) + # set the current and coming schedules attribute with the result + self._coming_schedules_ordered = coming_schedules_sorted + for zone_key in self._active_zones: # filtering past schedules, keeping the current zone past_schedules_zone_filtered = [ @@ -587,6 +610,85 @@ def token_remaining(self) -> int | None: """Return the remaining token of the controller.""" return self.metadata.token_remaining if self.metadata is not None else None + def calendar_schedules( + self, + start_date: Optional[datetime.date] = None, + end_date: Optional[datetime.date] = None, + ): + """Return the calendar events of the controller.""" + + return [ + { + "start": datetime.datetime.fromisoformat( + schedule[NETRO_SCHEDULE_START_TIME] + TZ_OFFSET + ), + "end": datetime.datetime.fromisoformat( + schedule[NETRO_SCHEDULE_END_TIME] + TZ_OFFSET + ), + "summary": f"{self._active_zones[schedule[NETRO_SCHEDULE_ZONE]].name}", + "description": "Duration: {} min. - Source: {}".format( + round( + ( + datetime.datetime.fromisoformat( + schedule[NETRO_SCHEDULE_END_TIME] + TZ_OFFSET + ) + - datetime.datetime.fromisoformat( + schedule[NETRO_SCHEDULE_START_TIME] + TZ_OFFSET + ) + ).seconds + / 60 + ), + schedule[NETRO_SCHEDULE_SOURCE], + ), + } + for schedule in self._coming_schedules_ordered + if ( + datetime.datetime.fromisoformat( + schedule[NETRO_SCHEDULE_END_TIME] + TZ_OFFSET + ) + > start_date + if start_date is not None + else True + ) + and ( + datetime.datetime.fromisoformat( + schedule[NETRO_SCHEDULE_START_TIME] + TZ_OFFSET + ) + < end_date + if end_date is not None + else True + ) + ] + + @property + def current_calendar_schedule(self) -> dict | None: + """Return current or next coming schedule if any.""" + if len(self._coming_schedules_ordered) > 0: + schedule = self._coming_schedules_ordered[0] + return { + "start": datetime.datetime.fromisoformat( + schedule[NETRO_SCHEDULE_START_TIME] + TZ_OFFSET + ), + "end": datetime.datetime.fromisoformat( + schedule[NETRO_SCHEDULE_END_TIME] + TZ_OFFSET + ), + "summary": f"{self._active_zones[schedule[NETRO_SCHEDULE_ZONE]].name}", + "description": "Duration: {} - Source: {}".format( + round( + ( + datetime.datetime.fromisoformat( + schedule[NETRO_SCHEDULE_END_TIME] + TZ_OFFSET + ) + - datetime.datetime.fromisoformat( + schedule[NETRO_SCHEDULE_START_TIME] + TZ_OFFSET + ) + ).seconds + / 60 + ), + schedule[NETRO_SCHEDULE_SOURCE], + ), + } + async def _async_update_data(self): """Fetch data from API endpoint. @@ -649,6 +751,7 @@ async def _async_update_data(self): - next watering event source (smart, fix, manual) - sensor - zone - battery - sensor - controller (only for non standalone controllers (e.g. Pixie)) - on/off - switch - controller + - schedules - calendar - controller services - start watering (controller) @@ -708,8 +811,11 @@ async def _async_update_data(self): zone[NETRO_ZONE_ENABLED], zone[NETRO_ZONE_SMART], zone[NETRO_ZONE_NAME] - if zone[NETRO_ZONE_NAME] is not None - else self.device_name + "-" + zone[NETRO_ZONE_ITH], + if ( + zone[NETRO_ZONE_NAME] is not None + and len(zone[NETRO_ZONE_NAME]) > 0 + ) + else self.device_name + "-" + str(zone[NETRO_ZONE_ITH]), self.serial_number, ) diff --git a/custom_components/netro_watering/sensor.py b/custom_components/netro_watering/sensor.py index b367db1..91b5ae6 100644 --- a/custom_components/netro_watering/sensor.py +++ b/custom_components/netro_watering/sensor.py @@ -352,7 +352,7 @@ def extra_state_attributes(self) -> dict[str, Any]: else None, "last active date": dt_util.as_local( self.coordinator.metadata.last_active_date.replace( - tzinfo=datetime.timezone.utc + tzinfo=datetime.UTC ) ) if self.coordinator.metadata is not None @@ -368,7 +368,7 @@ def extra_state_attributes(self) -> dict[str, Any]: else None, "token reset": dt_util.as_local( self.coordinator.metadata.token_reset_date.replace( - tzinfo=datetime.timezone.utc + tzinfo=datetime.UTC ) ) if self.coordinator.metadata is not None @@ -427,7 +427,7 @@ def extra_state_attributes(self) -> dict[str, Any]: else None, "last active date": dt_util.as_local( self.coordinator.metadata.last_active_date.replace( - tzinfo=datetime.timezone.utc + tzinfo=datetime.UTC ) ) if self.coordinator.metadata is not None @@ -443,7 +443,7 @@ def extra_state_attributes(self) -> dict[str, Any]: else None, "token reset": dt_util.as_local( self.coordinator.metadata.token_reset_date.replace( - tzinfo=datetime.timezone.utc + tzinfo=datetime.UTC ) ) if self.coordinator.metadata is not None @@ -512,7 +512,7 @@ def extra_state_attributes(self) -> dict[str, Any]: else None, "last active date": dt_util.as_local( self.coordinator.metadata.last_active_date.replace( - tzinfo=datetime.timezone.utc + tzinfo=datetime.UTC ) ) if self.coordinator.metadata is not None @@ -528,7 +528,7 @@ def extra_state_attributes(self) -> dict[str, Any]: else None, "token reset": dt_util.as_local( self.coordinator.metadata.token_reset_date.replace( - tzinfo=datetime.timezone.utc + tzinfo=datetime.UTC ) ) if self.coordinator.metadata is not None diff --git a/custom_components/netro_watering/strings.json b/custom_components/netro_watering/strings.json index 21a0e9e..cc01f86 100644 --- a/custom_components/netro_watering/strings.json +++ b/custom_components/netro_watering/strings.json @@ -129,6 +129,11 @@ "off": "No" } } + }, + "calendar": { + "schedules": { + "name": "Schedules" + } } }, "services": { @@ -241,4 +246,4 @@ "description": "Disable the selected controller" } } -} \ No newline at end of file +} diff --git a/custom_components/netro_watering/translations/en.json b/custom_components/netro_watering/translations/en.json index 86a03f7..d58d600 100644 --- a/custom_components/netro_watering/translations/en.json +++ b/custom_components/netro_watering/translations/en.json @@ -115,6 +115,11 @@ "watering": { "name": "Start/Stop watering" } + }, + "calendar": { + "schedules": { + "name": "Irrigation" + } } }, "options": { @@ -241,4 +246,4 @@ "description": "Disable the selected controller" } } -} \ No newline at end of file +} diff --git a/custom_components/netro_watering/translations/es.json b/custom_components/netro_watering/translations/es.json index d89ca31..53fd039 100644 --- a/custom_components/netro_watering/translations/es.json +++ b/custom_components/netro_watering/translations/es.json @@ -115,6 +115,11 @@ "watering": { "name": "Iniciar/Parar riego" } + }, + "calendar": { + "schedules": { + "name": "Riego" + } } }, "options": { @@ -241,4 +246,4 @@ "description": "Deshabilitar el controlador seleccionado" } } -} \ No newline at end of file +} diff --git a/custom_components/netro_watering/translations/fr.json b/custom_components/netro_watering/translations/fr.json index 75054ae..d19896d 100644 --- a/custom_components/netro_watering/translations/fr.json +++ b/custom_components/netro_watering/translations/fr.json @@ -115,6 +115,11 @@ "off": "inactif" } } + }, + "calendar": { + "schedules": { + "name": "Arrosage" + } } }, "options": { @@ -241,4 +246,4 @@ "description": "Désactiver le contrôleur sélectionné" } } -} \ No newline at end of file +}