Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
84 changes: 84 additions & 0 deletions TANK_TRACKING.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 8 additions & 3 deletions custom_components/poolside/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -44,6 +44,7 @@
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
Platform.BUTTON,
]


Expand All @@ -55,6 +56,10 @@ class PoolsideData:
site: PoolsideSite
controls: list[PoolsideControl]
pool_devices: list[PoolsideDevice]
# Per-body dosing sensors, by `<body uuid>_<dose key>`: 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]
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
80 changes: 80 additions & 0 deletions custom_components/poolside/button.py
Original file line number Diff line number Diff line change
@@ -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()
40 changes: 40 additions & 0 deletions custom_components/poolside/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
36 changes: 36 additions & 0 deletions custom_components/poolside/icons.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@
"actual_power_state": {
"default": "mdi:power"
},
"acid_dose": {
"default": "mdi:test-tube"
},
"actuator_state": {
"default": "mdi:valve",
"state": {
Expand All @@ -31,6 +34,9 @@
"overload": "mdi:alert-circle"
}
},
"chlorine_dose": {
"default": "mdi:beaker-outline"
},
"current_state": {
"default": "mdi:state-machine"
},
Expand All @@ -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"
},
Expand All @@ -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": {
Expand All @@ -85,6 +116,11 @@
"water_feature": {
"default": "mdi:fountain"
}
},
"button": {
"dose_reset": {
"default": "mdi:restart-alert"
}
}
}
}
Loading