Skip to content

Commit b4f9db1

Browse files
committed
Fix measurement not being provate to each account, add diagnostics
1 parent 91ac388 commit b4f9db1

File tree

4 files changed

+182
-129
lines changed

4 files changed

+182
-129
lines changed

custom_components/poollab/__init__.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,12 @@ def __init__(self, hass: HomeAssistant, api: PoolLabApi) -> None:
3333
_LOGGER,
3434
name="PoolLab API",
3535
update_interval=timedelta(seconds=30),
36-
update_method=self._async_update_data,
36+
update_method=self._async_update_poollab,
3737
)
3838
self.api = api
39+
self.entities = []
3940

40-
async def _async_update_data(self):
41+
async def _async_update_poollab(self):
4142
"""Fetch data from API endpoint."""
4243
try:
4344
async with asyncio.timeout(10):
@@ -49,6 +50,20 @@ async def _async_update_data(self):
4950
except Exception as err:
5051
raise UpdateFailed(f"Unknown error communicating with API: {err}") from err
5152

53+
def add_entity_ref(self, entity) -> None:
54+
"""Add an entity reference to the coordinator."""
55+
if entity not in self.entities:
56+
self.entities.append(entity)
57+
58+
def get_diag(self) -> dict[str, str]:
59+
"""Convert the coordinator data to a dictionary."""
60+
res = {
61+
k: v
62+
for k, v in self.__dict__.items()
63+
if not k.startswith("_") and not callable(v)
64+
}
65+
return res
66+
5267

5368
async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
5469
"""Set up this integration using UI."""
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
"""Diagnostics support for Nordpool Planner."""
2+
3+
from __future__ import annotations
4+
5+
# import json
6+
import logging
7+
from typing import Any
8+
9+
from homeassistant.components.diagnostics import async_redact_data
10+
from homeassistant.config_entries import ConfigEntry
11+
from homeassistant.const import CONF_API_KEY
12+
from homeassistant.core import HomeAssistant
13+
14+
from .const import DOMAIN
15+
16+
_LOGGER = logging.getLogger(__name__)
17+
18+
TO_REDACT = [
19+
CONF_API_KEY,
20+
"unique_id",
21+
]
22+
23+
24+
async def async_get_config_entry_diagnostics(
25+
hass: HomeAssistant, config_entry: ConfigEntry
26+
) -> dict[str, Any]:
27+
"""Return diagnostics for a config entry."""
28+
return async_redact_data(
29+
{
30+
"entry_data": config_entry.data,
31+
"coordinator": hass.data[DOMAIN][config_entry.entry_id].get_diag(),
32+
},
33+
TO_REDACT,
34+
)

custom_components/poollab/poollab.py

Lines changed: 121 additions & 126 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
"""PoolLab API handler."""
22

3+
from dataclasses import dataclass, field, fields
34
from datetime import datetime
45
from typing import Any
56

7+
API_ENDPOINT = "https://backend.labcom.cloud/graphql"
8+
69
from gql import Client, gql
710
from gql.transport.aiohttp import AIOHTTPTransport
811

9-
API_ENDPOINT = "https://backend.labcom.cloud/graphql"
10-
1112
# Measurement ranges according to https://poollab.org/static/manuals/poollab_manual_gb-fr-e-d-i.pdf
1213
MEAS_RANGES_BY_SCENARIO = {
1314
# Active oxygene 0-30
@@ -38,10 +39,11 @@
3839
}
3940

4041

41-
class Measurement(object):
42-
"""Data class for decoded water measurement."""
42+
@dataclass
43+
class Measurement:
44+
"""Data class for PoolLab measurements."""
4345

44-
id: int = None
46+
id: int | None = None
4547
scenario: str = ""
4648
parameter: str = ""
4749
parameter_id: str = ""
@@ -54,50 +56,43 @@ class Measurement(object):
5456
ideal_low: str = ""
5557
ideal_high: str = ""
5658
ideal_status: str = ""
57-
timestamp: datetime = None
59+
timestamp: datetime | None = None
60+
interpreted_value: float | None = field(init=False, repr=False, default=None)
61+
interpreted_oor: bool = field(init=False, repr=False, default=False)
5862

59-
def __init__(self, data: dict[str, Any]) -> None:
60-
"""Init the measurement object."""
61-
for key, value in data.items():
62-
if "timestamp" in key:
63-
setattr(self, key, datetime.fromtimestamp(value))
64-
else:
65-
setattr(self, key, value)
63+
def __post_init__(self):
64+
"""Post-initialization processing."""
65+
if isinstance(self.timestamp, (int, float)):
66+
self.timestamp = datetime.fromtimestamp(self.timestamp)
6667

