From 91bce8fa1743c19be3147c4fcfb06926c02339e9 Mon Sep 17 00:00:00 2001 From: Juan Altmayer Pizzorno Date: Thu, 1 Oct 2026 09:24:28 -0400 Subject: [PATCH 1/4] Add an estimated ready time sensor for bodies of water The controller pushes a TemperatureRiseInformation document under each body of water while it heats toward its set point. Its TargetEndDateTime is the prediction the Poolside app shows as "ready in ...". Expose it as a timestamp sensor so automations and dashboards can show when the spa or pool will be at temperature. The value is unknown while the controller is still sampling the water temperature (TargetEndDateTime is a 0001-01-01 placeholder), once ActualEndDateTime is set, and when no heat-up is in progress. Co-Authored-By: Claude Opus 5.5 --- custom_components/poolside/const.py | 7 ++ custom_components/poolside/icons.json | 3 + custom_components/poolside/sensor.py | 70 ++++++++++++++++++- custom_components/poolside/strings.json | 3 + .../poolside/translations/en.json | 3 + 5 files changed, 85 insertions(+), 1 deletion(-) diff --git a/custom_components/poolside/const.py b/custom_components/poolside/const.py index 2e01ff1..97a1679 100644 --- a/custom_components/poolside/const.py +++ b/custom_components/poolside/const.py @@ -31,3 +31,10 @@ # unique_id suffix of the site mode sensor - the one entity not keyed to a # control or body-of-water UUID from the layout. SITE_MODE_KEY = "site_mode" + +# Body-of-water field describing an ongoing heat-up (or cool-down) toward the +# set point, including the controller's predicted completion time. Not yet in +# aiopoolside.const. +TEMPERATURE_RISE_INFORMATION_FIELD = "TemperatureRiseInformation" +TEMPERATURE_RISE_TARGET_END_KEY = "TargetEndDateTime" +TEMPERATURE_RISE_ACTUAL_END_KEY = "ActualEndDateTime" diff --git a/custom_components/poolside/icons.json b/custom_components/poolside/icons.json index 52d26d9..c5e8297 100644 --- a/custom_components/poolside/icons.json +++ b/custom_components/poolside/icons.json @@ -48,6 +48,9 @@ "dissolved_oxygen_saturation": { "default": "mdi:gas-cylinder" }, + "estimated_ready_time": { + "default": "mdi:clock-check-outline" + }, "free_chlorine": { "default": "mdi:beaker" }, diff --git a/custom_components/poolside/sensor.py b/custom_components/poolside/sensor.py index a66cb14..8ce0341 100644 --- a/custom_components/poolside/sensor.py +++ b/custom_components/poolside/sensor.py @@ -68,7 +68,14 @@ from homeassistant.util import dt as dt_util from . import PoolsideConfigEntry -from .const import DOMAIN, LOGGER, SITE_MODE_KEY +from .const import ( + DOMAIN, + LOGGER, + SITE_MODE_KEY, + TEMPERATURE_RISE_ACTUAL_END_KEY, + TEMPERATURE_RISE_INFORMATION_FIELD, + TEMPERATURE_RISE_TARGET_END_KEY, +) from .entity import ( PoolsideBaseEntity, PoolsideDeviceEntity, @@ -229,6 +236,27 @@ def _datetime_value(value: Any) -> datetime | None: return parsed +def _temperature_rise_value(value: Any, key: str) -> datetime | None: + """Return a timestamp from a TemperatureRiseInformation document. + + The controller sends unset timestamps as 0001-01-01 placeholders (e.g. + TargetEndDateTime while it is still sampling the water temperature), + which map to no data, as does a missing or malformed document. + """ + if not isinstance(value, str): + return None + try: + document = json.loads(value) + except ValueError: + return None + if not isinstance(document, dict): + return None + parsed = _datetime_value(document.get(key)) + if parsed is None or parsed.year <= 1: + return None + return parsed + + @dataclass(frozen=True, kw_only=True) class PoolsideFieldSensorDescription(SensorEntityDescription): """Describes how one DisplayProcessingLogic renders as a sensor.""" @@ -544,6 +572,7 @@ async def async_setup_entry( PoolsideBodySensor(client, group, body_of_water_uuid, TEMPERATURE_SENSOR) ) entities.append(PoolsideBodyStateSensor(client, group, body_of_water_uuid)) + entities.append(PoolsideBodyReadyTimeSensor(client, group, body_of_water_uuid)) entities.extend( PoolsideControlDisabledReasonSensor(client, control) for control in data.controls @@ -697,6 +726,45 @@ def native_value(self) -> str | None: return None +class PoolsideBodyReadyTimeSensor(PoolsideGroupEntity, SensorEntity): + """When a heating body of water is predicted to reach its set point. + + Taken from the controller's TemperatureRiseInformation, the same + prediction the vendor app shows as "ready in". Unknown while the + controller is still sampling the water, once the set point has been + reached, and whenever the body isn't heating toward it. + """ + + _attr_device_class = SensorDeviceClass.TIMESTAMP + _attr_translation_key = "estimated_ready_time" + + def __init__( + self, client: PoolsideClient, group: PoolsideGroup, body_of_water_uuid: str + ) -> None: + """Set up the ready-time sensor for a given body of water.""" + super().__init__(client, group) + self._body_of_water_uuid = body_of_water_uuid + self._attr_unique_id = ( + f"{client.controller_uuid}_{body_of_water_uuid}_estimated_ready_time" + ) + + @override + def _status_keys(self) -> set[str]: + """Return the body-of-water key the prediction arrives under.""" + return {self._body_of_water_uuid} + + @property + @override + def native_value(self) -> datetime | None: + """Return the predicted completion time of the current heat-up.""" + value = self._client.get_status( + self._body_of_water_uuid, TEMPERATURE_RISE_INFORMATION_FIELD + ) + if _temperature_rise_value(value, TEMPERATURE_RISE_ACTUAL_END_KEY) is not None: + return None + return _temperature_rise_value(value, TEMPERATURE_RISE_TARGET_END_KEY) + + class PoolsideDeviceSensor(PoolsideDeviceEntity, SensorEntity): """One telemetry field of a physical pool device. diff --git a/custom_components/poolside/strings.json b/custom_components/poolside/strings.json index 489a2e1..79f0864 100644 --- a/custom_components/poolside/strings.json +++ b/custom_components/poolside/strings.json @@ -107,6 +107,9 @@ "dissolved_oxygen_saturation": { "name": "Dissolved oxygen saturation" }, + "estimated_ready_time": { + "name": "Estimated ready time" + }, "free_chlorine": { "name": "Free chlorine" }, diff --git a/custom_components/poolside/translations/en.json b/custom_components/poolside/translations/en.json index ed2d04c..8041da9 100644 --- a/custom_components/poolside/translations/en.json +++ b/custom_components/poolside/translations/en.json @@ -107,6 +107,9 @@ "dissolved_oxygen_saturation": { "name": "Dissolved oxygen saturation" }, + "estimated_ready_time": { + "name": "Estimated ready time" + }, "free_chlorine": { "name": "Free chlorine" }, From 08b69604b39901006d3a35d0b667d53e266e0b67 Mon Sep 17 00:00:00 2001 From: Juan Altmayer Pizzorno Date: Thu, 1 Oct 2026 10:11:42 -0400 Subject: [PATCH 2/4] Clarify where the TemperatureRiseInformation constants belong Co-Authored-By: Claude Opus 5.5 --- custom_components/poolside/const.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/poolside/const.py b/custom_components/poolside/const.py index 97a1679..11a5dd9 100644 --- a/custom_components/poolside/const.py +++ b/custom_components/poolside/const.py @@ -33,8 +33,8 @@ SITE_MODE_KEY = "site_mode" # Body-of-water field describing an ongoing heat-up (or cool-down) toward the -# set point, including the controller's predicted completion time. Not yet in -# aiopoolside.const. +# set point, including the controller's predicted completion time. Not in +# aiopoolside.const as of 0.1.0; import from there once it is. TEMPERATURE_RISE_INFORMATION_FIELD = "TemperatureRiseInformation" TEMPERATURE_RISE_TARGET_END_KEY = "TargetEndDateTime" TEMPERATURE_RISE_ACTUAL_END_KEY = "ActualEndDateTime" From 3eea4e500af70aa3dbeb438320fdbaec138ac994 Mon Sep 17 00:00:00 2001 From: Juan Altmayer Pizzorno Date: Fri, 2 Oct 2026 10:02:04 -0400 Subject: [PATCH 3/4] Address review of the estimated ready time sensor - Show a ready time only when the Poolside app does: TemperatureState SAMPLED and HeatingEventType NORMAL, and only while the target is ahead; a time that passes without a new prediction is cleared at that moment. - Drop the ActualEndDateTime check (the controller clears the document when a heat-up ends). - Parse the document once per update, accepting a native value or an encoded string, with a warning when it can't be parsed. - Create the sensor the first time a body of water reports TemperatureRiseInformation, like the chemistry sensors. - List the sensor in the README. Same change as PoolsideStan/home-assistant-core#1, where it is tested. Co-Authored-By: Claude Opus 5.5 --- README.md | 5 +- custom_components/poolside/const.py | 6 +- custom_components/poolside/sensor.py | 144 ++++++++++++++++++++------- 3 files changed, 117 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 30fa96d..24424c4 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,9 @@ your network via zeroconf and talks to it over an encrypted websocket session where supported - **switch** — single-speed pumps and other on/off controls - **select** — heating/cooling mode selection -- **sensor** — water temperature, body-of-water state, site mode, and optional - read-only pool device telemetry +- **sensor** — water temperature, body-of-water state, estimated ready time + while heating (the app's "ready in"), site mode, and optional read-only pool + device telemetry ## Requirements diff --git a/custom_components/poolside/const.py b/custom_components/poolside/const.py index 11a5dd9..9856968 100644 --- a/custom_components/poolside/const.py +++ b/custom_components/poolside/const.py @@ -37,4 +37,8 @@ # aiopoolside.const as of 0.1.0; import from there once it is. TEMPERATURE_RISE_INFORMATION_FIELD = "TemperatureRiseInformation" TEMPERATURE_RISE_TARGET_END_KEY = "TargetEndDateTime" -TEMPERATURE_RISE_ACTUAL_END_KEY = "ActualEndDateTime" +TEMPERATURE_RISE_STATE_KEY = "TemperatureState" +TEMPERATURE_RISE_EVENT_TYPE_KEY = "HeatingEventType" +# The vendor app only shows a "ready in" time for a sampled, normal heat-up. +TEMPERATURE_RISE_SAMPLED = "SAMPLED" +TEMPERATURE_RISE_NORMAL = "NORMAL" diff --git a/custom_components/poolside/sensor.py b/custom_components/poolside/sensor.py index 8ce0341..a4b39a4 100644 --- a/custom_components/poolside/sensor.py +++ b/custom_components/poolside/sensor.py @@ -62,9 +62,10 @@ UnitOfTime, UnitOfVolumeFlowRate, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.util import dt as dt_util from . import PoolsideConfigEntry @@ -72,8 +73,11 @@ DOMAIN, LOGGER, SITE_MODE_KEY, - TEMPERATURE_RISE_ACTUAL_END_KEY, + TEMPERATURE_RISE_EVENT_TYPE_KEY, TEMPERATURE_RISE_INFORMATION_FIELD, + TEMPERATURE_RISE_NORMAL, + TEMPERATURE_RISE_SAMPLED, + TEMPERATURE_RISE_STATE_KEY, TEMPERATURE_RISE_TARGET_END_KEY, ) from .entity import ( @@ -236,25 +240,47 @@ def _datetime_value(value: Any) -> datetime | None: return parsed -def _temperature_rise_value(value: Any, key: str) -> datetime | None: - """Return a timestamp from a TemperatureRiseInformation document. +def _temperature_rise_document( + client: PoolsideClient, body_of_water_uuid: str +) -> dict[str, Any] | None: + """Return a body of water's TemperatureRiseInformation document, if any. - The controller sends unset timestamps as 0001-01-01 placeholders (e.g. - TargetEndDateTime while it is still sampling the water temperature), - which map to no data, as does a missing or malformed document. + Like other JSON-in-string fields it arrives encoded inside the string + value in status pushes; a native document is accepted too. """ - if not isinstance(value, str): - return None - try: - document = json.loads(value) - except ValueError: - return None - if not isinstance(document, dict): + value = client.get_status(body_of_water_uuid, TEMPERATURE_RISE_INFORMATION_FIELD) + if isinstance(value, str): + try: + value = json.loads(value) + except ValueError: + LOGGER.warning( + "%s: unparsable %s: %r", + body_of_water_uuid, + TEMPERATURE_RISE_INFORMATION_FIELD, + value, + ) + return None + return value if isinstance(value, dict) else None + + +def _predicted_ready_time(document: dict[str, Any] | None) -> datetime | None: + """Return a heat-up's predicted completion time, as the vendor app shows it. + + The app only shows one for a sampled, normal heat-up: while the + controller is still sampling it already fills in a preliminary estimate, + and maintaining temperature or learning aren't heat-ups to wait for. An + unset TargetEndDateTime arrives as a 0001-01-01 placeholder. + """ + if ( + document is None + or document.get(TEMPERATURE_RISE_STATE_KEY) != TEMPERATURE_RISE_SAMPLED + or document.get(TEMPERATURE_RISE_EVENT_TYPE_KEY) != TEMPERATURE_RISE_NORMAL + ): return None - parsed = _datetime_value(document.get(key)) - if parsed is None or parsed.year <= 1: + target = _datetime_value(document.get(TEMPERATURE_RISE_TARGET_END_KEY)) + if target is None or target.year <= 1: return None - return parsed + return target @dataclass(frozen=True, kw_only=True) @@ -572,7 +598,6 @@ async def async_setup_entry( PoolsideBodySensor(client, group, body_of_water_uuid, TEMPERATURE_SENSOR) ) entities.append(PoolsideBodyStateSensor(client, group, body_of_water_uuid)) - entities.append(PoolsideBodyReadyTimeSensor(client, group, body_of_water_uuid)) entities.extend( PoolsideControlDisabledReasonSensor(client, control) for control in data.controls @@ -596,9 +621,21 @@ async def async_setup_entry( added: set[str] = set() @callback - def _async_add_reported_chemistry() -> None: - new_entities: list[PoolsideBodySensor] = [] + def _async_add_reported_body_sensors() -> None: + new_entities: list[SensorEntity] = [] for group, body_of_water_uuid in bodies: + # Only bodies with a heater report heat-ups. + ready_key = f"{body_of_water_uuid}_estimated_ready_time" + if ready_key not in added and ( + client.get_status( + body_of_water_uuid, TEMPERATURE_RISE_INFORMATION_FIELD + ) + is not None + ): + added.add(ready_key) + new_entities.append( + PoolsideBodyReadyTimeSensor(client, group, body_of_water_uuid) + ) for description in CHEMISTRY_SENSORS: added_key = f"{body_of_water_uuid}_{description.key}" if ( @@ -613,10 +650,12 @@ def _async_add_reported_chemistry() -> None: if new_entities: async_add_entities(new_entities) - _async_add_reported_chemistry() + _async_add_reported_body_sensors() for _group, body_of_water_uuid in bodies: entry.async_on_unload( - client.subscribe_status(body_of_water_uuid, _async_add_reported_chemistry) + client.subscribe_status( + body_of_water_uuid, _async_add_reported_body_sensors + ) ) # Pre-seeded so a descriptor that lists ActualPowerState or Winterized @@ -729,10 +768,9 @@ def native_value(self) -> str | None: class PoolsideBodyReadyTimeSensor(PoolsideGroupEntity, SensorEntity): """When a heating body of water is predicted to reach its set point. - Taken from the controller's TemperatureRiseInformation, the same - prediction the vendor app shows as "ready in". Unknown while the - controller is still sampling the water, once the set point has been - reached, and whenever the body isn't heating toward it. + The controller's prediction from TemperatureRiseInformation, shown when + the vendor app shows its "ready in" time. Unknown otherwise, including + once the predicted time has passed without a new prediction. """ _attr_device_class = SensorDeviceClass.TIMESTAMP @@ -747,22 +785,58 @@ def __init__( self._attr_unique_id = ( f"{client.controller_uuid}_{body_of_water_uuid}_estimated_ready_time" ) + self._target: datetime | None = None + self._cancel_expiry: CALLBACK_TYPE | None = None @override - def _status_keys(self) -> set[str]: - """Return the body-of-water key the prediction arrives under.""" - return {self._body_of_water_uuid} + async def async_added_to_hass(self) -> None: + """Track the prediction, and clear it once its time has passed.""" + await super().async_added_to_hass() + self.async_on_remove( + self._client.subscribe_status( + self._body_of_water_uuid, self._async_prediction_updated + ) + ) + self.async_on_remove(self._async_cancel_expiry) + self._async_read_prediction() + + @callback + def _async_read_prediction(self) -> None: + """Read the current prediction and schedule a refresh when it passes.""" + self._async_cancel_expiry() + self._target = _predicted_ready_time( + _temperature_rise_document(self._client, self._body_of_water_uuid) + ) + if self._target is not None and self._target > dt_util.utcnow(): + self._cancel_expiry = async_track_point_in_utc_time( + self.hass, self._async_prediction_expired, self._target + ) + + @callback + def _async_prediction_updated(self) -> None: + """Handle a status push for the body of water.""" + self._async_read_prediction() + self.async_write_ha_state() + + @callback + def _async_prediction_expired(self, _now: datetime) -> None: + """Drop a predicted time that has passed without a new prediction.""" + self._cancel_expiry = None + self.async_write_ha_state() + + @callback + def _async_cancel_expiry(self) -> None: + if self._cancel_expiry is not None: + self._cancel_expiry() + self._cancel_expiry = None @property @override def native_value(self) -> datetime | None: - """Return the predicted completion time of the current heat-up.""" - value = self._client.get_status( - self._body_of_water_uuid, TEMPERATURE_RISE_INFORMATION_FIELD - ) - if _temperature_rise_value(value, TEMPERATURE_RISE_ACTUAL_END_KEY) is not None: + """Return the predicted completion time while it's still ahead.""" + if self._target is None or self._target <= dt_util.utcnow(): return None - return _temperature_rise_value(value, TEMPERATURE_RISE_TARGET_END_KEY) + return self._target class PoolsideDeviceSensor(PoolsideDeviceEntity, SensorEntity): From e1a01d82442d46b6bcd7b9a6790f1e22ddcdb107 Mon Sep 17 00:00:00 2001 From: Stan Reznik Date: Mon, 5 Oct 2026 03:43:43 +0000 Subject: [PATCH 4/4] Import the TemperatureRiseInformation constants from aiopoolside 0.1.1 --- custom_components/poolside/const.py | 11 ----------- custom_components/poolside/manifest.json | 2 +- custom_components/poolside/sensor.py | 18 +++++++----------- 3 files changed, 8 insertions(+), 23 deletions(-) diff --git a/custom_components/poolside/const.py b/custom_components/poolside/const.py index 9856968..2e01ff1 100644 --- a/custom_components/poolside/const.py +++ b/custom_components/poolside/const.py @@ -31,14 +31,3 @@ # unique_id suffix of the site mode sensor - the one entity not keyed to a # control or body-of-water UUID from the layout. SITE_MODE_KEY = "site_mode" - -# Body-of-water field describing an ongoing heat-up (or cool-down) toward the -# set point, including the controller's predicted completion time. Not in -# aiopoolside.const as of 0.1.0; import from there once it is. -TEMPERATURE_RISE_INFORMATION_FIELD = "TemperatureRiseInformation" -TEMPERATURE_RISE_TARGET_END_KEY = "TargetEndDateTime" -TEMPERATURE_RISE_STATE_KEY = "TemperatureState" -TEMPERATURE_RISE_EVENT_TYPE_KEY = "HeatingEventType" -# The vendor app only shows a "ready in" time for a sampled, normal heat-up. -TEMPERATURE_RISE_SAMPLED = "SAMPLED" -TEMPERATURE_RISE_NORMAL = "NORMAL" diff --git a/custom_components/poolside/manifest.json b/custom_components/poolside/manifest.json index 13ed140..c3b95d9 100644 --- a/custom_components/poolside/manifest.json +++ b/custom_components/poolside/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "issue_tracker": "https://github.com/PoolsideStan/poolside-official-integration/issues", "loggers": ["aiopoolside"], - "requirements": ["aiopoolside==0.1.0"], + "requirements": ["aiopoolside==0.1.1"], "version": "0.1.0", "zeroconf": ["_poolside._tcp.local."] } diff --git a/custom_components/poolside/sensor.py b/custom_components/poolside/sensor.py index a4b39a4..68d0889 100644 --- a/custom_components/poolside/sensor.py +++ b/custom_components/poolside/sensor.py @@ -34,6 +34,12 @@ LIGHT_NAME_FIELD, POWER_STATE_FIELD, SPEED_FIELD, + TEMPERATURE_RISE_EVENT_TYPE_KEY, + TEMPERATURE_RISE_INFORMATION_FIELD, + TEMPERATURE_RISE_NORMAL, + TEMPERATURE_RISE_SAMPLED, + TEMPERATURE_RISE_STATE_KEY, + TEMPERATURE_RISE_TARGET_END_KEY, TWINKLE_FIELD, TWINKLE_INCREMENTS_FIELD, WINTERIZED_FIELD, @@ -69,17 +75,7 @@ from homeassistant.util import dt as dt_util from . import PoolsideConfigEntry -from .const import ( - DOMAIN, - LOGGER, - SITE_MODE_KEY, - TEMPERATURE_RISE_EVENT_TYPE_KEY, - TEMPERATURE_RISE_INFORMATION_FIELD, - TEMPERATURE_RISE_NORMAL, - TEMPERATURE_RISE_SAMPLED, - TEMPERATURE_RISE_STATE_KEY, - TEMPERATURE_RISE_TARGET_END_KEY, -) +from .const import DOMAIN, LOGGER, SITE_MODE_KEY from .entity import ( PoolsideBaseEntity, PoolsideDeviceEntity,