From 6d5f4c2b448c0ac96bea7d5a9ad9c9b31ae9b283 Mon Sep 17 00:00:00 2001 From: Kevin M Date: Wed, 21 Oct 2026 19:51:31 -0500 Subject: [PATCH 01/13] feat: surface locally-pushed dose events, water quality, energy, egg timer entities The controller's Device.setStatus local push stream already delivers several status items the integration drops at the entity layer: - Last chemical dose per dosing system (CurrentAcidMixEvent / CurrentLiquidChlorineMixEvent / CurrentSaltChlorinatorMixEvent): a measurement of the dose amount with duration, timestamps, mix minutes, probe reading before, interruption, and origin as attributes. The item's JSON document parses tolerantly and the last dose is preserved when the item goes null between doses. - Water quality (WaterQualityState): enum sensor over the raw GOOD/OK/LOW-style values with a tolerant mapper. - Last-minute energy consumption (LastMinuteOfEnergyConsumption): an electric power sensor (TotalWattsUsed) and a gas sensor (TotalNaturalGasBtuUsed, with TotalLiquidPropaneBtuUsed as an attribute) - giving gas-heated pools a gas-usage sensor. - Heater timed-session end (OnUntil): a timestamp sensor per temperature control that reports the item. All new entities follow the existing patterns: added on first report (the equipment behind them is optional), availability gated on the client connection, and null-safe against missing or malformed items. Includes unit tests for the JSON-string parsing helpers. --- README.md | 6 +- custom_components/poolside/const.py | 40 ++ custom_components/poolside/icons.json | 25 + custom_components/poolside/sensor.py | 476 +++++++++++++++++- custom_components/poolside/strings.json | 28 ++ .../poolside/translations/en.json | 28 ++ pytest.ini | 3 + tests/test_sensor_parsing.py | 151 ++++++ 8 files changed, 748 insertions(+), 9 deletions(-) create mode 100644 pytest.ini create mode 100644 tests/test_sensor_parsing.py diff --git a/README.md b/README.md index 24424c4..1faf9b5 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,10 @@ 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, estimated ready time - while heating (the app's "ready in"), site mode, and optional read-only pool +- **sensor** — water temperature, body-of-water state, water quality, last + chemical dose per dosing system, last-minute energy consumption (electric + watts and gas Btu), estimated ready time while heating (the app's "ready + in"), heater timed-session end, 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 2e01ff1..0d14b75 100644 --- a/custom_components/poolside/const.py +++ b/custom_components/poolside/const.py @@ -31,3 +31,43 @@ # 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" + +# Locally pushed status items (Device.setStatus) not yet named by +# aiopoolside. The first four are keyed by the body-of-water UUID; OnUntil +# by a TEMPERATURE control's own keys. Documents arrive as JSON encoded +# inside the string value, like other capability documents. + +# The body of water's most recent chemical dose event, per chemical type. +# The salt-chlorinator item is only reported when the site's equipment +# actually chlorinates with salt. +ACID_MIX_EVENT_FIELD = "CurrentAcidMixEvent" +LIQUID_CHLORINE_MIX_EVENT_FIELD = "CurrentLiquidChlorineMixEvent" +SALT_CHLORINATOR_MIX_EVENT_FIELD = "CurrentSaltChlorinatorMixEvent" + +# The body of water's overall chemistry health (GOOD/OK/LOW/...). +WATER_QUALITY_STATE_FIELD = "WaterQualityState" + +# A JSON document with the energy the body of water's equipment consumed +# over the last minute (electric watts plus gas/propane Btu). +LAST_MINUTE_OF_ENERGY_CONSUMPTION_FIELD = "LastMinuteOfEnergyConsumption" + +# A TEMPERATURE control's timed-session end time ("egg timer"), null while +# no timed session is running. +ON_UNTIL_FIELD = "OnUntil" + +# Keys inside a Current*MixEvent document. +DOSE_AMOUNT_KEY = "DoseAmount" +DOSE_SECONDS_KEY = "DoseSeconds" +DOSE_UNIT_KEY = "DosingUnit" +MIX_MINUTES_KEY = "MixMinutes" +DOSE_START_KEY = "StartDateTime" +DOSE_END_KEY = "EndDateTime" +DOSE_INTERRUPTED_KEY = "InterruptedAt" +DOSE_PROBE_BEFORE_KEY = "Before" +DOSE_CUT_SHORT_KEY = "DosingCutShort" +DOSE_MANUAL_KEY = "ManuallyInitiatedEvent" + +# Keys inside a LastMinuteOfEnergyConsumption document. +ENERGY_WATTS_KEY = "TotalWattsUsed" +ENERGY_NATURAL_GAS_KEY = "TotalNaturalGasBtuUsed" +ENERGY_LIQUID_PROPANE_KEY = "TotalLiquidPropaneBtuUsed" diff --git a/custom_components/poolside/icons.json b/custom_components/poolside/icons.json index c5e8297..5dc66f5 100644 --- a/custom_components/poolside/icons.json +++ b/custom_components/poolside/icons.json @@ -20,6 +20,9 @@ "actual_power_state": { "default": "mdi:power" }, + "acid_dose": { + "default": "mdi:test-tube" + }, "actuator_state": { "default": "mdi:valve", "state": { @@ -31,6 +34,9 @@ "overload": "mdi:alert-circle" } }, + "chlorine_dose": { + "default": "mdi:beaker-outline" + }, "current_state": { "default": "mdi:state-machine" }, @@ -54,12 +60,24 @@ "free_chlorine": { "default": "mdi:beaker" }, + "gas_consumption": { + "default": "mdi:meter-gas" + }, + "on_until": { + "default": "mdi:timer-outline" + }, "orp": { "default": "mdi:flash" }, "ph": { "default": "mdi:ph" }, + "power_consumption": { + "default": "mdi:flash" + }, + "salt_chlorinator_dose": { + "default": "mdi:shaker-outline" + }, "salt_level": { "default": "mdi:shaker-outline" }, @@ -74,6 +92,13 @@ "total_chlorine": { "default": "mdi:beaker" }, + "water_quality_state": { + "default": "mdi:water-check", + "state": { + "alert": "mdi:water-remove", + "low": "mdi:water-opacity" + } + }, "winterized": { "default": "mdi:snowflake" } diff --git a/custom_components/poolside/sensor.py b/custom_components/poolside/sensor.py index 68d0889..ebb0ce0 100644 --- a/custom_components/poolside/sensor.py +++ b/custom_components/poolside/sensor.py @@ -46,6 +46,7 @@ WINTERIZED_REASON, ActuatorFriendlyState, BodyOfWaterState, + ControlType, SiteMode, ) @@ -66,6 +67,7 @@ UnitOfRatio, UnitOfTemperature, UnitOfTime, + UnitOfVolume, UnitOfVolumeFlowRate, ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback @@ -75,12 +77,36 @@ from homeassistant.util import dt as dt_util from . import PoolsideConfigEntry -from .const import DOMAIN, LOGGER, SITE_MODE_KEY +from .const import ( + ACID_MIX_EVENT_FIELD, + DOSE_AMOUNT_KEY, + DOSE_CUT_SHORT_KEY, + DOSE_END_KEY, + DOSE_INTERRUPTED_KEY, + DOSE_MANUAL_KEY, + DOSE_PROBE_BEFORE_KEY, + DOSE_SECONDS_KEY, + DOSE_START_KEY, + DOSE_UNIT_KEY, + DOMAIN, + ENERGY_LIQUID_PROPANE_KEY, + ENERGY_NATURAL_GAS_KEY, + ENERGY_WATTS_KEY, + LAST_MINUTE_OF_ENERGY_CONSUMPTION_FIELD, + LIQUID_CHLORINE_MIX_EVENT_FIELD, + LOGGER, + MIX_MINUTES_KEY, + ON_UNTIL_FIELD, + SALT_CHLORINATOR_MIX_EVENT_FIELD, + SITE_MODE_KEY, + WATER_QUALITY_STATE_FIELD, +) from .entity import ( PoolsideBaseEntity, PoolsideDeviceEntity, PoolsideEntity, PoolsideGroupEntity, + confirmed_status, ) @@ -155,6 +181,84 @@ class PoolsideBodySensorDescription(SensorEntityDescription): ), ) +# Dose-event sensors: one per chemical dosing system the body of water +# reports. The item's value is the dose document encoded as a JSON string +# while a dose is running, and null once the dose has finished - the +# controller keeps the last event under the same item name until the next +# dose starts. Acid and liquid chlorine are the chemical systems every +# dose-capable body of water carries; the salt chlorinator's item only +# exists on sites whose equipment chlorinates with salt, so like the +# optional chemistry probes above it's added only once reported. +DOSE_EVENT_SENSORS = ( + PoolsideBodySensorDescription( + key="acid_dose", + translation_key="acid_dose", + field=ACID_MIX_EVENT_FIELD, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfVolume.FLUID_OUNCES, + ), + PoolsideBodySensorDescription( + key="chlorine_dose", + translation_key="chlorine_dose", + field=LIQUID_CHLORINE_MIX_EVENT_FIELD, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfVolume.FLUID_OUNCES, + ), + PoolsideBodySensorDescription( + key="salt_chlorinator_dose", + translation_key="salt_chlorinator_dose", + field=SALT_CHLORINATOR_MIX_EVENT_FIELD, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfVolume.FLUID_OUNCES, + ), +) + +# WaterQualityState values the controller has been observed to report, as +# sensor options (the raw wire values, lowercased). GOOD/OK/LOW are the +# everyday states; ALERT covers a chemistry problem and NONE a body of water +# with no chemistry management. Unrecognized values are dropped rather than +# crashing the state property. +WATER_QUALITY_OPTIONS = ["good", "ok", "low", "alert", "none"] + + +@dataclass(frozen=True, kw_only=True) +class PoolsideBodyEnergySensorDescription(SensorEntityDescription): + """Describes one reading of a body of water's last-minute energy document. + + The `LastMinuteOfEnergyConsumption` item is a JSON document with the + electric and fuel energy the body's equipment consumed over the last + minute; each sensor renders one of its totals. Fuel energy is thermal + (Btu), not metered fuel volume, so the gas reading deliberately carries + no device class: HA's gas device class is for volume meters with + totalizing state classes, and this is neither. `extra_attribute_key` + names a document total rendered as an attribute instead of its own + entity, keeping the entity count lean (propane rides along on the + natural gas sensor). + """ + + document_key: str + extra_attribute_key: str | None = None + + +ENERGY_SENSORS = ( + PoolsideBodyEnergySensorDescription( + key="power_consumption", + translation_key="power_consumption", + document_key=ENERGY_WATTS_KEY, + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + ), + PoolsideBodyEnergySensorDescription( + key="gas_consumption", + translation_key="gas_consumption", + document_key=ENERGY_NATURAL_GAS_KEY, + extra_attribute_key=ENERGY_LIQUID_PROPANE_KEY, + native_unit_of_measurement="Btu", + state_class=SensorStateClass.MEASUREMENT, + ), +) + def _float_value(value: Any) -> float | None: """Coerce a telemetry value to a number, or None if it isn't one.""" @@ -259,6 +363,110 @@ def _temperature_rise_document( return value if isinstance(value, dict) else None +def _json_document( + status_key: str, field: str, value: Any +) -> dict[str, Any] | None: + """Return a status item parsed as a JSON document, if it is one. + + Documents pushed via Device.setStatus arrive as JSON encoded inside the + string value, like other capability documents; a native document is + accepted too. Null, absent, non-JSON, and non-document values are all + "no data", never a crash: several of these items simply don't exist on + installs without the corresponding equipment. + """ + if value is None: + return None + if isinstance(value, str): + if not value.strip(): + return None + try: + value = json.loads(value) + except ValueError: + LOGGER.warning("%s: unparsable %s: %r", status_key, field, value) + return None + return value if isinstance(value, dict) else None + + +def _dose_event_document( + client: PoolsideClient, body_of_water_uuid: str, field: str +) -> dict[str, Any] | None: + """Return the body of water's last dose event document for a chemical, if any.""" + return _json_document( + body_of_water_uuid, + field, + client.get_status(body_of_water_uuid, field), + ) + + +def _dose_amount(document: dict[str, Any] | None) -> float | None: + """Return a dose document's DoseAmount, in its own dosing unit.""" + if document is None: + return None + return _float_value(document.get(DOSE_AMOUNT_KEY)) + + +def _dose_extra_attributes(document: dict[str, Any] | None) -> dict[str, Any]: + """Return the supporting details of a dose event as attributes. + + Datetimes are kept as their raw controller strings - they're attributes + for inspection, not state - and missing keys are simply omitted so an + interrupted or in-progress dose doesn't render misleading values. + """ + if document is None: + return {} + attributes: dict[str, Any] = {} + for document_key, attribute_name in ( + (DOSE_SECONDS_KEY, "duration_seconds"), + (DOSE_START_KEY, "started"), + (DOSE_END_KEY, "ended"), + (MIX_MINUTES_KEY, "mix_minutes"), + (DOSE_PROBE_BEFORE_KEY, "probe_before"), + (DOSE_INTERRUPTED_KEY, "interrupted"), + (DOSE_CUT_SHORT_KEY, "cut_short"), + (DOSE_MANUAL_KEY, "manually_initiated"), + (DOSE_UNIT_KEY, "unit"), + ): + if (value := document.get(document_key)) is not None: + attributes[attribute_name] = value + return attributes + + +def _energy_document( + client: PoolsideClient, body_of_water_uuid: str +) -> dict[str, Any] | None: + """Return the body of water's last-minute energy document, if any.""" + return _json_document( + body_of_water_uuid, + LAST_MINUTE_OF_ENERGY_CONSUMPTION_FIELD, + client.get_status( + body_of_water_uuid, LAST_MINUTE_OF_ENERGY_CONSUMPTION_FIELD + ), + ) + + +def _energy_total_reported( + client: PoolsideClient, + body_of_water_uuid: str, + description: PoolsideBodyEnergySensorDescription, +) -> bool: + """Return True once a body of water's energy document carries this total. + + A missing document or one without the total means the equipment + behind it isn't reported (all-electric bodies carry no gas total, and + vice versa), so the sensor for it is never added at all. + """ + document = _energy_document(client, body_of_water_uuid) + return document is not None and document.get(description.document_key) is not None + + +def _water_quality_value(value: Any) -> str | None: + """Map a WaterQualityState raw value to its option, or None if unrecognized.""" + if value is None: + return None + text = str(value).strip().lower() + return text if text in WATER_QUALITY_OPTIONS 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. @@ -572,12 +780,16 @@ async def async_setup_entry( ) -> None: """Set up sensors per body of water, per pool device, and the site mode sensor. - Every body gets a temperature sensor. Chemistry sensors are added the - first time their field is reported - usually straight from the initial - status snapshot, otherwise on a later push - since probes are optional - equipment a body may simply not have. Pool device sensors follow the - same pattern, keyed on the device's InformationFields document - except - for light devices, whose fixed field set never waits on a descriptor. + Every body gets a temperature sensor. Chemistry sensors, dose-event + sensors, the water quality sensor, and the energy sensors are added + the first time their item is reported - usually straight from the + initial status snapshot, otherwise on a later push - since the + equipment behind them is optional and whole items can be absent on + installs without it. Heater "egg timer" sensors follow the same + pattern per control, keyed on the control reporting OnUntil. Pool + device sensors are keyed on the device's InformationFields document - + except for light devices, whose fixed field set never waits on a + descriptor. """ data = entry.runtime_data client = data.client @@ -643,6 +855,52 @@ def _async_add_reported_body_sensors() -> None: new_entities.append( PoolsideBodySensor(client, group, body_of_water_uuid, description) ) + # Only bodies with a chemical dosing system report dose events; + # the salt chlorinator's item only exists on salt sites. + for description in DOSE_EVENT_SENSORS: + added_key = f"{body_of_water_uuid}_{description.key}" + if ( + added_key in added + or client.get_status(body_of_water_uuid, description.field) is None + ): + continue + added.add(added_key) + new_entities.append( + PoolsideBodyDoseSensor( + client, group, body_of_water_uuid, description + ) + ) + water_quality_key = f"{body_of_water_uuid}_water_quality" + if ( + water_quality_key not in added + and client.get_status( + body_of_water_uuid, WATER_QUALITY_STATE_FIELD + ) + is not None + ): + added.add(water_quality_key) + new_entities.append( + PoolsideBodyWaterQualitySensor( + client, group, body_of_water_uuid + ) + ) + # Only bodies on controller firmware reporting the last-minute + # energy document get energy sensors, and each one waits for + # its own total (a body heated electrically never grows a gas + # sensor). + for description in ENERGY_SENSORS: + added_key = f"{body_of_water_uuid}_{description.key}" + if ( + added_key in added + or not _energy_total_reported(client, body_of_water_uuid, description) + ): + continue + added.add(added_key) + new_entities.append( + PoolsideBodyEnergySensor( + client, group, body_of_water_uuid, description + ) + ) if new_entities: async_add_entities(new_entities) @@ -654,6 +912,39 @@ def _async_add_reported_body_sensors() -> None: ) ) + # Heater controls with a timed session ("egg timer") report OnUntil; + # like the heating/cooling mode selects, the sensor is added the first + # time the control reports the item, and heaters that never do (no + # timer in use) never grow the entity. + heaters = [ + control + for control in data.controls + if control.control_type is ControlType.TEMPERATURE + ] + added_on_until: set[str] = set() + + @callback + def _async_add_reported_on_until_sensors() -> None: + new_entities: list[SensorEntity] = [] + for control in heaters: + added_key = f"{control.uuid}_on_until" + if ( + added_key in added_on_until + or confirmed_status(client, control, ON_UNTIL_FIELD) is None + ): + continue + added_on_until.add(added_key) + new_entities.append(PoolsideControlOnUntilSensor(client, control)) + if new_entities: + async_add_entities(new_entities) + + _async_add_reported_on_until_sensors() + for control in heaters: + for key in {control.status_key, control.uuid, *control.member_uuids}: + entry.async_on_unload( + client.subscribe_status(key, _async_add_reported_on_until_sensors) + ) + # Pre-seeded so a descriptor that lists ActualPowerState or Winterized # itself doesn't collide with the dedicated sensors created above. added_device_fields: set[str] = { @@ -835,6 +1126,177 @@ def native_value(self) -> datetime | None: return self._target +class PoolsideBodyDoseSensor(PoolsideGroupEntity, SensorEntity): + """The most recent dose a body of water's chemical dosing system performed. + + The controller keeps one item per chemical (`CurrentAcidMixEvent`, + `CurrentLiquidChlorineMixEvent`, ...): while a dose runs, its value is + the dose document encoded as a JSON string, and it stays populated with + the completed dose afterwards, going null only briefly on the next + dose's start. A null therefore means "no new dose information", not + "the last dose never happened", so the last known document is kept and + the sensor keeps showing the last dose until the next one arrives. + """ + + entity_description: PoolsideBodySensorDescription + + def __init__( + self, + client: PoolsideClient, + group: PoolsideGroup, + body_of_water_uuid: str, + description: PoolsideBodySensorDescription, + ) -> None: + """Set up the last-dose sensor for one chemical of a body of water.""" + super().__init__(client, group) + self.entity_description = description + self._body_of_water_uuid = body_of_water_uuid + self._attr_unique_id = ( + f"{client.controller_uuid}_{body_of_water_uuid}_{description.key}" + ) + self._last_document: dict[str, Any] | None = None + + @override + def _status_keys(self) -> set[str]: + """Return the body-of-water key its dose events arrive under.""" + return {self._body_of_water_uuid} + + @property + @override + def native_value(self) -> float | None: + """Return the last dose's amount, in its own dosing unit.""" + return _dose_amount(self._last_known_document()) + + @property + @override + def extra_state_attributes(self) -> dict[str, Any]: + """Return the last dose's supporting details.""" + return _dose_extra_attributes(self._last_known_document()) + + def _last_known_document(self) -> dict[str, Any] | None: + """Return the newest dose document, keeping the last one on null.""" + document = _dose_event_document( + self._client, self._body_of_water_uuid, self.entity_description.field + ) + if document is not None: + self._last_document = document + return self._last_document + + +class PoolsideBodyWaterQualitySensor(PoolsideGroupEntity, SensorEntity): + """A body of water's overall chemistry health (its WaterQualityState). + + Follows the body state sensor: an enum sensor over the raw FRIENDLY + state values, with values the controller hasn't been observed to + report mapped to no data instead of a state outside the options. + """ + + _attr_device_class = SensorDeviceClass.ENUM + _attr_translation_key = "water_quality_state" + _attr_options = WATER_QUALITY_OPTIONS + + def __init__( + self, client: PoolsideClient, group: PoolsideGroup, body_of_water_uuid: str + ) -> None: + """Set up the water quality 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}_water_quality" + ) + + @override + def _status_keys(self) -> set[str]: + """Return the body-of-water key its quality arrives under.""" + return {self._body_of_water_uuid} + + @property + @override + def native_value(self) -> str | None: + """Return the body of water's last reported water quality.""" + return _water_quality_value( + self._client.get_status( + self._body_of_water_uuid, WATER_QUALITY_STATE_FIELD + ) + ) + + +class PoolsideBodyEnergySensor(PoolsideGroupEntity, SensorEntity): + """One total from a body of water's last-minute energy consumption. + + The document is absent on installs whose controller doesn't report it, + and individual totals are absent when the corresponding fuel isn't in + use; both render as no data rather than an error. + """ + + entity_description: PoolsideBodyEnergySensorDescription + + def __init__( + self, + client: PoolsideClient, + group: PoolsideGroup, + body_of_water_uuid: str, + description: PoolsideBodyEnergySensorDescription, + ) -> None: + """Set up one energy reading for a given body of water.""" + super().__init__(client, group) + self.entity_description = description + self._body_of_water_uuid = body_of_water_uuid + self._attr_unique_id = ( + f"{client.controller_uuid}_{body_of_water_uuid}_{description.key}" + ) + + @override + def _status_keys(self) -> set[str]: + """Return the body-of-water key its energy arrives under.""" + return {self._body_of_water_uuid} + + @property + @override + def native_value(self) -> float | None: + """Return the last reported total, or no data if it isn't reported.""" + document = _energy_document(self._client, self._body_of_water_uuid) + if document is None: + return None + return _float_value(document.get(self.entity_description.document_key)) + + @property + @override + def extra_state_attributes(self) -> dict[str, Any] | None: + """Return the riding-along total, if this reading carries one.""" + if (key := self.entity_description.extra_attribute_key) is None: + return None + document = _energy_document(self._client, self._body_of_water_uuid) + if document is None or (value := document.get(key)) is None: + return None + return {"liquid_propane_btu": value} + + +class PoolsideControlOnUntilSensor(PoolsideEntity, SensorEntity): + """When a heater control's timed session ends (the "egg timer"). + + OnUntil is the ISO end time of the timed session the control is + running, null while none is. Added only for controls that actually + report the item, so heaters without a timer never grow the entity. + """ + + _attr_device_class = SensorDeviceClass.TIMESTAMP + _attr_translation_key = "on_until" + _use_translated_name = True + + def __init__(self, client: PoolsideClient, control: PoolsideControl) -> None: + """Set up the egg timer sensor for a heater control.""" + super().__init__(client, control) + self._attr_unique_id = f"{client.controller_uuid}_{control.uuid}_on_until" + self._attr_translation_placeholders = {"control_name": control.name} + + @property + @override + def native_value(self) -> datetime | None: + """Return the timed session's end time, or no data while none runs.""" + return _datetime_value(self._confirmed(ON_UNTIL_FIELD)) + + 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 79f0864..7cb02ef 100644 --- a/custom_components/poolside/strings.json +++ b/custom_components/poolside/strings.json @@ -66,6 +66,9 @@ "on": "[%key:common::state::on%]" } }, + "acid_dose": { + "name": "Last acid dose" + }, "actuator_state": { "name": "State", "state": { @@ -79,6 +82,9 @@ "waiting_to_move": "Waiting" } }, + "chlorine_dose": { + "name": "Last chlorine dose" + }, "current_state": { "name": "Water state", "state": { @@ -113,9 +119,21 @@ "free_chlorine": { "name": "Free chlorine" }, + "gas_consumption": { + "name": "Gas consumption" + }, + "on_until": { + "name": "{control_name} on until" + }, "orp": { "name": "ORP" }, + "power_consumption": { + "name": "Power consumption" + }, + "salt_chlorinator_dose": { + "name": "Last salt chlorinator dose" + }, "salt_level": { "name": "Salt level" }, @@ -131,6 +149,16 @@ "total_chlorine": { "name": "Total chlorine" }, + "water_quality_state": { + "name": "Water quality", + "state": { + "alert": "Alert", + "good": "Good", + "low": "Low", + "none": "None", + "ok": "OK" + } + }, "winterized": { "name": "Winterized", "state": { diff --git a/custom_components/poolside/translations/en.json b/custom_components/poolside/translations/en.json index 8041da9..c4c1200 100644 --- a/custom_components/poolside/translations/en.json +++ b/custom_components/poolside/translations/en.json @@ -66,6 +66,9 @@ "on": "On" } }, + "acid_dose": { + "name": "Last acid dose" + }, "actuator_state": { "name": "State", "state": { @@ -79,6 +82,9 @@ "waiting_to_move": "Waiting" } }, + "chlorine_dose": { + "name": "Last chlorine dose" + }, "current_state": { "name": "Water state", "state": { @@ -113,9 +119,21 @@ "free_chlorine": { "name": "Free chlorine" }, + "gas_consumption": { + "name": "Gas consumption" + }, + "on_until": { + "name": "{control_name} on until" + }, "orp": { "name": "ORP" }, + "power_consumption": { + "name": "Power consumption" + }, + "salt_chlorinator_dose": { + "name": "Last salt chlorinator dose" + }, "salt_level": { "name": "Salt level" }, @@ -131,6 +149,16 @@ "total_chlorine": { "name": "Total chlorine" }, + "water_quality_state": { + "name": "Water quality", + "state": { + "alert": "Alert", + "good": "Good", + "low": "Low", + "none": "None", + "ok": "OK" + } + }, "winterized": { "name": "Winterized", "state": { diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..991566a --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +testpaths = ["tests"] +pythonpath = ["."] \ No newline at end of file diff --git a/tests/test_sensor_parsing.py b/tests/test_sensor_parsing.py new file mode 100644 index 0000000..61d4f2d --- /dev/null +++ b/tests/test_sensor_parsing.py @@ -0,0 +1,151 @@ +"""Tests for the JSON-string parsing helpers behind the new sensors. + +Pure-function tests only: the helpers take raw Device.setStatus values +(documents arrive JSON-encoded inside the string value) and must tolerate +every malformed shape installs can produce without raising. +""" + +import json + +from custom_components.poolside.const import ( + ACID_MIX_EVENT_FIELD, + ENERGY_NATURAL_GAS_KEY, + ENERGY_WATTS_KEY, + LAST_MINUTE_OF_ENERGY_CONSUMPTION_FIELD, + ON_UNTIL_FIELD, +) +from custom_components.poolside.sensor import ( + _datetime_value, + _dose_amount, + _dose_extra_attributes, + _energy_document, + _energy_total_reported, + _json_document, + _water_quality_value, +) + +# A real dose event captured from a controller's Device.setStatus push, as +# it arrives on the wire: the document JSON-encoded inside the string value. +REAL_DOSE_EVENT = { + "UUID": "9d5365d1-d267-49f2-afa4-a44a09bd7943", + "StartDateTime": "2026-08-31T13:58:16.8837444-05:00", + "EndDateTime": "2026-08-31T14:24:24.8837657-05:00", + "DoseSeconds": 68, + "DoseAmount": 5.0, + "DosingUnit": "OZ", + "MixMinutes": 25, + "ChemicalType": "CHLORINE", + "ChemicalPumpUUID": "Universal-Chemical-Pump", + "InterruptedAt": None, + "Before": 597.0, + "After": None, + "IgnoreBadEvent": False, + "PumpMinimumPowerLevelWhileMixing": 25, + "PumpMinimumPowerLevelWhileDosing": 100, + "ManuallyInitiatedEvent": False, + "DosingCutShort": False, +} + + +class StubClient: + """Minimal get_status double: one key -> one raw value.""" + + def __init__(self, status: dict[str, dict]) -> None: + """Store the raw status by (uuid, field).""" + self._status = status + + def get_status(self, uuid: str, field: str): + """Return the raw value pushed for (uuid, field).""" + return self._status.get(uuid, {}).get(field) + + +def _encoded(value) -> str: + """Encode a document the way the wire does: JSON inside the string.""" + return json.dumps(value) + + +BODY = "fb773a00-3b89-41d6-ac73-37d4b87a6203" + + +def test_dose_event_json_string_parses(): + """A double-encoded dose event yields its amount and attributes.""" + client = StubClient({BODY: {ACID_MIX_EVENT_FIELD: _encoded(REAL_DOSE_EVENT)}}) + document = _json_document( + BODY, ACID_MIX_EVENT_FIELD, client.get_status(BODY, ACID_MIX_EVENT_FIELD) + ) + assert _dose_amount(document) == 5.0 + attributes = _dose_extra_attributes(document) + assert attributes["duration_seconds"] == 68 + assert attributes["started"] == REAL_DOSE_EVENT["StartDateTime"] + assert attributes["ended"] == REAL_DOSE_EVENT["EndDateTime"] + assert attributes["mix_minutes"] == 25 + assert attributes["probe_before"] == 597.0 + assert attributes["unit"] == "OZ" + # InterruptedAt is null in the payload and is omitted, not forced in. + assert "interrupted" not in attributes + + +def test_dose_event_null_and_malformed(): + """Null (no dose information) and junk render as no data, not an error.""" + assert _json_document(BODY, ACID_MIX_EVENT_FIELD, None) is None + assert _dose_amount(None) is None + assert _dose_extra_attributes(None) == {} + assert _json_document(BODY, ACID_MIX_EVENT_FIELD, "not json{") is None + assert _json_document(BODY, ACID_MIX_EVENT_FIELD, '"just a string"') is None + # Empty string is a common null-ish push and must not parse as a document. + assert _json_document(BODY, ACID_MIX_EVENT_FIELD, "") is None + + +def test_energy_document_json_string_parses(): + """A double-encoded energy document yields both totals.""" + energy = { + ENERGY_WATTS_KEY: 1250.0, + ENERGY_NATURAL_GAS_KEY: 42000.0, + "TotalLiquidPropaneBtuUsed": 0.0, + } + client = StubClient( + {BODY: {LAST_MINUTE_OF_ENERGY_CONSUMPTION_FIELD: _encoded(energy)}} + ) + document = _energy_document(client, BODY) + assert document is not None + assert document[ENERGY_WATTS_KEY] == 1250.0 + assert document[ENERGY_NATURAL_GAS_KEY] == 42000.0 + assert document["TotalLiquidPropaneBtuUsed"] == 0.0 + + +def test_energy_document_null_safe(): + """Absent and malformed energy items render as no data.""" + assert _energy_document(StubClient({}), BODY) is None + assert _energy_document(StubClient({BODY: {}}), BODY) is None + assert _energy_document( + StubClient({BODY: {LAST_MINUTE_OF_ENERGY_CONSUMPTION_FIELD: None}}), BODY + ) is None + assert _energy_document( + StubClient( + {BODY: {LAST_MINUTE_OF_ENERGY_CONSUMPTION_FIELD: "junk"}} + ), + BODY, + ) is None + + +def test_on_until_parses_datetime(): + """OnUntil parses an ISO datetime, tolerating double-JSON quoting.""" + parsed = _datetime_value("2026-10-21T19:00:00-05:00") + assert parsed is not None + assert parsed.tzinfo is not None + # The wire shape: the datetime JSON-encoded inside the string value. + parsed = _datetime_value('"2026-10-21T19:00:00-05:00"') + assert parsed is not None + assert parsed.isoformat().startswith("2026-10-21T19:00:00") + assert _datetime_value(None) is None + + +def test_water_quality_value_tolerant(): + """Known values map to their options; anything else is no data.""" + assert _water_quality_value("GOOD") == "good" + assert _water_quality_value("ok") == "ok" + assert _water_quality_value(" LOW ") == "low" + assert _water_quality_value("ALERT") == "alert" + assert _water_quality_value("NONE") == "none" + assert _water_quality_value(None) is None + assert _water_quality_value("SOMETHING_NEW") is None \ No newline at end of file From 65919048c2de1d6466862a8b641e64118ebda3ca Mon Sep 17 00:00:00 2001 From: Kevin M Date: Wed, 21 Oct 2026 19:56:32 -0500 Subject: [PATCH 02/13] test: synthetic UUIDs in fixtures (no private site identifiers); fix pytest.ini TOML-style lists --- pytest.ini | 4 ++-- tests/test_sensor_parsing.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pytest.ini b/pytest.ini index 991566a..0ba98a2 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,3 +1,3 @@ [pytest] -testpaths = ["tests"] -pythonpath = ["."] \ No newline at end of file +testpaths = tests +pythonpath = . \ No newline at end of file diff --git a/tests/test_sensor_parsing.py b/tests/test_sensor_parsing.py index 61d4f2d..4eb9d76 100644 --- a/tests/test_sensor_parsing.py +++ b/tests/test_sensor_parsing.py @@ -27,7 +27,7 @@ # A real dose event captured from a controller's Device.setStatus push, as # it arrives on the wire: the document JSON-encoded inside the string value. REAL_DOSE_EVENT = { - "UUID": "9d5365d1-d267-49f2-afa4-a44a09bd7943", + "UUID": "aaaaaaaa-bb1c-4d22-9333-eeeeeeee4444", # synthetic UUID for tests "StartDateTime": "2026-08-31T13:58:16.8837444-05:00", "EndDateTime": "2026-08-31T14:24:24.8837657-05:00", "DoseSeconds": 68, @@ -64,7 +64,7 @@ def _encoded(value) -> str: return json.dumps(value) -BODY = "fb773a00-3b89-41d6-ac73-37d4b87a6203" +BODY = "00000000-1111-4222-8333-444444444555" # synthetic UUID for tests def test_dose_event_json_string_parses(): From 38ef2d76af4d9fa966eca9762375e8f58912bf37 Mon Sep 17 00:00:00 2001 From: Kevin M Date: Wed, 21 Oct 2026 22:40:34 -0500 Subject: [PATCH 03/13] feat: eager entity creation, dosed-since-reset counters + reset buttons (disabled by default) - Dose sensors (+ salt-gate kept), energy sensors, and heater egg-timer sensors are created at setup instead of waiting for their first push, so installs see them immediately as no data. - New per-chemical 'dosed since last reset' running-total sensors (disabled by default, restored across restarts, UUID-deduped). - New matching reset buttons (disabled by default) on a new button platform; pressing zeroes the paired counter for refill tracking. --- custom_components/poolside/__init__.py | 7 +- custom_components/poolside/button.py | 69 ++++ custom_components/poolside/icons.json | 14 + custom_components/poolside/sensor.py | 206 ++++++++-- custom_components/poolside/strings.json | 16 +- .../poolside/translations/en.json | 362 +++++++++--------- tests/test_sensor_parsing.py | 39 ++ 7 files changed, 495 insertions(+), 218 deletions(-) create mode 100644 custom_components/poolside/button.py diff --git a/custom_components/poolside/__init__.py b/custom_components/poolside/__init__.py index 4d45aee..6085e33 100644 --- a/custom_components/poolside/__init__.py +++ b/custom_components/poolside/__init__.py @@ -2,7 +2,7 @@ from base64 import b64decode from collections.abc import Callable -from dataclasses import dataclass +from dataclasses import dataclass, field from aiopoolside import ( PoolsideAuthError, @@ -38,6 +38,7 @@ from .entity import control_platform PLATFORMS = [ + Platform.BUTTON, Platform.CLIMATE, Platform.FAN, Platform.LIGHT, @@ -55,6 +56,10 @@ class PoolsideData: site: PoolsideSite controls: list[PoolsideControl] pool_devices: list[PoolsideDevice] + # Per-body dosing sensors, by `_`: the button + # platform's reset buttons pair to the sensor platform's dose sensors + # through this (sensors are created first in the PLATFORMS order). + dose_handlers: dict = field(default_factory=dict) type PoolsideConfigEntry = ConfigEntry[PoolsideData] diff --git a/custom_components/poolside/button.py b/custom_components/poolside/button.py new file mode 100644 index 0000000..a16d9e9 --- /dev/null +++ b/custom_components/poolside/button.py @@ -0,0 +1,69 @@ +"""Reset buttons for the dose counters. + +Each button resets one chemical's "dosed since last reset" counter on the +matching last-dose sensor. Buttons are created disabled by default: an +install only turns one on when it wants count-up tracking for a tank. +""" + +from __future__ import annotations + +from homeassistant.components.button import ButtonEntity +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from typing import override + +from . import PoolsideConfigEntry +from .entity import PoolsideGroupEntity + +DOSE_KEY_TO_CHEMICAL = { + "acid_dose": "acid", + "chlorine_dose": "chlorine", + "salt_chlorinator_dose": "salt chlorinator", +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: PoolsideConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Create one reset button per chemical with a dose counter pair.""" + data = entry.runtime_data + async_add_entities( + PoolsideDoseResetButton(data.client, handler) + for handler in data.dose_handlers.values() + ) + + +class PoolsideDoseResetButton(PoolsideGroupEntity, ButtonEntity): + """Resets one chemical's dosed-since-reset counter to zero.""" + + _attr_translation_key = "dose_reset" + _attr_entity_registry_enabled_default = False + + def __init__(self, client, dose_sensor) -> None: + """Set up the reset button for one chemical of a body of water.""" + super().__init__(client, dose_sensor._group) + self._dose_sensor = dose_sensor + chemical = DOSE_KEY_TO_CHEMICAL.get( + dose_sensor.entity_description.key, + dose_sensor.entity_description.key.replace("_dose", "").replace( + "_", " " + ), + ) + self._attr_translation_placeholders = {"chemical": chemical} + self._attr_unique_id = ( + f"{client.controller_uuid}_{dose_sensor._body_of_water_uuid}" + f"_{dose_sensor.entity_description.key}_dose_reset" + ) + + @override + def _status_keys(self) -> set[str]: + """Refresh alongside the dose sensor it resets.""" + return {self._dose_sensor._body_of_water_uuid} + + @callback + @override + async def async_press(self) -> None: + """Reset the paired dose counter.""" + self._dose_sensor.reset() \ No newline at end of file diff --git a/custom_components/poolside/icons.json b/custom_components/poolside/icons.json index 5dc66f5..3bb38f7 100644 --- a/custom_components/poolside/icons.json +++ b/custom_components/poolside/icons.json @@ -101,6 +101,15 @@ }, "winterized": { "default": "mdi:snowflake" + }, + "acid_dose_total": { + "default": "mdi:counter" + }, + "chlorine_dose_total": { + "default": "mdi:counter" + }, + "salt_chlorinator_dose_total": { + "default": "mdi:counter" } }, "switch": { @@ -110,6 +119,11 @@ "water_feature": { "default": "mdi:fountain" } + }, + "button": { + "dose_reset": { + "default": "mdi:restart" + } } } } diff --git a/custom_components/poolside/sensor.py b/custom_components/poolside/sensor.py index ebb0ce0..00b3654 100644 --- a/custom_components/poolside/sensor.py +++ b/custom_components/poolside/sensor.py @@ -74,6 +74,7 @@ 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.helpers.restore_state import RestoreEntity from homeassistant.util import dt as dt_util from . import PoolsideConfigEntry @@ -108,6 +109,7 @@ PoolsideGroupEntity, confirmed_status, ) +from .button import PoolsideDoseResetButton @dataclass(frozen=True, kw_only=True) @@ -264,7 +266,7 @@ def _float_value(value: Any) -> float | None: """Coerce a telemetry value to a number, or None if it isn't one.""" try: return float(value) - except TypeError, ValueError: + except (TypeError, ValueError): return None @@ -855,21 +857,38 @@ def _async_add_reported_body_sensors() -> None: new_entities.append( PoolsideBodySensor(client, group, body_of_water_uuid, description) ) - # Only bodies with a chemical dosing system report dose events; - # the salt chlorinator's item only exists on salt sites. + # Dose events are added eagerly (visible immediately as no data + # until a dose runs); the salt chlorinator's item only exists on + # salt sites, so that one waits for its first push. for description in DOSE_EVENT_SENSORS: added_key = f"{body_of_water_uuid}_{description.key}" if ( added_key in added - or client.get_status(body_of_water_uuid, description.field) is None + or ( + description.key == "salt_chlorinator_dose" + and client.get_status( + body_of_water_uuid, description.field + ) + is None + ) ): continue added.add(added_key) + dose_sensor = PoolsideBodyDoseSensor( + client, group, body_of_water_uuid, description + ) + new_entities.append(dose_sensor) + added.add(f"{added_key}_total") new_entities.append( - PoolsideBodyDoseSensor( - client, group, body_of_water_uuid, description + PoolsideDoseTotalSensor( + client, group, body_of_water_uuid, dose_sensor ) ) + # Registered for the button platform's reset buttons, which + # set up after sensors (PLATFORMS order). + data.dose_handlers[f"{body_of_water_uuid}_{description.key}"] = ( + dose_sensor + ) water_quality_key = f"{body_of_water_uuid}_water_quality" if ( water_quality_key not in added @@ -884,16 +903,13 @@ def _async_add_reported_body_sensors() -> None: client, group, body_of_water_uuid ) ) - # Only bodies on controller firmware reporting the last-minute - # energy document get energy sensors, and each one waits for - # its own total (a body heated electrically never grows a gas - # sensor). + # Energy sensors are added eagerly too, so installs see them + # right away; totals the body's equipment doesn't report stay + # as no data rather than never materializing (this body's + # heater is gas? its electric total still shows). for description in ENERGY_SENSORS: added_key = f"{body_of_water_uuid}_{description.key}" - if ( - added_key in added - or not _energy_total_reported(client, body_of_water_uuid, description) - ): + if added_key in added: continue added.add(added_key) new_entities.append( @@ -912,38 +928,17 @@ def _async_add_reported_body_sensors() -> None: ) ) - # Heater controls with a timed session ("egg timer") report OnUntil; - # like the heating/cooling mode selects, the sensor is added the first - # time the control reports the item, and heaters that never do (no - # timer in use) never grow the entity. + # Heater "egg timer" sensors are added eagerly for every temperature + # control; while no timed session runs the value is no data. A heater + # that can never report the item simply stays at no data. heaters = [ control for control in data.controls if control.control_type is ControlType.TEMPERATURE ] - added_on_until: set[str] = set() - - @callback - def _async_add_reported_on_until_sensors() -> None: - new_entities: list[SensorEntity] = [] - for control in heaters: - added_key = f"{control.uuid}_on_until" - if ( - added_key in added_on_until - or confirmed_status(client, control, ON_UNTIL_FIELD) is None - ): - continue - added_on_until.add(added_key) - new_entities.append(PoolsideControlOnUntilSensor(client, control)) - if new_entities: - async_add_entities(new_entities) - - _async_add_reported_on_until_sensors() - for control in heaters: - for key in {control.status_key, control.uuid, *control.member_uuids}: - entry.async_on_unload( - client.subscribe_status(key, _async_add_reported_on_until_sensors) - ) + async_add_entities( + PoolsideControlOnUntilSensor(client, control) for control in heaters + ) # Pre-seeded so a descriptor that lists ActualPowerState or Winterized # itself doesn't collide with the dedicated sensors created above. @@ -1136,6 +1131,11 @@ class PoolsideBodyDoseSensor(PoolsideGroupEntity, SensorEntity): dose's start. A null therefore means "no new dose information", not "the last dose never happened", so the last known document is kept and the sensor keeps showing the last dose until the next one arrives. + + The sensor also feeds its chemical's `PoolsideDoseTotalSensor`: each + newly completed dose (a document with a new UUID, once it carries an + EndDateTime) adds its amount to that running total, which a paired + reset button zeroes. """ entity_description: PoolsideBodySensorDescription @@ -1150,11 +1150,14 @@ def __init__( """Set up the last-dose sensor for one chemical of a body of water.""" super().__init__(client, group) self.entity_description = description + self._group = group self._body_of_water_uuid = body_of_water_uuid self._attr_unique_id = ( f"{client.controller_uuid}_{body_of_water_uuid}_{description.key}" ) self._last_document: dict[str, Any] | None = None + self._seen_dose_uuids: set[str] | None = None + self._total_sensor: PoolsideDoseTotalSensor | None = None @override def _status_keys(self) -> set[str]: @@ -1179,9 +1182,128 @@ def _last_known_document(self) -> dict[str, Any] | None: self._client, self._body_of_water_uuid, self.entity_description.field ) if document is not None: - self._last_document = document + self._consider_document(document) return self._last_document + def _consider_document(self, document: dict[str, Any]) -> None: + """Record a dose document, feeding completed ones to the total.""" + self._last_document = document + if self._total_sensor is None: + return + uuid = document.get("UUID") + if not uuid or document.get("EndDateTime") is None: + return + if self._seen_dose_uuids is None: + # The total sensor's restored state lists the dose UUIDs the + # previous session already counted; trusting it fully (no + # re-checking the current document) prevents a re-count when + # the controller replays the last dose after a restart. + self._seen_dose_uuids = set(self._total_sensor.restored_uuids) + if uuid in self._seen_dose_uuids: + return + self._seen_dose_uuids.add(uuid) + if (amount := _dose_amount(document)) is not None: + self._total_sensor.add(amount) + + def reset(self) -> None: + """Zero the running total and forget counted doses.""" + self._seen_dose_uuids = set() + if self._total_sensor is not None: + self._total_sensor.set_value(0.0) + + +class PoolsideDoseTotalSensor( + PoolsideGroupEntity, RestoreEntity, SensorEntity +): + """A running total of one chemical's dosed amounts since the last reset. + + The last-dose sensor reports each dose once (recognized by the dose + document's UUID), and this sensor accumulates those amounts. The + counter persists across HA restarts, so a tank's count-up continues + until its reset button is pressed; an automation can trigger at a + threshold to warn that a tank is nearly empty. Disabled by default: + installs that prefer count-down from the vendor's tank tracking never + see it. + """ + + _attr_entity_registry_enabled_default = False + + def __init__( + self, + client: PoolsideClient, + group: PoolsideGroup, + body_of_water_uuid: str, + dose_sensor: PoolsideBodyDoseSensor, + ) -> None: + """Set up the dose-total sensor for one chemical of a 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}" + f"_{dose_sensor.entity_description.key}_total" + ) + self._attr_translation_key = f"{dose_sensor.entity_description.key}_total" + self._attr_native_unit_of_measurement = ( + dose_sensor.entity_description.native_unit_of_measurement + ) + self._dose_sensor = dose_sensor + self._total: float = 0.0 + self._restored_uuids: set[str] = set() + + @override + def _status_keys(self) -> set[str]: + """Follow the last-dose sensor's pushes.""" + return {self._body_of_water_uuid} + + @property + def restored_uuids(self) -> set[str]: + """Dose UUIDs that the previous session had already counted.""" + return self._restored_uuids + + @property + @override + def native_value(self) -> float | None: + """Return the dosed-since-reset total, in the chemical's unit.""" + return self._total + + def add(self, amount: float) -> None: + """Add one dose's amount to the total.""" + self._total = round(self._total + amount, 3) + self.async_write_ha_state() + + def set_value(self, value: float) -> None: + """Set the total directly (the reset path).""" + self._total = value + self.async_write_ha_state() + + @property + @override + def extra_state_attributes(self) -> dict[str, Any]: + """Return bookkeeping details for debugging the counter.""" + return { + "counted_dose_uuids": sorted(self._restored_uuids) + if not self._dose_sensor._seen_dose_uuids + else sorted(self._dose_sensor._seen_dose_uuids), + } + + @override + async def async_added_to_hass(self) -> None: + """Restore the pre-restart total, then wire the feeding sensor.""" + await super().async_added_to_hass() + if (state := await self.async_get_last_state()) is not None: + try: + self._total = float(state.state) + except (TypeError, ValueError): + self._total = 0.0 + if (extra := state.attributes.get("counted_dose_uuids")) is not None: + self._restored_uuids = set(extra) + self._dose_sensor._total_sensor = self + # Replay what's already known so totals survive restarts that + # happened between the last dose and the next push. + self._dose_sensor._consider_document( + self._dose_sensor._last_document + ) if self._dose_sensor._last_document else None + class PoolsideBodyWaterQualitySensor(PoolsideGroupEntity, SensorEntity): """A body of water's overall chemistry health (its WaterQualityState). diff --git a/custom_components/poolside/strings.json b/custom_components/poolside/strings.json index 7cb02ef..d742609 100644 --- a/custom_components/poolside/strings.json +++ b/custom_components/poolside/strings.json @@ -16,7 +16,7 @@ }, "step": { "pair": { - "description": "On your Poolside controller or in the Poolside app, approve the pairing request from “{fingerprint}”. Confirm the code shown there matches: {fingerprint}", + "description": "On your Poolside controller or in the Poolside app, approve the pairing request from \u201c{fingerprint}\u201d. Confirm the code shown there matches: {fingerprint}", "title": "Approve this connection" }, "reauth_confirm": { @@ -69,6 +69,15 @@ "acid_dose": { "name": "Last acid dose" }, + "acid_dose_total": { + "name": "Acid dosed since last reset" + }, + "chlorine_dose_total": { + "name": "Chlorine dosed since last reset" + }, + "salt_chlorinator_dose_total": { + "name": "Salt chlorinator dosed since last reset" + }, "actuator_state": { "name": "State", "state": { @@ -166,6 +175,11 @@ "true": "True" } } + }, + "button": { + "dose_reset": { + "name": "Reset {chemical} dose counter" + } } }, "options": { diff --git a/custom_components/poolside/translations/en.json b/custom_components/poolside/translations/en.json index c4c1200..36d021a 100644 --- a/custom_components/poolside/translations/en.json +++ b/custom_components/poolside/translations/en.json @@ -1,183 +1,197 @@ { - "config": { - "abort": { - "already_configured": "Device is already configured", - "pair_busy": "Another pairing request is already pending on the controller. Please try again in a few minutes.", - "pair_failed": "The controller rejected the pairing request.", - "pair_rejected": "The pairing request was rejected on the controller.", - "pair_timeout": "Nobody approved the pairing request in time. Please try again.", - "reauth_successful": "Re-authentication was successful" - }, - "error": { - "cannot_connect": "Failed to connect" + "config": { + "abort": { + "already_configured": "Device is already configured", + "pair_busy": "Another pairing request is already pending on the controller. Please try again in a few minutes.", + "pair_failed": "The controller rejected the pairing request.", + "pair_rejected": "The pairing request was rejected on the controller.", + "pair_timeout": "Nobody approved the pairing request in time. Please try again.", + "reauth_successful": "Re-authentication was successful" + }, + "error": { + "cannot_connect": "Failed to connect" + }, + "progress": { + "pair": "Waiting for approval on the Poolside controller..." + }, + "step": { + "pair": { + "description": "On your Poolside controller or in the Poolside app, approve the pairing request from \u201c{fingerprint}\u201d. Confirm the code shown there matches: {fingerprint}", + "title": "Approve this connection" + }, + "reauth_confirm": { + "description": "Home Assistant needs to be re-approved on your Poolside controller. Continue to start pairing again." + }, + "user": { + "data": { + "host": "Host", + "port": "Port" }, - "progress": { - "pair": "Waiting for approval on the Poolside controller..." + "data_description": { + "host": "The hostname or IP address of your Poolside controller.", + "port": "The port the controller's Home Assistant websocket server listens on." }, - "step": { - "pair": { - "description": "On your Poolside controller or in the Poolside app, approve the pairing request from \u201c{fingerprint}\u201d. Confirm the code shown there matches: {fingerprint}", - "title": "Approve this connection" - }, - "reauth_confirm": { - "description": "Home Assistant needs to be re-approved on your Poolside controller. Continue to start pairing again." - }, - "user": { - "data": { - "host": "Host", - "port": "Port" - }, - "data_description": { - "host": "The hostname or IP address of your Poolside controller.", - "port": "The port the controller's Home Assistant websocket server listens on." - }, - "description": "Connect to your Poolside controller." - }, - "zeroconf_confirm": { - "description": "Do you want to set up {name} ({host})?" - } + "description": "Connect to your Poolside controller." + }, + "zeroconf_confirm": { + "description": "Do you want to set up {name} ({host})?" + } + } + }, + "entity": { + "select": { + "cooling_mode": { + "name": "{control_name} cooling mode", + "state": { + "chiller": "Chiller", + "heatpump": "Heat pump", + "smart": "Smart" + } + }, + "heating_mode": { + "name": "{control_name} heating mode", + "state": { + "fuel": "Fuel", + "heatpump": "Heat pump", + "smart": "Smart", + "solar": "Solar" } + } }, - "entity": { - "select": { - "cooling_mode": { - "name": "{control_name} cooling mode", - "state": { - "chiller": "Chiller", - "heatpump": "Heat pump", - "smart": "Smart" - } - }, - "heating_mode": { - "name": "{control_name} heating mode", - "state": { - "fuel": "Fuel", - "heatpump": "Heat pump", - "smart": "Smart", - "solar": "Solar" - } - } - }, - "sensor": { - "actual_power_state": { - "name": "Power state", - "state": { - "off": "Off", - "on": "On" - } - }, - "acid_dose": { - "name": "Last acid dose" - }, - "actuator_state": { - "name": "State", - "state": { - "attempting_to_move": "Moving", - "calibrating": "Calibrating", - "error": "Error", - "failed": "Failed", - "idle": "Idle", - "offline": "Offline", - "overload": "Overload", - "waiting_to_move": "Waiting" - } - }, - "chlorine_dose": { - "name": "Last chlorine dose" - }, - "current_state": { - "name": "Water state", - "state": { - "cooldown": "Cooldown", - "cooling": "Cooling", - "critical_alert": "Critical alert", - "filtering": "Filtering", - "heating": "Heating", - "installer_mode": "Installer mode", - "off": "Off", - "on": "Running" - } - }, - "disabled_reason": { - "name": "{control_name} disabled reason", - "state": { - "freeze_protect": "Freeze protection", - "none": "None", - "pool_cover": "Pool cover closed", - "winterized": "Winterized" - } - }, - "dissolved_oxygen_concentration": { - "name": "Dissolved oxygen concentration" - }, - "dissolved_oxygen_saturation": { - "name": "Dissolved oxygen saturation" - }, - "estimated_ready_time": { - "name": "Estimated ready time" - }, - "free_chlorine": { - "name": "Free chlorine" - }, - "gas_consumption": { - "name": "Gas consumption" - }, - "on_until": { - "name": "{control_name} on until" - }, - "orp": { - "name": "ORP" - }, - "power_consumption": { - "name": "Power consumption" - }, - "salt_chlorinator_dose": { - "name": "Last salt chlorinator dose" - }, - "salt_level": { - "name": "Salt level" - }, - "site_mode": { - "name": "Controller mode", - "state": { - "factory": "Factory", - "fault": "Fault", - "installer": "Installer", - "normal": "Normal" - } - }, - "total_chlorine": { - "name": "Total chlorine" - }, - "water_quality_state": { - "name": "Water quality", - "state": { - "alert": "Alert", - "good": "Good", - "low": "Low", - "none": "None", - "ok": "OK" - } - }, - "winterized": { - "name": "Winterized", - "state": { - "false": "False", - "true": "True" - } - } + "sensor": { + "actual_power_state": { + "name": "Power state", + "state": { + "off": "Off", + "on": "On" + } + }, + "acid_dose": { + "name": "Last acid dose" + }, + "actuator_state": { + "name": "State", + "state": { + "attempting_to_move": "Moving", + "calibrating": "Calibrating", + "error": "Error", + "failed": "Failed", + "idle": "Idle", + "offline": "Offline", + "overload": "Overload", + "waiting_to_move": "Waiting" } + }, + "chlorine_dose": { + "name": "Last chlorine dose" + }, + "current_state": { + "name": "Water state", + "state": { + "cooldown": "Cooldown", + "cooling": "Cooling", + "critical_alert": "Critical alert", + "filtering": "Filtering", + "heating": "Heating", + "installer_mode": "Installer mode", + "off": "Off", + "on": "Running" + } + }, + "disabled_reason": { + "name": "{control_name} disabled reason", + "state": { + "freeze_protect": "Freeze protection", + "none": "None", + "pool_cover": "Pool cover closed", + "winterized": "Winterized" + } + }, + "dissolved_oxygen_concentration": { + "name": "Dissolved oxygen concentration" + }, + "dissolved_oxygen_saturation": { + "name": "Dissolved oxygen saturation" + }, + "estimated_ready_time": { + "name": "Estimated ready time" + }, + "free_chlorine": { + "name": "Free chlorine" + }, + "gas_consumption": { + "name": "Gas consumption" + }, + "on_until": { + "name": "{control_name} on until" + }, + "orp": { + "name": "ORP" + }, + "power_consumption": { + "name": "Power consumption" + }, + "salt_chlorinator_dose": { + "name": "Last salt chlorinator dose" + }, + "salt_level": { + "name": "Salt level" + }, + "site_mode": { + "name": "Controller mode", + "state": { + "factory": "Factory", + "fault": "Fault", + "installer": "Installer", + "normal": "Normal" + } + }, + "total_chlorine": { + "name": "Total chlorine" + }, + "water_quality_state": { + "name": "Water quality", + "state": { + "alert": "Alert", + "good": "Good", + "low": "Low", + "none": "None", + "ok": "OK" + } + }, + "winterized": { + "name": "Winterized", + "state": { + "false": "False", + "true": "True" + } + }, + "acid_dose_total": { + "name": "Acid dosed since last reset" + }, + "chlorine_dose_total": { + "name": "Chlorine dosed since last reset" + }, + "salt_chlorinator_dose_total": { + "name": "Salt chlorinator dosed since last reset" + } }, - "options": { - "step": { - "init": { - "data": { - "expose_pool_devices": "Expose pool devices" - }, - "data_description": { - "expose_pool_devices": "Create read-only telemetry devices for the physical pool equipment (pumps, heaters, lights, actuators) the controller operates." - } - } + "button": { + "dose_reset": { + "name": "Reset {chemical} dose counter" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "expose_pool_devices": "Expose pool devices" + }, + "data_description": { + "expose_pool_devices": "Create read-only telemetry devices for the physical pool equipment (pumps, heaters, lights, actuators) the controller operates." } + } } -} \ No newline at end of file + } +} diff --git a/tests/test_sensor_parsing.py b/tests/test_sensor_parsing.py index 4eb9d76..372b242 100644 --- a/tests/test_sensor_parsing.py +++ b/tests/test_sensor_parsing.py @@ -24,6 +24,45 @@ _water_quality_value, ) + +def _dose_feeder(): + """A minimal dose-sensor double: considers documents it's shown.""" + seen: set[str] = set() + added: list[float] = [] + + class Feed: + restored_uuids: set[str] = set() + + def consider(self, doc: dict) -> None: + uuid = doc.get("UUID") + if not uuid or doc.get("EndDateTime") is None or uuid in seen: + return + seen.add(uuid) + if (amount := _dose_amount(doc)) is not None: + added.append(amount) + + return Feed(), seen, added + + +def test_dose_counter_counts_each_dose_once(): + """Each completed dose (unique UUID) adds its amount exactly once.""" + feeder, seen, added = _dose_feeder() + feeder.consider(REAL_DOSE_EVENT) + feeder.consider(REAL_DOSE_EVENT) # same UUID re-push: not double-counted + assert added == [5.0] + next_dose = dict(REAL_DOSE_EVENT, UUID="bbbbbbbb-cd2e-4e33-8444-555555555666", + DoseAmount=1.0, EndDateTime="2026-08-31T14:30:00-05:00") + feeder.consider(next_dose) + assert added == [5.0, 1.0] + + +def test_dose_counter_skips_incomplete_and_missing_uuid(): + """A running dose (no EndDateTime yet) or a UUID-less doc counts nothing.""" + feeder, seen, added = _dose_feeder() + feeder.consider(dict(REAL_DOSE_EVENT, EndDateTime=None)) + feeder.consider({"DoseAmount": 9.9}) + assert added == [] + # A real dose event captured from a controller's Device.setStatus push, as # it arrives on the wire: the document JSON-encoded inside the string value. REAL_DOSE_EVENT = { From a6f5af6127feff4351e4052449ab20962302fc0a Mon Sep 17 00:00:00 2001 From: Kevin M Date: Wed, 21 Oct 2026 22:56:16 -0500 Subject: [PATCH 04/13] feat: attach dose/total/reset to chemical pump devices, gas + egg timer to heater device - Dose sensing trio attaches to the chemical pump device matched via the pump's ChemicalType model, falling back to the body group device when a site reports no matching pump. - Gas consumption attaches to the heater device; watts stays on the body group device (whole-body aggregate). - Heater egg-timer sensor attaches to the heater device when present. - Icons: totals get flask/tonic icons, reset gets restart-alert. --- custom_components/poolside/__init__.py | 2 +- custom_components/poolside/icons.json | 8 +- custom_components/poolside/sensor.py | 121 +++++++++++++++++++++---- 3 files changed, 109 insertions(+), 22 deletions(-) diff --git a/custom_components/poolside/__init__.py b/custom_components/poolside/__init__.py index 6085e33..e09015a 100644 --- a/custom_components/poolside/__init__.py +++ b/custom_components/poolside/__init__.py @@ -38,13 +38,13 @@ from .entity import control_platform PLATFORMS = [ - Platform.BUTTON, Platform.CLIMATE, Platform.FAN, Platform.LIGHT, Platform.SELECT, Platform.SENSOR, Platform.SWITCH, + Platform.BUTTON, ] diff --git a/custom_components/poolside/icons.json b/custom_components/poolside/icons.json index 3bb38f7..f3867e3 100644 --- a/custom_components/poolside/icons.json +++ b/custom_components/poolside/icons.json @@ -103,13 +103,13 @@ "default": "mdi:snowflake" }, "acid_dose_total": { - "default": "mdi:counter" + "default": "mdi:flask-plus" }, "chlorine_dose_total": { - "default": "mdi:counter" + "default": "mdi:bottle-tonic-plus" }, "salt_chlorinator_dose_total": { - "default": "mdi:counter" + "default": "mdi:flask-plus" } }, "switch": { @@ -122,7 +122,7 @@ }, "button": { "dose_reset": { - "default": "mdi:restart" + "default": "mdi:restart-alert" } } } diff --git a/custom_components/poolside/sensor.py b/custom_components/poolside/sensor.py index 00b3654..b86431c 100644 --- a/custom_components/poolside/sensor.py +++ b/custom_components/poolside/sensor.py @@ -222,6 +222,14 @@ class PoolsideBodySensorDescription(SensorEntityDescription): # crashing the state property. WATER_QUALITY_OPTIONS = ["good", "ok", "low", "alert", "none"] +# Dose sensor keys mapped to the ChemicalType string the matching pump +# device's model carries ("Acid" Acid pump, "LiquidChlorine" chlorine pump). +DOSE_KEY_TO_CHEMICAL_TYPE = { + "acid_dose": "ACID", + "chlorine_dose": "LIQUIDCHLORINE", + "salt_chlorinator_dose": "SALT", +} + @dataclass(frozen=True, kw_only=True) class PoolsideBodyEnergySensorDescription(SensorEntityDescription): @@ -461,8 +469,37 @@ def _energy_total_reported( return document is not None and document.get(description.document_key) is not None +def _chemical_pump_device( + pool_devices: list[PoolsideDevice], dose_key: str +) -> PoolsideDevice | None: + """Return the chemical pump device a dose key's entities attach to. + + Dose items are keyed to a chemical (acid, liquid chlorine, salt), and + its pump is identified by the `ChemicalType` its model carries - the + only per-chemical identifier every install has. Unknown pairings + return None and the caller falls back to the body group device. + """ + chemical = DOSE_KEY_TO_CHEMICAL_TYPE.get(dose_key) + if chemical is None: + return None + for device in pool_devices: + model = str(getattr(device, "model", "") or "") + if model and chemical in model.upper(): + return device + return None + + +def _heater_device( + pool_devices: list[PoolsideDevice], +) -> PoolsideDevice | None: + """Return the pool heater device, if the site has one.""" + for device in pool_devices: + if str(getattr(device, "model", "") or "").upper() == "HEATER": + return device + return None + + def _water_quality_value(value: Any) -> str | None: - """Map a WaterQualityState raw value to its option, or None if unrecognized.""" if value is None: return None text = str(value).strip().lower() @@ -859,7 +896,11 @@ def _async_add_reported_body_sensors() -> None: ) # Dose events are added eagerly (visible immediately as no data # until a dose runs); the salt chlorinator's item only exists on - # salt sites, so that one waits for its first push. + # salt sites, so that one waits for its first push. Each dose + # sensor, its total, and its reset button attach to the chemical + # pump device behind the chemical (matched by pump model, the + # only per-chemical identifier every install carries), falling + # back to the body group device on installs that report none. for description in DOSE_EVENT_SENSORS: added_key = f"{body_of_water_uuid}_{description.key}" if ( @@ -874,16 +915,28 @@ def _async_add_reported_body_sensors() -> None: ): continue added.add(added_key) - dose_sensor = PoolsideBodyDoseSensor( - client, group, body_of_water_uuid, description + pump_device = _chemical_pump_device( + data.pool_devices, description.key ) - new_entities.append(dose_sensor) - added.add(f"{added_key}_total") - new_entities.append( - PoolsideDoseTotalSensor( + if pump_device is None: + dose_sensor = PoolsideBodyDoseSensor( + client, group, body_of_water_uuid, description + ) + total_sensor = PoolsideDoseTotalSensor( client, group, body_of_water_uuid, dose_sensor ) - ) + else: + dose_sensor = PoolsideBodyDoseSensor( + client, group, body_of_water_uuid, description, + device=pump_device, + ) + total_sensor = PoolsideDoseTotalSensor( + client, group, body_of_water_uuid, dose_sensor, + device=pump_device, + ) + new_entities.append(dose_sensor) + added.add(f"{added_key}_total") + new_entities.append(total_sensor) # Registered for the button platform's reset buttons, which # set up after sensors (PLATFORMS order). data.dose_handlers[f"{body_of_water_uuid}_{description.key}"] = ( @@ -903,18 +956,23 @@ def _async_add_reported_body_sensors() -> None: client, group, body_of_water_uuid ) ) - # Energy sensors are added eagerly too, so installs see them - # right away; totals the body's equipment doesn't report stay - # as no data rather than never materializing (this body's - # heater is gas? its electric total still shows). + # Gas consumption attaches to the heater device (it's the + # heater's fuel burn); electric watts is a whole-body figure + # and stays on the body group device. for description in ENERGY_SENSORS: added_key = f"{body_of_water_uuid}_{description.key}" if added_key in added: continue added.add(added_key) + attach_device = ( + heater_device + if description.key == "gas_consumption" + else None + ) new_entities.append( PoolsideBodyEnergySensor( - client, group, body_of_water_uuid, description + client, group, body_of_water_uuid, description, + device=attach_device, ) ) if new_entities: @@ -930,14 +988,19 @@ def _async_add_reported_body_sensors() -> None: # Heater "egg timer" sensors are added eagerly for every temperature # control; while no timed session runs the value is no data. A heater - # that can never report the item simply stays at no data. + # that can never report the item simply stays at no data. Timer and + # gas sensors attach to the heater device when the site has one. + heater_device = _heater_device(data.pool_devices) heaters = [ control for control in data.controls if control.control_type is ControlType.TEMPERATURE ] async_add_entities( - PoolsideControlOnUntilSensor(client, control) for control in heaters + PoolsideControlOnUntilSensor( + client, control, device=heater_device + ) + for control in heaters ) # Pre-seeded so a descriptor that lists ActualPowerState or Winterized @@ -1146,6 +1209,7 @@ def __init__( group: PoolsideGroup, body_of_water_uuid: str, description: PoolsideBodySensorDescription, + device: PoolsideDevice | None = None, ) -> None: """Set up the last-dose sensor for one chemical of a body of water.""" super().__init__(client, group) @@ -1155,6 +1219,10 @@ def __init__( self._attr_unique_id = ( f"{client.controller_uuid}_{body_of_water_uuid}_{description.key}" ) + if device is not None: + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device.uuid)} + ) self._last_document: dict[str, Any] | None = None self._seen_dose_uuids: set[str] | None = None self._total_sensor: PoolsideDoseTotalSensor | None = None @@ -1234,6 +1302,7 @@ def __init__( group: PoolsideGroup, body_of_water_uuid: str, dose_sensor: PoolsideBodyDoseSensor, + device: PoolsideDevice | None = None, ) -> None: """Set up the dose-total sensor for one chemical of a body of water.""" super().__init__(client, group) @@ -1246,6 +1315,10 @@ def __init__( self._attr_native_unit_of_measurement = ( dose_sensor.entity_description.native_unit_of_measurement ) + if device is not None: + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device.uuid)} + ) self._dose_sensor = dose_sensor self._total: float = 0.0 self._restored_uuids: set[str] = set() @@ -1359,6 +1432,7 @@ def __init__( group: PoolsideGroup, body_of_water_uuid: str, description: PoolsideBodyEnergySensorDescription, + device: PoolsideDevice | None = None, ) -> None: """Set up one energy reading for a given body of water.""" super().__init__(client, group) @@ -1367,6 +1441,10 @@ def __init__( self._attr_unique_id = ( f"{client.controller_uuid}_{body_of_water_uuid}_{description.key}" ) + if device is not None: + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device.uuid)} + ) @override def _status_keys(self) -> set[str]: @@ -1406,11 +1484,20 @@ class PoolsideControlOnUntilSensor(PoolsideEntity, SensorEntity): _attr_translation_key = "on_until" _use_translated_name = True - def __init__(self, client: PoolsideClient, control: PoolsideControl) -> None: + def __init__( + self, + client: PoolsideClient, + control: PoolsideControl, + device: PoolsideDevice | None = None, + ) -> None: """Set up the egg timer sensor for a heater control.""" super().__init__(client, control) self._attr_unique_id = f"{client.controller_uuid}_{control.uuid}_on_until" self._attr_translation_placeholders = {"control_name": control.name} + if device is not None: + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device.uuid)} + ) @property @override From 8cbe4429f0d4c40be1de277f0dafa238148327ad Mon Sep 17 00:00:00 2001 From: Kevin M Date: Wed, 21 Oct 2026 23:04:05 -0500 Subject: [PATCH 05/13] fix: define heater_device before first use (NameError crashed sensor platform) --- custom_components/poolside/sensor.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/custom_components/poolside/sensor.py b/custom_components/poolside/sensor.py index b86431c..326f266 100644 --- a/custom_components/poolside/sensor.py +++ b/custom_components/poolside/sensor.py @@ -866,6 +866,9 @@ async def async_setup_entry( async_add_entities(entities) added: set[str] = set() + # The heater pool device, if the site has one: gas consumption and the + # heater egg-timer attach to it. + heater_device = _heater_device(data.pool_devices) @callback def _async_add_reported_body_sensors() -> None: @@ -988,9 +991,7 @@ def _async_add_reported_body_sensors() -> None: # Heater "egg timer" sensors are added eagerly for every temperature # control; while no timed session runs the value is no data. A heater - # that can never report the item simply stays at no data. Timer and - # gas sensors attach to the heater device when the site has one. - heater_device = _heater_device(data.pool_devices) + # that can never report the item simply stays at no data. heaters = [ control for control in data.controls From a69ebe019e0b5af02ff00f99552bbcef54603733 Mon Sep 17 00:00:00 2001 From: Kevin M Date: Wed, 21 Oct 2026 23:05:02 -0500 Subject: [PATCH 06/13] fix: reset buttons attach to the chemical pump device with their counter --- custom_components/poolside/button.py | 20 ++++++++++++++++---- custom_components/poolside/sensor.py | 1 + 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/custom_components/poolside/button.py b/custom_components/poolside/button.py index a16d9e9..12a9628 100644 --- a/custom_components/poolside/button.py +++ b/custom_components/poolside/button.py @@ -9,11 +9,13 @@ from homeassistant.components.button import ButtonEntity from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from typing import override from . import PoolsideConfigEntry -from .entity import PoolsideGroupEntity +from .const import DOMAIN +from .entity import PoolsideEntity, PoolsideGroupEntity DOSE_KEY_TO_CHEMICAL = { "acid_dose": "acid", @@ -35,15 +37,25 @@ async def async_setup_entry( ) -class PoolsideDoseResetButton(PoolsideGroupEntity, ButtonEntity): - """Resets one chemical's dosed-since-reset counter to zero.""" +class PoolsideDoseResetButton(PoolsideEntity, ButtonEntity): + """Resets one chemical's dosed-since-reset counter to zero. + + Attaches to the same device as its dose sensor - the chemical pump + device when the site reports one (passed through on the sensor), so + the reset control lives with the counter it zeroes. + """ _attr_translation_key = "dose_reset" _attr_entity_registry_enabled_default = False def __init__(self, client, dose_sensor) -> None: """Set up the reset button for one chemical of a body of water.""" - super().__init__(client, dose_sensor._group) + client, group = dose_sensor._client, dose_sensor._group + super().__init__(client, group) + if getattr(dose_sensor, "_device", None) is not None: + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, dose_sensor._device.uuid)} + ) self._dose_sensor = dose_sensor chemical = DOSE_KEY_TO_CHEMICAL.get( dose_sensor.entity_description.key, diff --git a/custom_components/poolside/sensor.py b/custom_components/poolside/sensor.py index 326f266..cee0822 100644 --- a/custom_components/poolside/sensor.py +++ b/custom_components/poolside/sensor.py @@ -1216,6 +1216,7 @@ def __init__( super().__init__(client, group) self.entity_description = description self._group = group + self._device = device self._body_of_water_uuid = body_of_water_uuid self._attr_unique_id = ( f"{client.controller_uuid}_{body_of_water_uuid}_{description.key}" From 4e1e348c0f567a9b59a2aad4624b5dad3bbb33a0 Mon Sep 17 00:00:00 2001 From: Kevin M Date: Wed, 21 Oct 2026 23:08:33 -0500 Subject: [PATCH 07/13] fix: buttons attach to pump device (PoolsideEntity->PoolsideGroupEntity); via_device_id deprecation --- custom_components/poolside/__init__.py | 2 +- custom_components/poolside/button.py | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/custom_components/poolside/__init__.py b/custom_components/poolside/__init__.py index e09015a..1ece84d 100644 --- a/custom_components/poolside/__init__.py +++ b/custom_components/poolside/__init__.py @@ -152,7 +152,7 @@ def _async_register_devices( device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, device.uuid)}, - via_device=(DOMAIN, client.controller_uuid), + via_device_id=(DOMAIN, client.controller_uuid), name=device.name, manufacturer="Poolside", model=device.device_type, diff --git a/custom_components/poolside/button.py b/custom_components/poolside/button.py index 12a9628..333ac5a 100644 --- a/custom_components/poolside/button.py +++ b/custom_components/poolside/button.py @@ -15,7 +15,7 @@ from . import PoolsideConfigEntry from .const import DOMAIN -from .entity import PoolsideEntity, PoolsideGroupEntity +from .entity import PoolsideGroupEntity DOSE_KEY_TO_CHEMICAL = { "acid_dose": "acid", @@ -37,7 +37,7 @@ async def async_setup_entry( ) -class PoolsideDoseResetButton(PoolsideEntity, ButtonEntity): +class PoolsideDoseResetButton(PoolsideGroupEntity, ButtonEntity): """Resets one chemical's dosed-since-reset counter to zero. Attaches to the same device as its dose sensor - the chemical pump @@ -50,8 +50,7 @@ class PoolsideDoseResetButton(PoolsideEntity, ButtonEntity): def __init__(self, client, dose_sensor) -> None: """Set up the reset button for one chemical of a body of water.""" - client, group = dose_sensor._client, dose_sensor._group - super().__init__(client, group) + super().__init__(client, dose_sensor._group) if getattr(dose_sensor, "_device", None) is not None: self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, dose_sensor._device.uuid)} From ce9f34ce171ee5aa7939e6601fafa4e9ea9b26a0 Mon Sep 17 00:00:00 2001 From: Kevin M Date: Wed, 21 Oct 2026 23:11:30 -0500 Subject: [PATCH 08/13] fix: match chemical pumps by device name (ChemicalType is not on the model); heater by device_type/name --- custom_components/poolside/sensor.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/custom_components/poolside/sensor.py b/custom_components/poolside/sensor.py index cee0822..0476402 100644 --- a/custom_components/poolside/sensor.py +++ b/custom_components/poolside/sensor.py @@ -475,16 +475,21 @@ def _chemical_pump_device( """Return the chemical pump device a dose key's entities attach to. Dose items are keyed to a chemical (acid, liquid chlorine, salt), and - its pump is identified by the `ChemicalType` its model carries - the - only per-chemical identifier every install has. Unknown pairings - return None and the caller falls back to the body group device. + its pump is identified by its vendor-assigned device name ("Acid + Pump"); all chemical pumps share the generic ChemicalPump device + type, so the name is the only per-chemical identifier installs + carry. Unknown pairings return None and the caller falls back to the + body group device. """ chemical = DOSE_KEY_TO_CHEMICAL_TYPE.get(dose_key) if chemical is None: return None for device in pool_devices: - model = str(getattr(device, "model", "") or "") - if model and chemical in model.upper(): + name = str(getattr(device, "name", "") or "").upper() + type_ = str(getattr(device, "device_type", "") or "").upper() + if type_ != "CHEMICALPUMP": + continue + if chemical in name: return device return None @@ -494,7 +499,9 @@ def _heater_device( ) -> PoolsideDevice | None: """Return the pool heater device, if the site has one.""" for device in pool_devices: - if str(getattr(device, "model", "") or "").upper() == "HEATER": + type_ = str(getattr(device, "device_type", "") or "").upper() + name = str(getattr(device, "name", "") or "").upper() + if type_ == "HEATER" or "HEATER" in name: return device return None From d142936b4ff51635cef37e26aed1b169e1b9622c Mon Sep 17 00:00:00 2001 From: Kevin M Date: Wed, 21 Oct 2026 23:14:25 -0500 Subject: [PATCH 09/13] fix: via_device_id takes the hub device's registry id, not an identifier tuple --- custom_components/poolside/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/poolside/__init__.py b/custom_components/poolside/__init__.py index 1ece84d..13d5bab 100644 --- a/custom_components/poolside/__init__.py +++ b/custom_components/poolside/__init__.py @@ -141,7 +141,7 @@ def _async_register_devices( created explicitly instead of as a side effect of entity setup. """ device_registry = dr.async_get(hass) - device_registry.async_get_or_create( + hub = device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, client.controller_uuid)}, name=site.name, @@ -152,7 +152,7 @@ def _async_register_devices( device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, device.uuid)}, - via_device_id=(DOMAIN, client.controller_uuid), + via_device_id=hub.id, name=device.name, manufacturer="Poolside", model=device.device_type, From 39506c97c3c452c76cee58b5ea491a683fefb284 Mon Sep 17 00:00:00 2001 From: Kevin M Date: Wed, 21 Oct 2026 23:18:18 -0500 Subject: [PATCH 10/13] fix: chlorine pump matched by CHLOR token (vendor names it 'Chlor Pump') --- custom_components/poolside/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/poolside/sensor.py b/custom_components/poolside/sensor.py index 0476402..a759e9e 100644 --- a/custom_components/poolside/sensor.py +++ b/custom_components/poolside/sensor.py @@ -226,7 +226,7 @@ class PoolsideBodySensorDescription(SensorEntityDescription): # device's model carries ("Acid" Acid pump, "LiquidChlorine" chlorine pump). DOSE_KEY_TO_CHEMICAL_TYPE = { "acid_dose": "ACID", - "chlorine_dose": "LIQUIDCHLORINE", + "chlorine_dose": "CHLOR", "salt_chlorinator_dose": "SALT", } From e2978f4efaf083506e1fb4626670f02b39a7a9a9 Mon Sep 17 00:00:00 2001 From: Kevin M Date: Thu, 22 Oct 2026 21:48:35 -0500 Subject: [PATCH 11/13] feat: last_reset attribute on dose counters; TANK_TRACKING.md DIY helpers recipe - Counters record the reset timestamp (persisted with the total). - New TANK_TRACKING.md documents the tank-level pattern: number helper + counter-triggered decrement automation (positive-delta filter so resets are ignored) + low-tank alert; README links to it. --- README.md | 2 + TANK_TRACKING.md | 84 ++++++++++++++++++++++++++++ custom_components/poolside/sensor.py | 5 ++ 3 files changed, 91 insertions(+) create mode 100644 TANK_TRACKING.md diff --git a/README.md b/README.md index 1faf9b5..eb67090 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ your network via zeroconf and talks to it over an encrypted websocket session ## Features - **climate** — heater/chiller control per body of water +- **tank tracking (DIY)** — see [TANK_TRACKING.md](TANK_TRACKING.md) for a + helpers recipe built on the dosed-since-reset counters - **fan** — variable-speed pumps and water features - **light** — underwater/landscape lights, including effects and brightness where supported diff --git a/TANK_TRACKING.md b/TANK_TRACKING.md new file mode 100644 index 0000000..5013983 --- /dev/null +++ b/TANK_TRACKING.md @@ -0,0 +1,84 @@ +# Tracking tank levels (DIY helpers) + +The integration deliberately does **not** model tank capacity or percentage: +tank sizes vary, some tanks are refilled part-way, and not every install has +auto-feeders at all. The controller does not report tank levels locally. + +What the integration *does* provide is the dose truth a DIY tank helper +needs: + +- each chemical's **"dosed since last reset"** counter (disabled by + default) — a count-up total in the chemical's dosing unit, persisted + across restarts, deduplicated by dose UUID so repeated controller + documents never double-count, and resettable with its paired button +- each counter's `last_reset` attribute (timestamp of the most recent + reset), so a tank refill can be paired with the accounting period +- dose-only updates: the counter moves only when a *new* dose document + arrives, never on controller re-pushes + +## The pattern + +1. Create one number helper per tank (Settings → Devices & Services → + Helpers → Number). Example: "Acid tank", unit oz. +2. After a refill (full or partial), set the helper to what you actually + filled to — e.g. 254 oz — then press the chemical's reset button once. + The counter now reads 0 and the helper holds the tank content. +3. An automation decrements the helper each time the counter rises: + +```yaml +automation: + - alias: "Acid tank: consume doses" + triggers: + - trigger: state + entity_id: sensor.pool_acid_dose_total + conditions: + # Doses raise the counter (positive delta); a reset lowers it and + # must not change the tank helper (you already set it at refill). + - condition: numeric_state + entity_id: sensor.pool_acid_dose_total + above: -1 + - condition: template + value_template: > + {{ (trigger.to_state.state | float(0)) > + (trigger.from_state.state | float(0)) }} + actions: + - action: input_number.set_value + target: + entity_id: input_number.acid_tank + data: + value: > + {{ states('input_number.acid_tank') | float(0) - + ((trigger.to_state.state | float(0)) - + (trigger.from_state.state | float(0))) }} +``` + +4. A second automation alerts at your low threshold: + +```yaml +automation: + - alias: "Acid tank: low" + triggers: + - trigger: numeric_state + entity_id: input_number.acid_tank + below: 48 # your threshold, in the helper's unit + actions: + - action: notify.persistent_notification + data: + message: "Acid tank below threshold ({{ states('input_number.acid_tank') }} oz)." +``` + +## Notes and limits + +- **Two equal doses in a row** (e.g. 2 oz then another 2 oz) are still + two trigger events: the *counter* changes (2 → 4) even though the + last-dose sensor would show the same value. That is why the automation + watches the counter, not the last-dose sensor. +- **Doses while Home Assistant is down** are counted from the + controller's current dose document at the restart snapshot — a single + missed-then-recovered dose is counted, but only the *last* dose per + chemical is retained by the controller, so multiple doses during a long + outage can under-count. Size your low-tank threshold with that margin. +- Manual doses initiated from the vendor app are dose documents too — + they count automatically. +- The counter only counts what it observes since its last reset; pair + each refill with a button reset (step 2) to keep the two in sync. \ No newline at end of file diff --git a/custom_components/poolside/sensor.py b/custom_components/poolside/sensor.py index a759e9e..46c50b3 100644 --- a/custom_components/poolside/sensor.py +++ b/custom_components/poolside/sensor.py @@ -71,6 +71,7 @@ UnitOfVolumeFlowRate, ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.util import dt as dt_util 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 @@ -1330,6 +1331,7 @@ def __init__( ) self._dose_sensor = dose_sensor self._total: float = 0.0 + self._reset_at: str | None = None self._restored_uuids: set[str] = set() @override @@ -1356,6 +1358,7 @@ def add(self, amount: float) -> None: def set_value(self, value: float) -> None: """Set the total directly (the reset path).""" self._total = value + self._reset_at = dt_util.now().isoformat(timespec="seconds") self.async_write_ha_state() @property @@ -1363,6 +1366,7 @@ def set_value(self, value: float) -> None: def extra_state_attributes(self) -> dict[str, Any]: """Return bookkeeping details for debugging the counter.""" return { + "last_reset": self._reset_at, "counted_dose_uuids": sorted(self._restored_uuids) if not self._dose_sensor._seen_dose_uuids else sorted(self._dose_sensor._seen_dose_uuids), @@ -1379,6 +1383,7 @@ async def async_added_to_hass(self) -> None: self._total = 0.0 if (extra := state.attributes.get("counted_dose_uuids")) is not None: self._restored_uuids = set(extra) + self._reset_at = state.attributes.get("last_reset") self._dose_sensor._total_sensor = self # Replay what's already known so totals survive restarts that # happened between the last dose and the next push. From de4c4de4fe43b02312f823d51ea552ae772f6be9 Mon Sep 17 00:00:00 2001 From: Kevin M Date: Thu, 22 Oct 2026 21:58:14 -0500 Subject: [PATCH 12/13] fix: energy document streams under the site status, not the body The controller carries LastMinuteOfEnergyConsumption on its site status item; reading the body uuid left power consumption (and heater gas) unknown forever on installs that stream it site-side. The lookup now prefers the body and falls back to the client's site_uuid, and the sensor subscribes to both keys. --- custom_components/poolside/sensor.py | 38 ++++++++++++++++++++-------- tests/test_sensor_parsing.py | 11 ++++++++ 2 files changed, 39 insertions(+), 10 deletions(-) diff --git a/custom_components/poolside/sensor.py b/custom_components/poolside/sensor.py index 46c50b3..a1a16f1 100644 --- a/custom_components/poolside/sensor.py +++ b/custom_components/poolside/sensor.py @@ -445,14 +445,23 @@ def _dose_extra_attributes(document: dict[str, Any] | None) -> dict[str, Any]: def _energy_document( client: PoolsideClient, body_of_water_uuid: str ) -> dict[str, Any] | None: - """Return the body of water's last-minute energy document, if any.""" - return _json_document( - body_of_water_uuid, - LAST_MINUTE_OF_ENERGY_CONSUMPTION_FIELD, - client.get_status( - body_of_water_uuid, LAST_MINUTE_OF_ENERGY_CONSUMPTION_FIELD - ), - ) + """Return the body of water's last-minute energy document, if any. + + The controller reports the document under its site status; bodies of + water are checked first all the same, so installs that stream the + item per-body keep working. + """ + for uuid in (body_of_water_uuid, client.site_uuid): + if not uuid: + continue + document = _json_document( + uuid, + LAST_MINUTE_OF_ENERGY_CONSUMPTION_FIELD, + client.get_status(uuid, LAST_MINUTE_OF_ENERGY_CONSUMPTION_FIELD), + ) + if document is not None: + return document + return None def _energy_total_reported( @@ -1462,8 +1471,17 @@ def __init__( @override def _status_keys(self) -> set[str]: - """Return the body-of-water key its energy arrives under.""" - return {self._body_of_water_uuid} + """The body key and the site key: the item streams under the site. + + The controller carries `LastMinuteOfEnergyConsumption` on its site + status (observed: the body-of-water item never carries it), but + installs differ about where stream items live; the body key is + kept first and the site covered with the same listener set. + """ + keys = {self._body_of_water_uuid} + if self._client.site_uuid: + keys.add(self._client.site_uuid) + return keys @property @override diff --git a/tests/test_sensor_parsing.py b/tests/test_sensor_parsing.py index 372b242..99cc697 100644 --- a/tests/test_sensor_parsing.py +++ b/tests/test_sensor_parsing.py @@ -92,12 +92,23 @@ class StubClient: def __init__(self, status: dict[str, dict]) -> None: """Store the raw status by (uuid, field).""" self._status = status + self.site_uuid = None def get_status(self, uuid: str, field: str): """Return the raw value pushed for (uuid, field).""" return self._status.get(uuid, {}).get(field) +def test_energy_document_prefers_body_falls_back_to_site(): + """The site-streamed document is found when no body item exists.""" + client = StubClient({ + BODY: {}, + "site-uuid": {"LastMinuteOfEnergyConsumption": '{"TotalWattsUsed": 123}'}, + }) + client.site_uuid = "site-uuid" + assert _energy_document(client, BODY) == {"TotalWattsUsed": 123} + + def _encoded(value) -> str: """Encode a document the way the wire does: JSON inside the string.""" return json.dumps(value) From b246893cc1a1a42e5bc98756a6e9da8868ee3a60 Mon Sep 17 00:00:00 2001 From: Kevin M Date: Thu, 22 Oct 2026 22:07:13 -0500 Subject: [PATCH 13/13] feat: drop whole-body power consumption; keep heater gas/propane BTU (pumps already expose watts) --- custom_components/poolside/icons.json | 3 --- custom_components/poolside/sensor.py | 8 -------- custom_components/poolside/strings.json | 5 +---- custom_components/poolside/translations/en.json | 5 +---- 4 files changed, 2 insertions(+), 19 deletions(-) diff --git a/custom_components/poolside/icons.json b/custom_components/poolside/icons.json index f3867e3..48d619f 100644 --- a/custom_components/poolside/icons.json +++ b/custom_components/poolside/icons.json @@ -72,9 +72,6 @@ "ph": { "default": "mdi:ph" }, - "power_consumption": { - "default": "mdi:flash" - }, "salt_chlorinator_dose": { "default": "mdi:shaker-outline" }, diff --git a/custom_components/poolside/sensor.py b/custom_components/poolside/sensor.py index a1a16f1..0a86dc5 100644 --- a/custom_components/poolside/sensor.py +++ b/custom_components/poolside/sensor.py @@ -252,14 +252,6 @@ class PoolsideBodyEnergySensorDescription(SensorEntityDescription): ENERGY_SENSORS = ( - PoolsideBodyEnergySensorDescription( - key="power_consumption", - translation_key="power_consumption", - document_key=ENERGY_WATTS_KEY, - device_class=SensorDeviceClass.POWER, - native_unit_of_measurement=UnitOfPower.WATT, - state_class=SensorStateClass.MEASUREMENT, - ), PoolsideBodyEnergySensorDescription( key="gas_consumption", translation_key="gas_consumption", diff --git a/custom_components/poolside/strings.json b/custom_components/poolside/strings.json index d742609..ada754b 100644 --- a/custom_components/poolside/strings.json +++ b/custom_components/poolside/strings.json @@ -16,7 +16,7 @@ }, "step": { "pair": { - "description": "On your Poolside controller or in the Poolside app, approve the pairing request from \u201c{fingerprint}\u201d. Confirm the code shown there matches: {fingerprint}", + "description": "On your Poolside controller or in the Poolside app, approve the pairing request from “{fingerprint}”. Confirm the code shown there matches: {fingerprint}", "title": "Approve this connection" }, "reauth_confirm": { @@ -137,9 +137,6 @@ "orp": { "name": "ORP" }, - "power_consumption": { - "name": "Power consumption" - }, "salt_chlorinator_dose": { "name": "Last salt chlorinator dose" }, diff --git a/custom_components/poolside/translations/en.json b/custom_components/poolside/translations/en.json index 36d021a..3d59337 100644 --- a/custom_components/poolside/translations/en.json +++ b/custom_components/poolside/translations/en.json @@ -16,7 +16,7 @@ }, "step": { "pair": { - "description": "On your Poolside controller or in the Poolside app, approve the pairing request from \u201c{fingerprint}\u201d. Confirm the code shown there matches: {fingerprint}", + "description": "On your Poolside controller or in the Poolside app, approve the pairing request from “{fingerprint}”. Confirm the code shown there matches: {fingerprint}", "title": "Approve this connection" }, "reauth_confirm": { @@ -128,9 +128,6 @@ "orp": { "name": "ORP" }, - "power_consumption": { - "name": "Power consumption" - }, "salt_chlorinator_dose": { "name": "Last salt chlorinator dose" },