67-
self.interpreted_value = None
68-
self.interpreted_oor = False
6968
if self.value and self.scenario in MEAS_RANGES_BY_SCENARIO:
7069
try:
71-
value = float(self.value)
72-
range_min = MEAS_RANGES_BY_SCENARIO[self.scenario][0]
73-
range_max = MEAS_RANGES_BY_SCENARIO[self.scenario][1]
74-
if value < range_min:
75-
self.interpreted_value = float(range_min)
76-
self.interpreted_oor = True
77-
elif value > range_max:
78-
self.interpreted_value = float(range_max)
79-
self.interpreted_oor = True
80-
else:
81-
self.interpreted_value = value
82-
except: # noqa: E722
70+
val = float(self.value)
71+
min_val, max_val = MEAS_RANGES_BY_SCENARIO[self.scenario]
72+
self.interpreted_value = max(min(val, max_val), min_val)
73+
self.interpreted_oor = not (min_val <= val <= max_val)
74+
except ValueError:
8375
pass
8476

8577
@staticmethod
8678
def get_schema(indent: str) -> str:
87-
"""Return the schema for the measurement object."""
88-
schema = ""
89-
for attribute in Measurement.__dict__:
90-
if attribute[:2] != "__":
91-
value = getattr(Measurement, attribute)
92-
if not callable(value):
93-
schema += indent + str(attribute) + "\n"
94-
return schema
79+
"""Return the schema for the Measurement class."""
80+
return "".join(
81+
f"{indent}{f.name}\n"
82+
for f in fields(Measurement)
83+
if not f.name.startswith("interpreted")
84+
)
9585

86+
def as_dict(self) -> dict[str, Any]:
87+
"""Return the Measurement as a dictionary."""
88+
return self.__dict__.copy()
9689

97-
class Account(object):
98-
"""Data class for decoded account data."""
9990

100-
id: int = None
91+
@dataclass
92+
class Account:
93+
"""Data class for PoolLab accounts."""
94+
95+
id: int | None = None
10196
forename: str = ""
10297
surname: str = ""
10398
street: str = ""
@@ -114,103 +109,95 @@ class Account(object):
114109
volume_unit: str = ""
115110
pooltext: str = ""
116111
gps: str = ""
117-
Measurements: list[Measurement] = []
118-
119-
def __init__(self, data: dict[str, Any]) -> None:
120-
"""Init the account object."""
121-
for key, value in data.items():
122-
if key == "Measurements":
123-
for m in data["Measurements"]:
124-
self.Measurements.append(Measurement(m))
125-
else:
126-
setattr(self, key, value)
112+
Measurements: list[Measurement] = field(default_factory=list)
113+
114+
def __post_init__(self):
115+
"""Post-initialization processing."""
116+
self.Measurements = [
117+
m if isinstance(m, Measurement) else Measurement(**m)
118+
for m in self.Measurements
119+
]
127120

128121
@property
129122
def full_name(self) -> str:
130-
"""Compiled full name of account."""
131-
_full_name = ""
132-
if self.forename:
133-
_full_name += self.forename
134-
if self.surname:
135-
if _full_name:
136-
_full_name += " "
137-
_full_name += self.surname
138-
return _full_name
123+
"""Return the full name of the account holder."""
124+
return f"{self.forename} {self.surname}".strip()
139125

140126
@staticmethod
141-
def get_schema(indent: str) -> str:
142-
"""Return the schema for the account object."""
127+
def get_schema(indent: str = "") -> str:
128+
"""Return the schema for the Account class."""
143129
schema = ""
144-
for attribute in Account.__dict__:
145-
if attribute[:2] != "__":
146-
value = getattr(Account, attribute)
147-
if not callable(value):
148-
if attribute == "Measurements":
149-
schema += indent + "Measurements {\n"
150-
schema += Measurement.get_schema(indent + " ")
151-
schema += indent + "}\n"
152-
elif attribute == "full_name":
153-
pass
154-
else:
155-
schema += indent + str(attribute) + "\n"
130+
for f in fields(Account):
131+
if f.name == "Measurements":
132+
schema += f"{indent}Measurements {{\n"
133+
schema += Measurement.get_schema(indent + " ")
134+
schema += f"{indent}}}\n"
135+
else:
136+
schema += f"{indent}{f.name}\n"
156137
return schema
157138

139+
def as_dict(self) -> dict[str, Any]:
140+
"""Return the Account as a dictionary."""
141+
return self.__dict__.copy()
142+
158143

159-
class WaterTreatmentProduct(object):
160-
"""Data class for decoded water treatment products."""
144+
@dataclass
145+
class WaterTreatmentProduct:
146+
"""Data class for PoolLab water treatment products."""
161147

162148
id: int = None
163149
name: str = ""
164150
effect: str = ""
165151
phrase: str = ""
166152

167-
def __init__(self, data: dict[str, Any]) -> None:
168-
"""Init the water treatment product object."""
169-
170-
for key, value in data.items():
171-
setattr(self, key, value)
153+
def __post_init__(self):
154+
"""Post-initialization processing."""
155+
pass # Nothing special for now, can keep or drop
172156

