diff --git a/README.md b/README.md index 24424c4..eb67090 100644 --- a/README.md +++ b/README.md @@ -10,13 +10,17 @@ 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 - **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/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/__init__.py b/custom_components/poolside/__init__.py index 4d45aee..13d5bab 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, @@ -44,6 +44,7 @@ Platform.SELECT, Platform.SENSOR, Platform.SWITCH, + Platform.BUTTON, ] @@ -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] @@ -136,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, @@ -147,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=hub.id, name=device.name, manufacturer="Poolside", model=device.device_type, diff --git a/custom_components/poolside/button.py b/custom_components/poolside/button.py new file mode 100644 index 0000000..333ac5a --- /dev/null +++ b/custom_components/poolside/button.py @@ -0,0 +1,80 @@ +"""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.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from typing import override + +from . import PoolsideConfigEntry +from .const import DOMAIN +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. + + 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) + 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, + 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/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..48d619f 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,21 @@ "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" }, + "salt_chlorinator_dose": { + "default": "mdi:shaker-outline" + }, "salt_level": { "default": "mdi:shaker-outline" }, @@ -74,8 +89,24 @@ "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" + }, + "acid_dose_total": { + "default": "mdi:flask-plus" + }, + "chlorine_dose_total": { + "default": "mdi:bottle-tonic-plus" + }, + "salt_chlorinator_dose_total": { + "default": "mdi:flask-plus" } }, "switch": { @@ -85,6 +116,11 @@ "water_feature": { "default": "mdi:fountain" } + }, + "button": { + "dose_reset": { + "default": "mdi:restart-alert" + } } } } diff --git a/custom_components/poolside/sensor.py b/custom_components/poolside/sensor.py index 68d0889..0a86dc5 100644 --- a/custom_components/poolside/sensor.py +++ b/custom_components/poolside/sensor.py @@ -46,6 +46,7 @@ WINTERIZED_REASON, ActuatorFriendlyState, BodyOfWaterState, + ControlType, SiteMode, ) @@ -66,22 +67,50 @@ UnitOfRatio, UnitOfTemperature, UnitOfTime, + UnitOfVolume, 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 +from homeassistant.helpers.restore_state import RestoreEntity 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, ) +from .button import PoolsideDoseResetButton @dataclass(frozen=True, kw_only=True) @@ -155,12 +184,90 @@ 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"] + +# 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": "CHLOR", + "salt_chlorinator_dose": "SALT", +} + + +@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="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.""" try: return float(value) - except TypeError, ValueError: + except (TypeError, ValueError): return None @@ -259,6 +366,155 @@ 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. + + 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( + 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 _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 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: + 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 + + +def _heater_device( + pool_devices: list[PoolsideDevice], +) -> PoolsideDevice | None: + """Return the pool heater device, if the site has one.""" + for device in pool_devices: + 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 + + +def _water_quality_value(value: Any) -> str | None: + 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 +828,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 @@ -615,6 +875,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: @@ -643,6 +906,87 @@ def _async_add_reported_body_sensors() -> None: new_entities.append( PoolsideBodySensor(client, group, body_of_water_uuid, description) ) + # 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. 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 ( + added_key in added + or ( + description.key == "salt_chlorinator_dose" + and client.get_status( + body_of_water_uuid, description.field + ) + is None + ) + ): + continue + added.add(added_key) + pump_device = _chemical_pump_device( + data.pool_devices, description.key + ) + 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}"] = ( + dose_sensor + ) + 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 + ) + ) + # 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, + device=attach_device, + ) + ) if new_entities: async_add_entities(new_entities) @@ -654,6 +998,21 @@ 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. + heaters = [ + control + for control in data.controls + if control.control_type is ControlType.TEMPERATURE + ] + async_add_entities( + PoolsideControlOnUntilSensor( + client, control, device=heater_device + ) + for control in heaters + ) + # 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 +1194,342 @@ 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. + + 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 + + def __init__( + self, + client: PoolsideClient, + 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) + 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}" + ) + 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 + + @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._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, + device: PoolsideDevice | None = None, + ) -> 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 + ) + 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._reset_at: str | None = None + 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._reset_at = dt_util.now().isoformat(timespec="seconds") + self.async_write_ha_state() + + @property + @override + 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), + } + + @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._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. + 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). + + 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, + device: PoolsideDevice | None = None, + ) -> 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}" + ) + if device is not None: + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device.uuid)} + ) + + @override + def _status_keys(self) -> set[str]: + """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 + 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, + 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 + 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..ada754b 100644 --- a/custom_components/poolside/strings.json +++ b/custom_components/poolside/strings.json @@ -66,6 +66,18 @@ "on": "[%key:common::state::on%]" } }, + "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": { @@ -79,6 +91,9 @@ "waiting_to_move": "Waiting" } }, + "chlorine_dose": { + "name": "Last chlorine dose" + }, "current_state": { "name": "Water state", "state": { @@ -113,9 +128,18 @@ "free_chlorine": { "name": "Free chlorine" }, + "gas_consumption": { + "name": "Gas consumption" + }, + "on_until": { + "name": "{control_name} on until" + }, "orp": { "name": "ORP" }, + "salt_chlorinator_dose": { + "name": "Last salt chlorinator dose" + }, "salt_level": { "name": "Salt level" }, @@ -131,6 +155,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": { @@ -138,6 +172,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 8041da9..3d59337 100644 --- a/custom_components/poolside/translations/en.json +++ b/custom_components/poolside/translations/en.json @@ -1,155 +1,194 @@ { - "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 “{fingerprint}”. 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" - } - }, - "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" - } - }, - "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" - }, - "orp": { - "name": "ORP" - }, - "salt_level": { - "name": "Salt level" - }, - "site_mode": { - "name": "Controller mode", - "state": { - "factory": "Factory", - "fault": "Fault", - "installer": "Installer", - "normal": "Normal" - } - }, - "total_chlorine": { - "name": "Total chlorine" - }, - "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" + }, + "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/pytest.ini b/pytest.ini new file mode 100644 index 0000000..0ba98a2 --- /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..99cc697 --- /dev/null +++ b/tests/test_sensor_parsing.py @@ -0,0 +1,201 @@ +"""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, +) + + +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 = { + "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, + "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 + 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) + + +BODY = "00000000-1111-4222-8333-444444444555" # synthetic UUID for tests + + +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