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