From 1f1672f63b793b9d82a00b1976c8a15b9f3c697d Mon Sep 17 00:00:00 2001 From: Juan Altmayer Pizzorno Date: Thu, 1 Oct 2026 09:52:45 -0400 Subject: [PATCH 1/4] Add an estimated ready time sensor to Poolside 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 per body of water. The value is unknown while the controller is still sampling the water temperature (TargetEndDateTime is a 0001-01-01 placeholder), once ActualEndDateTime is set, when the document is missing or malformed, and when no heat-up is in progress. Co-Authored-By: Claude Opus 5.5 --- homeassistant/components/poolside/const.py | 7 + homeassistant/components/poolside/icons.json | 3 + homeassistant/components/poolside/sensor.py | 70 +++++++++- .../components/poolside/strings.json | 3 + tests/components/poolside/test_sensor.py | 129 +++++++++++++++++- 5 files changed, 209 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/poolside/const.py b/homeassistant/components/poolside/const.py index 2e01ff120dbf7a..97a1679a9e69b8 100644 --- a/homeassistant/components/poolside/const.py +++ b/homeassistant/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/homeassistant/components/poolside/icons.json b/homeassistant/components/poolside/icons.json index 52d26d95ce33b8..c5e829727216ea 100644 --- a/homeassistant/components/poolside/icons.json +++ b/homeassistant/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/homeassistant/components/poolside/sensor.py b/homeassistant/components/poolside/sensor.py index a66cb14cd5d732..8ce03418dac49e 100644 --- a/homeassistant/components/poolside/sensor.py +++ b/homeassistant/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/homeassistant/components/poolside/strings.json b/homeassistant/components/poolside/strings.json index 489a2e16ac893a..79f08644bc254d 100644 --- a/homeassistant/components/poolside/strings.json +++ b/homeassistant/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/tests/components/poolside/test_sensor.py b/tests/components/poolside/test_sensor.py index 675c5a3eddd105..d518252cc64cc4 100644 --- a/tests/components/poolside/test_sensor.py +++ b/tests/components/poolside/test_sensor.py @@ -1,13 +1,15 @@ """Tests for the Poolside sensors: water temperature and site mode.""" +import json from typing import Any from aiopoolside import PoolsideControl, PoolsideSite from aiopoolside.const import ControlType, GroupKind import pytest -from homeassistant.components.sensor import ATTR_OPTIONS +from homeassistant.components.sensor import ATTR_OPTIONS, SensorDeviceClass from homeassistant.const import ( + ATTR_DEVICE_CLASS, ATTR_UNIT_OF_MEASUREMENT, STATE_UNAVAILABLE, STATE_UNKNOWN, @@ -28,6 +30,7 @@ ENTITY_ID = "sensor.pool_temperature" MODE_ENTITY_ID = "sensor.test_residence_controller_mode" WATER_STATE_ENTITY_ID = "sensor.pool_water_state" +READY_TIME_ENTITY_ID = "sensor.pool_estimated_ready_time" DISABLED_REASON_ENTITY_ID = "sensor.pool_heater_disabled_reason" @@ -51,13 +54,14 @@ def controls(hass: HomeAssistant) -> list[PoolsideControl]: @pytest.mark.usefixtures("setup_integration") async def test_expected_sensors_created(hass: HomeAssistant) -> None: - """Temperature and water state per body of water (none for landscape groups), a disabled reason sensor per control, plus the site mode sensor. + """Temperature, water state and ready time per body of water (none for landscape groups), a disabled reason sensor per control, plus the site mode sensor. Chemistry sensors are absent until their fields are actually reported. """ assert set(hass.states.async_entity_ids("sensor")) == { ENTITY_ID, WATER_STATE_ENTITY_ID, + READY_TIME_ENTITY_ID, MODE_ENTITY_ID, DISABLED_REASON_ENTITY_ID, "sensor.pool_pool_light_disabled_reason", @@ -192,6 +196,126 @@ async def test_water_state_sensor_reflects_pushes( assert state.state == STATE_UNKNOWN +# The controller's placeholder for an unset timestamp, as sent on the wire. +UNSET_DATETIME = "0001-01-01T00:00:00-04:24" + + +def temperature_rise_information(**overrides: Any) -> str: + """Build a TemperatureRiseInformation push, shaped like a real controller's.""" + document: dict[str, Any] = { + "HeaterUUIDs": [], + "UUID": "rise-1", + "HvacMode": "HEATING", + "LastCalculatedDateTime": "2026-10-01T09:14:56.5610490-04:00", + "LastCalculatedWaterTemperature": 82.655655, + "StartTemperature": 82.655655, + "TargetTemperature": 102.0, + "SetPointChangedOrTStatWasTurnedOff": False, + "StartDateTime": "2026-10-01T09:09:39.5644686-04:00", + "TargetEndDateTime": "2026-10-01T10:55:56.5610490-04:00", + "OriginalTargetEndDateTime": "2026-10-01T10:55:56.5610490-04:00", + "ActualEndDateTime": UNSET_DATETIME, + "ActualEndTemperature": 0.0, + "EstimatedWattNeeded": 0, + "EstimatedBTUNeeded": 0, + "HeatingEventType": "NORMAL", + "TemperatureState": "SAMPLED", + "OriginalFutureHeatingEvent": None, + } + document.update(overrides) + return json.dumps(document, indent=2) + + +@pytest.mark.parametrize( + ("raw_value", "expected_state"), + [ + pytest.param( + temperature_rise_information(), + "2026-10-01T14:55:56+00:00", + id="predicted", + ), + pytest.param( + temperature_rise_information( + TemperatureState="SAMPLING", + StartTemperature=None, + TargetEndDateTime=UNSET_DATETIME, + OriginalTargetEndDateTime=UNSET_DATETIME, + ), + STATE_UNKNOWN, + id="still-sampling", + ), + pytest.param( + temperature_rise_information( + ActualEndDateTime="2026-10-01T10:54:10.0000000-04:00" + ), + STATE_UNKNOWN, + id="target-reached", + ), + pytest.param( + temperature_rise_information(TargetEndDateTime=None), + STATE_UNKNOWN, + id="no-target", + ), + pytest.param("not json", STATE_UNKNOWN, id="malformed"), + pytest.param("[]", STATE_UNKNOWN, id="not-a-document"), + ], +) +@pytest.mark.usefixtures("setup_integration") +async def test_ready_time_sensor_reflects_temperature_rise( + hass: HomeAssistant, + fake_client: FakePoolsideClient, + raw_value: str, + expected_state: str, +) -> None: + """The ready time is the controller's TargetEndDateTime, unknown while unset.""" + state = hass.states.get(READY_TIME_ENTITY_ID) + assert state is not None + assert state.state == STATE_UNKNOWN + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.TIMESTAMP + + fake_client.set_status( + TEST_BODY_OF_WATER_UUID, "TemperatureRiseInformation", raw_value + ) + await hass.async_block_till_done() + + state = hass.states.get(READY_TIME_ENTITY_ID) + assert state is not None + assert state.state == expected_state + + +@pytest.mark.usefixtures("setup_integration") +async def test_ready_time_sensor_follows_revisions( + hass: HomeAssistant, + fake_client: FakePoolsideClient, +) -> None: + """Revised predictions update the sensor, and clearing the field resets it.""" + fake_client.set_status( + TEST_BODY_OF_WATER_UUID, + "TemperatureRiseInformation", + temperature_rise_information(), + ) + await hass.async_block_till_done() + fake_client.set_status( + TEST_BODY_OF_WATER_UUID, + "TemperatureRiseInformation", + temperature_rise_information( + TargetEndDateTime="2026-10-01T10:54:32.1000000-04:00" + ), + ) + await hass.async_block_till_done() + + state = hass.states.get(READY_TIME_ENTITY_ID) + assert state is not None + assert state.state == "2026-10-01T14:54:32+00:00" + + fake_client.set_status(TEST_BODY_OF_WATER_UUID, "TemperatureRiseInformation", None) + await hass.async_block_till_done() + + state = hass.states.get(READY_TIME_ENTITY_ID) + assert state is not None + assert state.state == STATE_UNKNOWN + + @pytest.mark.usefixtures("setup_integration") async def test_site_mode_sensor_reflects_pushes( hass: HomeAssistant, @@ -254,6 +378,7 @@ async def test_no_mode_sensor_without_site_uuid( assert set(hass.states.async_entity_ids("sensor")) == { ENTITY_ID, WATER_STATE_ENTITY_ID, + READY_TIME_ENTITY_ID, DISABLED_REASON_ENTITY_ID, } From 1b4185c7c80ff47e43f2c568e78c230d2f4f5d6b Mon Sep 17 00:00:00 2001 From: Juan Altmayer Pizzorno Date: Thu, 1 Oct 2026 10:11:44 -0400 Subject: [PATCH 2/4] Clarify where the TemperatureRiseInformation constants belong Co-Authored-By: Claude Opus 5.5 --- homeassistant/components/poolside/const.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/poolside/const.py b/homeassistant/components/poolside/const.py index 97a1679a9e69b8..11a5dd95e4c0ab 100644 --- a/homeassistant/components/poolside/const.py +++ b/homeassistant/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 1b814ad60651cc1933ddba9675ad698e7d6f224c Mon Sep 17 00:00:00 2001 From: Juan Altmayer Pizzorno Date: Fri, 2 Oct 2026 10:01:43 -0400 Subject: [PATCH 3/4] Address review of the estimated ready time sensor - Show a ready time only when the vendor app does: TemperatureState SAMPLED and HeatingEventType NORMAL (no preliminary estimate while sampling, none while maintaining temperature or learning), and only while the target is ahead. A time that passes without a new prediction is cleared at that moment, rather than lingering as "x minutes ago". - Drop the ActualEndDateTime check: the controller clears the document when a heat-up ends, so it's never set on the value read. - Parse the document once per update, accepting a native value or an encoded string, and warn when it can't be parsed, like other JSON fields. - Create the sensor the first time a body of water reports TemperatureRiseInformation, like the chemistry sensors, so bodies without a heater don't get one. Tests first; coverage of the sensor's new code stays complete. Co-Authored-By: Claude Opus 5.5 --- homeassistant/components/poolside/const.py | 6 +- homeassistant/components/poolside/sensor.py | 144 +++++++++++++---- tests/components/poolside/test_sensor.py | 167 ++++++++++++++++---- 3 files changed, 249 insertions(+), 68 deletions(-) diff --git a/homeassistant/components/poolside/const.py b/homeassistant/components/poolside/const.py index 11a5dd95e4c0ab..9856968825d2b2 100644 --- a/homeassistant/components/poolside/const.py +++ b/homeassistant/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/homeassistant/components/poolside/sensor.py b/homeassistant/components/poolside/sensor.py index 8ce03418dac49e..a4b39a49f8f992 100644 --- a/homeassistant/components/poolside/sensor.py +++ b/homeassistant/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): diff --git a/tests/components/poolside/test_sensor.py b/tests/components/poolside/test_sensor.py index d518252cc64cc4..b2df2321196b62 100644 --- a/tests/components/poolside/test_sensor.py +++ b/tests/components/poolside/test_sensor.py @@ -5,6 +5,7 @@ from aiopoolside import PoolsideControl, PoolsideSite from aiopoolside.const import ControlType, GroupKind +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.sensor import ATTR_OPTIONS, SensorDeviceClass @@ -25,7 +26,7 @@ make_group, ) -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed ENTITY_ID = "sensor.pool_temperature" MODE_ENTITY_ID = "sensor.test_residence_controller_mode" @@ -54,14 +55,13 @@ def controls(hass: HomeAssistant) -> list[PoolsideControl]: @pytest.mark.usefixtures("setup_integration") async def test_expected_sensors_created(hass: HomeAssistant) -> None: - """Temperature, water state and ready time per body of water (none for landscape groups), a disabled reason sensor per control, plus the site mode sensor. + """Temperature and water state per body of water (none for landscape groups), a disabled reason sensor per control, plus the site mode sensor. - Chemistry sensors are absent until their fields are actually reported. + Chemistry and ready time sensors are absent until their fields are actually reported. """ assert set(hass.states.async_entity_ids("sensor")) == { ENTITY_ID, WATER_STATE_ENTITY_ID, - READY_TIME_ENTITY_ID, MODE_ENTITY_ID, DISABLED_REASON_ENTITY_ID, "sensor.pool_pool_light_disabled_reason", @@ -198,10 +198,14 @@ async def test_water_state_sensor_reflects_pushes( # The controller's placeholder for an unset timestamp, as sent on the wire. UNSET_DATETIME = "0001-01-01T00:00:00-04:24" +# During a heat-up captured from a controller: sampled at 9:14:56 AM, ready +# predicted for 10:55:56 AM. +NOW = "2026-10-01T09:20:00-04:00" +PREDICTED = "2026-10-01T10:55:56.5610490-04:00" -def temperature_rise_information(**overrides: Any) -> str: - """Build a TemperatureRiseInformation push, shaped like a real controller's.""" +def temperature_rise_information(**overrides: Any) -> dict[str, Any]: + """Build a TemperatureRiseInformation document, shaped like a real one.""" document: dict[str, Any] = { "HeaterUUIDs": [], "UUID": "rise-1", @@ -212,8 +216,8 @@ def temperature_rise_information(**overrides: Any) -> str: "TargetTemperature": 102.0, "SetPointChangedOrTStatWasTurnedOff": False, "StartDateTime": "2026-10-01T09:09:39.5644686-04:00", - "TargetEndDateTime": "2026-10-01T10:55:56.5610490-04:00", - "OriginalTargetEndDateTime": "2026-10-01T10:55:56.5610490-04:00", + "TargetEndDateTime": PREDICTED, + "OriginalTargetEndDateTime": PREDICTED, "ActualEndDateTime": UNSET_DATETIME, "ActualEndTemperature": 0.0, "EstimatedWattNeeded": 0, @@ -223,56 +227,101 @@ def temperature_rise_information(**overrides: Any) -> str: "OriginalFutureHeatingEvent": None, } document.update(overrides) + return document + + +def encoded(document: dict[str, Any]) -> str: + """Encode a document the way status pushes carry it: JSON in a string.""" return json.dumps(document, indent=2) +@pytest.mark.usefixtures("setup_integration") +async def test_ready_time_sensor_created_when_first_reported( + hass: HomeAssistant, + fake_client: FakePoolsideClient, +) -> None: + """The ready time sensor appears once the body of water reports a heat-up. + + Like the chemistry sensors: a body without a heater never reports one. + """ + assert hass.states.get(READY_TIME_ENTITY_ID) is None + + fake_client.set_status( + TEST_BODY_OF_WATER_UUID, + "TemperatureRiseInformation", + encoded(temperature_rise_information()), + ) + await hass.async_block_till_done() + + state = hass.states.get(READY_TIME_ENTITY_ID) + assert state is not None + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.TIMESTAMP + + @pytest.mark.parametrize( ("raw_value", "expected_state"), [ pytest.param( - temperature_rise_information(), + encoded(temperature_rise_information()), "2026-10-01T14:55:56+00:00", id="predicted", ), pytest.param( - temperature_rise_information( - TemperatureState="SAMPLING", - StartTemperature=None, - TargetEndDateTime=UNSET_DATETIME, - OriginalTargetEndDateTime=UNSET_DATETIME, + temperature_rise_information(), + "2026-10-01T14:55:56+00:00", + id="native-document", + ), + pytest.param( + encoded(temperature_rise_information(TemperatureState="SAMPLING")), + STATE_UNKNOWN, + id="preliminary-while-sampling", + ), + pytest.param( + encoded( + temperature_rise_information(HeatingEventType="MAINTAIN_TEMPERATURE") ), STATE_UNKNOWN, - id="still-sampling", + id="maintaining-temperature", ), pytest.param( - temperature_rise_information( - ActualEndDateTime="2026-10-01T10:54:10.0000000-04:00" + encoded(temperature_rise_information(HeatingEventType="LEARNING")), + STATE_UNKNOWN, + id="learning", + ), + pytest.param( + encoded( + temperature_rise_information( + TargetEndDateTime="2026-10-01T09:10:00.0000000-04:00" + ) ), STATE_UNKNOWN, - id="target-reached", + id="target-passed", + ), + pytest.param( + encoded(temperature_rise_information(TargetEndDateTime=UNSET_DATETIME)), + STATE_UNKNOWN, + id="unset-target", ), pytest.param( - temperature_rise_information(TargetEndDateTime=None), + encoded(temperature_rise_information(TargetEndDateTime=None)), STATE_UNKNOWN, id="no-target", ), - pytest.param("not json", STATE_UNKNOWN, id="malformed"), pytest.param("[]", STATE_UNKNOWN, id="not-a-document"), ], ) +@pytest.mark.freeze_time(NOW) @pytest.mark.usefixtures("setup_integration") -async def test_ready_time_sensor_reflects_temperature_rise( +async def test_ready_time_sensor_matches_the_app( hass: HomeAssistant, fake_client: FakePoolsideClient, - raw_value: str, + raw_value: Any, expected_state: str, ) -> None: - """The ready time is the controller's TargetEndDateTime, unknown while unset.""" - state = hass.states.get(READY_TIME_ENTITY_ID) - assert state is not None - assert state.state == STATE_UNKNOWN - assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.TIMESTAMP + """The ready time is shown only when the vendor app shows one. + That is, for a sampled, normal heat-up whose target is still ahead. + """ fake_client.set_status( TEST_BODY_OF_WATER_UUID, "TemperatureRiseInformation", raw_value ) @@ -283,23 +332,51 @@ async def test_ready_time_sensor_reflects_temperature_rise( assert state.state == expected_state +@pytest.mark.freeze_time(NOW) +@pytest.mark.usefixtures("setup_integration") +async def test_ready_time_sensor_warns_on_unparsable_document( + hass: HomeAssistant, + fake_client: FakePoolsideClient, + caplog: pytest.LogCaptureFixture, +) -> None: + """An unparsable document is logged and shown as unknown.""" + fake_client.set_status( + TEST_BODY_OF_WATER_UUID, + "TemperatureRiseInformation", + encoded(temperature_rise_information()), + ) + await hass.async_block_till_done() + fake_client.set_status( + TEST_BODY_OF_WATER_UUID, "TemperatureRiseInformation", "not json" + ) + await hass.async_block_till_done() + + state = hass.states.get(READY_TIME_ENTITY_ID) + assert state is not None + assert state.state == STATE_UNKNOWN + assert "unparsable TemperatureRiseInformation" in caplog.text + + +@pytest.mark.freeze_time(NOW) @pytest.mark.usefixtures("setup_integration") async def test_ready_time_sensor_follows_revisions( hass: HomeAssistant, fake_client: FakePoolsideClient, ) -> None: - """Revised predictions update the sensor, and clearing the field resets it.""" + """Revised predictions update the sensor; clearing the field resets it.""" fake_client.set_status( TEST_BODY_OF_WATER_UUID, "TemperatureRiseInformation", - temperature_rise_information(), + encoded(temperature_rise_information()), ) await hass.async_block_till_done() fake_client.set_status( TEST_BODY_OF_WATER_UUID, "TemperatureRiseInformation", - temperature_rise_information( - TargetEndDateTime="2026-10-01T10:54:32.1000000-04:00" + encoded( + temperature_rise_information( + TargetEndDateTime="2026-10-01T10:54:32.1000000-04:00" + ) ), ) await hass.async_block_till_done() @@ -316,6 +393,33 @@ async def test_ready_time_sensor_follows_revisions( assert state.state == STATE_UNKNOWN +@pytest.mark.freeze_time(NOW) +@pytest.mark.usefixtures("setup_integration") +async def test_ready_time_sensor_clears_when_the_target_passes( + hass: HomeAssistant, + fake_client: FakePoolsideClient, + freezer: FrozenDateTimeFactory, +) -> None: + """The ready time is cleared once it passes, even without a new push. + + Otherwise it would linger and show as "x minutes ago". + """ + fake_client.set_status( + TEST_BODY_OF_WATER_UUID, + "TemperatureRiseInformation", + encoded(temperature_rise_information()), + ) + await hass.async_block_till_done() + + freezer.move_to("2026-10-01T10:56:00-04:00") + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(READY_TIME_ENTITY_ID) + assert state is not None + assert state.state == STATE_UNKNOWN + + @pytest.mark.usefixtures("setup_integration") async def test_site_mode_sensor_reflects_pushes( hass: HomeAssistant, @@ -378,7 +482,6 @@ async def test_no_mode_sensor_without_site_uuid( assert set(hass.states.async_entity_ids("sensor")) == { ENTITY_ID, WATER_STATE_ENTITY_ID, - READY_TIME_ENTITY_ID, DISABLED_REASON_ENTITY_ID, } From 3704a57d3992b22a33e25c0ab3e36c6fa9aa85e0 Mon Sep 17 00:00:00 2001 From: Stan Reznik Date: Mon, 5 Oct 2026 03:43:09 +0000 Subject: [PATCH 4/4] Import the TemperatureRiseInformation constants from aiopoolside 0.1.1 --- homeassistant/components/poolside/const.py | 11 ----------- .../components/poolside/manifest.json | 2 +- homeassistant/components/poolside/sensor.py | 18 +++++++----------- requirements_all.txt | 2 +- 4 files changed, 9 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/poolside/const.py b/homeassistant/components/poolside/const.py index 9856968825d2b2..2e01ff120dbf7a 100644 --- a/homeassistant/components/poolside/const.py +++ b/homeassistant/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/homeassistant/components/poolside/manifest.json b/homeassistant/components/poolside/manifest.json index 9c1c44d1b5304b..3d8db7ce4c6948 100644 --- a/homeassistant/components/poolside/manifest.json +++ b/homeassistant/components/poolside/manifest.json @@ -8,6 +8,6 @@ "iot_class": "local_push", "loggers": ["aiopoolside"], "quality_scale": "bronze", - "requirements": ["aiopoolside==0.1.0"], + "requirements": ["aiopoolside==0.1.1"], "zeroconf": ["_poolside._tcp.local."] } diff --git a/homeassistant/components/poolside/sensor.py b/homeassistant/components/poolside/sensor.py index a4b39a49f8f992..68d08898fb3b7f 100644 --- a/homeassistant/components/poolside/sensor.py +++ b/homeassistant/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, diff --git a/requirements_all.txt b/requirements_all.txt index df25d120dd39bc..9dbe736a455e32 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -381,7 +381,7 @@ aiopegelonline==0.1.1 aiopnsense==1.0.10 # homeassistant.components.poolside -aiopoolside==0.1.0 +aiopoolside==0.1.1 # homeassistant.components.ptdevices aioptdevices==2026.03.2