Skip to content

Commit 7249b57

Browse files
committed
Add chemistry monitoring features and multi-pool support (v2.2.0)
Features: - Binary sensors for out-of-range chemistry detection (fc_problem, ph_problem, etc.) - Target range attributes on sensors (target_min, target_max, in_range) - Last logged timestamp as ISO datetime attribute - Events fired when chemistry goes out of or back in range (poolmath_chemistry_out_of_range, poolmath_chemistry_in_range) - Expanded TFP target ranges for all chemistry sensors - Block pool identity changes in options flow (prevents orphaned entities) - Multi-pool support with user_id in unique IDs - Backwards compatible entity migration for existing installations
1 parent 31b2374 commit 7249b57

9 files changed

Lines changed: 619 additions & 28 deletions

File tree

custom_components/poolmath/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828

2929
LOG = logging.getLogger(__name__)
3030

31-
PLATFORMS: list[Platform] = [Platform.SENSOR]
31+
PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR]
3232

3333

3434
def get_config_options(
Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
"""Pool Math binary sensor platform for out-of-range detection."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
from typing import Any
7+
8+
from homeassistant.components.binary_sensor import (
9+
BinarySensorDeviceClass,
10+
BinarySensorEntity,
11+
BinarySensorEntityDescription,
12+
)
13+
from homeassistant.config_entries import ConfigEntry
14+
from homeassistant.const import CONF_NAME
15+
from homeassistant.core import HomeAssistant, callback
16+
from homeassistant.helpers import entity_registry as er
17+
from homeassistant.helpers.entity_platform import AddEntitiesCallback
18+
from homeassistant.helpers.update_coordinator import CoordinatorEntity
19+
20+
from .client import parse_pool
21+
from .const import (
22+
ATTR_TARGET_MAX,
23+
ATTR_TARGET_MIN,
24+
ATTRIBUTION,
25+
CONF_POOL_ID,
26+
CONF_TARGET,
27+
CONF_TIMEOUT,
28+
CONF_USER_ID,
29+
DEFAULT_TIMEOUT,
30+
DOMAIN,
31+
)
32+
from .coordinator import PoolMathUpdateCoordinator
33+
from .models import PoolMathConfig
34+
from .sensor import get_device_info
35+
from .targets import CHEMISTRY_SENSORS_WITH_TARGETS, get_target_range
36+
37+
LOG = logging.getLogger(__name__)
38+
39+
# binary sensor descriptions for chemistry problem sensors
40+
BINARY_SENSOR_DESCRIPTIONS: dict[str, BinarySensorEntityDescription] = {
41+
'fc': BinarySensorEntityDescription(
42+
key='fc',
43+
translation_key='fc_problem',
44+
device_class=BinarySensorDeviceClass.PROBLEM,
45+
),
46+
'cc': BinarySensorEntityDescription(
47+
key='cc',
48+
translation_key='cc_problem',
49+
device_class=BinarySensorDeviceClass.PROBLEM,
50+
),
51+
'ph': BinarySensorEntityDescription(
52+
key='ph',
53+
translation_key='ph_problem',
54+
device_class=BinarySensorDeviceClass.PROBLEM,
55+
),
56+
'ta': BinarySensorEntityDescription(
57+
key='ta',
58+
translation_key='ta_problem',
59+
device_class=BinarySensorDeviceClass.PROBLEM,
60+
),
61+
'ch': BinarySensorEntityDescription(
62+
key='ch',
63+
translation_key='ch_problem',
64+
device_class=BinarySensorDeviceClass.PROBLEM,
65+
),
66+
'cya': BinarySensorEntityDescription(
67+
key='cya',
68+
translation_key='cya_problem',
69+
device_class=BinarySensorDeviceClass.PROBLEM,
70+
),
71+
'salt': BinarySensorEntityDescription(
72+
key='salt',
73+
translation_key='salt_problem',
74+
device_class=BinarySensorDeviceClass.PROBLEM,
75+
),
76+
'bor': BinarySensorEntityDescription(
77+
key='bor',
78+
translation_key='bor_problem',
79+
device_class=BinarySensorDeviceClass.PROBLEM,
80+
),
81+
'borate': BinarySensorEntityDescription(
82+
key='borate',
83+
translation_key='borate_problem',
84+
device_class=BinarySensorDeviceClass.PROBLEM,
85+
),
86+
'csi': BinarySensorEntityDescription(
87+
key='csi',
88+
translation_key='csi_problem',
89+
device_class=BinarySensorDeviceClass.PROBLEM,
90+
),
91+
}
92+
93+
# sensors that are conditional (only created if tracked in Pool Math)
94+
CONDITIONAL_SENSORS = {
95+
'salt': 'trackSalt',
96+
'bor': 'trackBor',
97+
'borate': 'trackBor',
98+
'csi': 'trackCSI',
99+
}
100+
101+
102+
async def _migrate_entity_unique_ids(
103+
hass: HomeAssistant,
104+
config: PoolMathConfig,
105+
) -> None:
106+
"""Migrate binary sensor unique IDs from old format to new format.
107+
108+
Old format: poolmath_{pool_id}_{sensor_key}_problem
109+
New format: poolmath_{user_id}_{pool_id}_{sensor_key}_problem
110+
"""
111+
ent_reg = er.async_get(hass)
112+
113+
for sensor_key in BINARY_SENSOR_DESCRIPTIONS:
114+
old_unique_id = f'poolmath_{config.pool_id}_{sensor_key}_problem'
115+
new_unique_id = (
116+
f'poolmath_{config.user_id}_{config.pool_id}_{sensor_key}_problem'
117+
)
118+
119+
entity_id = ent_reg.async_get_entity_id('binary_sensor', DOMAIN, old_unique_id)
120+
if entity_id:
121+
LOG.info(f'Migrating binary sensor {entity_id} to new unique ID format')
122+
ent_reg.async_update_entity(entity_id, new_unique_id=new_unique_id)
123+
124+
125+
async def async_setup_entry(
126+
hass: HomeAssistant,
127+
entry: ConfigEntry,
128+
async_add_entities: AddEntitiesCallback,
129+
) -> None:
130+
"""Set up Pool Math binary sensors based on a config entry."""
131+
coordinator: PoolMathUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][
132+
'coordinator'
133+
]
134+
135+
config = PoolMathConfig(
136+
user_id=entry.options[CONF_USER_ID],
137+
pool_id=entry.options[CONF_POOL_ID],
138+
name=entry.options[CONF_NAME],
139+
timeout=entry.options.get(CONF_TIMEOUT, DEFAULT_TIMEOUT),
140+
target=entry.options[CONF_TARGET],
141+
)
142+
143+
# migrate old unique IDs for backwards compatibility
144+
await _migrate_entity_unique_ids(hass, config)
145+
146+
entities: list[PoolMathProblemSensor] = []
147+
148+
if coordinator.data and coordinator.data.json:
149+
pool = parse_pool(coordinator.data.json)
150+
if pool and pool.get('overview'):
151+
overview = pool['overview']
152+
153+
# create binary sensors for chemistry measurements with targets
154+
for sensor_key in CHEMISTRY_SENSORS_WITH_TARGETS:
155+
# skip if measurement not available
156+
if sensor_key not in overview or overview.get(sensor_key) is None:
157+
continue
158+
159+
# skip conditional sensors if not tracked
160+
if sensor_key in CONDITIONAL_SENSORS:
161+
track_key = CONDITIONAL_SENSORS[sensor_key]
162+
if not pool.get(track_key):
163+
LOG.debug(
164+
f'Skipping {sensor_key} binary sensor - tracking disabled'
165+
)
166+
continue
167+
168+
# check if we have a description for this sensor
169+
if sensor_key not in BINARY_SENSOR_DESCRIPTIONS:
170+
continue
171+
172+
# check if we have target ranges for this sensor
173+
target_range = get_target_range(sensor_key, config.target, pool)
174+
if not target_range:
175+
LOG.debug(f'Skipping {sensor_key} binary sensor - no target range')
176+
continue
177+
178+
entities.append(
179+
PoolMathProblemSensor(
180+
coordinator,
181+
config,
182+
BINARY_SENSOR_DESCRIPTIONS[sensor_key],
183+
)
184+
)
185+
186+
if entities:
187+
async_add_entities(entities)
188+
LOG.info(f'Created {len(entities)} Pool Math binary sensors for {config.name}')
189+
190+
191+
class PoolMathProblemSensor(
192+
CoordinatorEntity[PoolMathUpdateCoordinator], BinarySensorEntity
193+
):
194+
"""Binary sensor indicating if a pool chemistry value is out of range."""
195+
196+
_attr_attribution = ATTRIBUTION
197+
_attr_has_entity_name = True
198+
199+
def __init__(
200+
self,
201+
coordinator: PoolMathUpdateCoordinator,
202+
config: PoolMathConfig,
203+
description: BinarySensorEntityDescription,
204+
) -> None:
205+
"""Initialize the binary sensor."""
206+
super().__init__(coordinator)
207+
208+
self._config = config
209+
self._target_profile = config.target
210+
self.entity_description = description
211+
212+
self._attr_unique_id = (
213+
f'poolmath_{config.user_id}_{config.pool_id}_{description.key}_problem'
214+
)
215+
self._attr_device_info = get_device_info(config)
216+
self._attr_is_on: bool | None = None
217+
self._attr_extra_state_attributes: dict[str, Any] = {}
218+
219+
@callback
220+
def _handle_coordinator_update(self) -> None:
221+
"""Handle updated data from the coordinator."""
222+
if not self.coordinator.data or not self.coordinator.data.json:
223+
return
224+
225+
pool = parse_pool(self.coordinator.data.json)
226+
if not pool:
227+
return
228+
229+
overview = pool.get('overview', {})
230+
sensor_key = self.entity_description.key
231+
232+
value = overview.get(sensor_key)
233+
if value is None:
234+
self._attr_is_on = None
235+
self._attr_extra_state_attributes = {}
236+
self.async_write_ha_state()
237+
return
238+
239+
# get target range
240+
target_range = get_target_range(sensor_key, self._target_profile, pool)
241+
if not target_range:
242+
self._attr_is_on = None
243+
self._attr_extra_state_attributes = {'current_value': value}
244+
self.async_write_ha_state()
245+
return
246+
247+
target_min = target_range.get(ATTR_TARGET_MIN)
248+
target_max = target_range.get(ATTR_TARGET_MAX)
249+
250+
if target_min is None or target_max is None:
251+
self._attr_is_on = None
252+
self._attr_extra_state_attributes = {'current_value': value}
253+
self.async_write_ha_state()
254+
return
255+
256+
# is_on = True means there's a problem (out of range)
257+
in_range = target_min <= value <= target_max
258+
self._attr_is_on = not in_range
259+
260+
# calculate deviation from range
261+
deviation = 0.0
262+
if value < target_min:
263+
deviation = target_min - value
264+
elif value > target_max:
265+
deviation = value - target_max
266+
267+
self._attr_extra_state_attributes = {
268+
'current_value': value,
269+
ATTR_TARGET_MIN: target_min,
270+
ATTR_TARGET_MAX: target_max,
271+
'deviation': round(deviation, 2),
272+
}
273+
274+
self.async_write_ha_state()
275+
276+
@property
277+
def available(self) -> bool:
278+
"""Return if sensor is available."""
279+
return (
280+
self.coordinator.last_update_success
281+
and self.coordinator.data is not None
282+
and self.coordinator.data.json is not None
283+
)

0 commit comments

Comments
 (0)