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/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 a66cb14cd5d732..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, @@ -62,9 +68,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 @@ -229,6 +236,49 @@ def _datetime_value(value: Any) -> datetime | None: return parsed +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. + + Like other JSON-in-string fields it arrives encoded inside the string + value in status pushes; a native document is accepted too. + """ + 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 + target = _datetime_value(document.get(TEMPERATURE_RISE_TARGET_END_KEY)) + if target is None or target.year <= 1: + return None + return target + + @dataclass(frozen=True, kw_only=True) class PoolsideFieldSensorDescription(SensorEntityDescription): """Describes how one DisplayProcessingLogic renders as a sensor.""" @@ -567,9 +617,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 ( @@ -584,10 +646,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 @@ -697,6 +761,80 @@ 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. + + 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 + _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" + ) + self._target: datetime | None = None + self._cancel_expiry: CALLBACK_TYPE | None = None + + @override + 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 while it's still ahead.""" + if self._target is None or self._target <= dt_util.utcnow(): + return None + return self._target + + 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/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 diff --git a/tests/components/poolside/test_sensor.py b/tests/components/poolside/test_sensor.py index 675c5a3eddd105..b2df2321196b62 100644 --- a/tests/components/poolside/test_sensor.py +++ b/tests/components/poolside/test_sensor.py @@ -1,13 +1,16 @@ """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 +from freezegun.api import FrozenDateTimeFactory 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, @@ -23,11 +26,12 @@ 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" 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" @@ -53,7 +57,7 @@ def controls(hass: HomeAssistant) -> list[PoolsideControl]: 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. - 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, @@ -192,6 +196,230 @@ 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" +# 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) -> dict[str, Any]: + """Build a TemperatureRiseInformation document, shaped like a real one.""" + 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": PREDICTED, + "OriginalTargetEndDateTime": PREDICTED, + "ActualEndDateTime": UNSET_DATETIME, + "ActualEndTemperature": 0.0, + "EstimatedWattNeeded": 0, + "EstimatedBTUNeeded": 0, + "HeatingEventType": "NORMAL", + "TemperatureState": "SAMPLED", + "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( + encoded(temperature_rise_information()), + "2026-10-01T14:55:56+00:00", + id="predicted", + ), + pytest.param( + 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="maintaining-temperature", + ), + pytest.param( + 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-passed", + ), + pytest.param( + encoded(temperature_rise_information(TargetEndDateTime=UNSET_DATETIME)), + STATE_UNKNOWN, + id="unset-target", + ), + pytest.param( + encoded(temperature_rise_information(TargetEndDateTime=None)), + STATE_UNKNOWN, + id="no-target", + ), + pytest.param("[]", STATE_UNKNOWN, id="not-a-document"), + ], +) +@pytest.mark.freeze_time(NOW) +@pytest.mark.usefixtures("setup_integration") +async def test_ready_time_sensor_matches_the_app( + hass: HomeAssistant, + fake_client: FakePoolsideClient, + raw_value: Any, + expected_state: str, +) -> None: + """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 + ) + 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.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; clearing the field resets it.""" + 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", + encoded( + 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.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,