173157
@staticmethod
174158
def get_schema(indent: str) -> str:
175-
"""Return the schema for the water treatment product object."""
176-
schema = ""
177-
for attribute in WaterTreatmentProduct.__dict__:
178-
if attribute[:2] != "__":
179-
value = getattr(WaterTreatmentProduct, attribute)
180-
if not callable(value):
181-
schema += indent + str(attribute) + "\n"
182-
return schema
159+
"""Return the schema for the WaterTreatmentProduct class."""
160+
return "".join(f"{indent}{f.name}\n" for f in fields(WaterTreatmentProduct))
161+
162+
def as_dict(self) -> dict[str, Any]:
163+
"""Return the WaterTreatmentProduct as a dictionary."""
164+
return self.__dict__.copy()
183165

184166

167+
@dataclass
185168
class CloudAccount:
186-
"""Master class for PoolLab data."""
169+
"""Data class for PoolLab cloud account."""
187170

188171
id: int = None
189172
email: str = ""
190-
last_change_time: datetime = None
191-
last_wtp_change: datetime = None
192-
Accounts: list[Account] = []
193-
WaterTreatmentProducts: list[WaterTreatmentProduct] = []
194-
195-
def __init__(self, data: dict[str, Any]) -> None:
196-
"""Init the clound account object."""
197-
198-
if data := data.get("CloudAccount"):
199-
# data = data["CloudAccount"]
200-
for key, value in data.items():
201-
if key == "Accounts":
202-
for a in data["Accounts"]:
203-
self.Accounts.append(Account(a))
204-
elif key == "WaterTreatmentProducts":
205-
for w in data["WaterTreatmentProducts"]:
206-
self.WaterTreatmentProducts.append(WaterTreatmentProduct(w))
207-
elif "last" in key:
208-
setattr(self, key, datetime.fromtimestamp(value))
209-
else:
210-
setattr(self, key, value)
211-
212-
def get_measurement(self, account_id: int, meas_param: str):
213-
"""Get a measurement."""
173+
last_change_time: datetime | None = None
174+
last_wtp_change: datetime | None = None
175+
Accounts: list[Account] = field(default_factory=list)
176+
WaterTreatmentProducts: list[WaterTreatmentProduct] = field(default_factory=list)
177+
178+
def __init__(self, data: dict[str, Any]):
179+
"""Initialize the CloudAccount from a dictionary."""
180+
if cloud_data := data.get("CloudAccount"):
181+
self.id = cloud_data.get("id")
182+
self.email = cloud_data.get("email")
183+
self.last_change_time = (
184+
datetime.fromtimestamp(cloud_data["last_change_time"])
185+
if "last_change_time" in cloud_data
186+
else None
187+
)
188+
self.last_wtp_change = (
189+
datetime.fromtimestamp(cloud_data["last_wtp_change"])
190+
if "last_wtp_change" in cloud_data
191+
else None
192+
)
193+
self.Accounts = [Account(**a) for a in cloud_data.get("Accounts", [])]
194+
self.WaterTreatmentProducts = [
195+
WaterTreatmentProduct(**w)
196+
for w in cloud_data.get("WaterTreatmentProducts", [])
197+
]
198+
199+
def get_measurement(self, account_id: int, meas_param: str) -> Measurement:
200+
"""Get the latest measurement for a given account and parameter."""
214201
account = next(x for x in self.Accounts if x.id == account_id)
215202
sorted_meas = sorted(
216203
account.Measurements, key=lambda x: x.timestamp, reverse=True
@@ -219,24 +206,32 @@ def get_measurement(self, account_id: int, meas_param: str):
219206

220207
@staticmethod
221208
def get_schema(indent: str) -> str:
222-
"""Return the schema for the cloud account object."""
209+
"""Return the schema for the CloudAccount class."""
223210
schema = ""
224-
for attribute in CloudAccount.__dict__:
225-
if attribute[:2] != "__":
226-
value = getattr(CloudAccount, attribute)
227-
if not callable(value):
228-
if attribute == "Accounts":
229-
schema += indent + "Accounts {\n"
230-
schema += Account.get_schema(indent + " ")
231-
schema += indent + "}\n"
232-
elif attribute == "WaterTreatmentProducts":
233-
schema += indent + "WaterTreatmentProducts {\n"
234-
schema += WaterTreatmentProduct.get_schema(indent + " ")
235-
schema += indent + "}\n"
236-
else:
237-
schema += indent + str(attribute) + "\n"
211+
for attr in [
212+
"id",
213+
"email",
214+
"last_change_time",
215+
"last_wtp_change",
216+
"Accounts",
217+
"WaterTreatmentProducts",
218+
]:
219+
if attr == "Accounts":
220+
schema += f"{indent}Accounts {{\n"
221+
schema += Account.get_schema(indent + " ")
222+
schema += f"{indent}}}\n"
223+
elif attr == "WaterTreatmentProducts":
224+
schema += f"{indent}WaterTreatmentProducts {{\n"
225+
schema += WaterTreatmentProduct.get_schema(indent + " ")
226+
schema += f"{indent}}}\n"
227+
else:
228+
schema += f"{indent}{attr}\n"
238229
return schema
239230

231+
def as_dict(self) -> dict[str, Any]:
232+
"""Return the CloudAccount as a dictionary."""
233+
return self.__dict__.copy()
234+
240235

241236
class PoolLabApi:
242237
"""Public API class for PoolLab."""

0 commit comments

Comments
 (0)