UX-13 P0 review: keep unread keys, back off a failed entity read
- uimodel: editing a listed inverter keeps the stored flat keys, and a region of "own" keeps a stored price_taxes_profile (both were dropped). - haentities: a failed read also waits CACHE_S, so polls while Home Assistant is down do not queue one 10 s read each. - ui_text: the Flanders preset names its DSO, and the help says Wallonia and Brussels have no figures yet. REGION_PRESETS cites the card. - tests: decode of a reversed solar row, both kept keys, the backoff. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -51,13 +51,17 @@ class EntityCache:
|
|||||||
self.clock = clock
|
self.clock = clock
|
||||||
self.rows: dict | None = None
|
self.rows: dict | None = None
|
||||||
self.read_at: float | None = None
|
self.read_at: float | None = None
|
||||||
|
self.tried_at: float | None = None
|
||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
async def get(self) -> tuple[dict | None, float | None]:
|
async def get(self) -> tuple[dict | None, float | None]:
|
||||||
"""(rows by entity id, age in s), or (None, None) if never read."""
|
"""(rows by entity id, age in s), or (None, None) if never read."""
|
||||||
async with self._lock: # one HA read however many pickers ask
|
async with self._lock: # one HA read however many pickers ask
|
||||||
now = self.clock()
|
now = self.clock()
|
||||||
if self.read_at is None or now - self.read_at >= CACHE_S:
|
# A failed read also waits CACHE_S, so polls while HA is down do
|
||||||
|
# not queue one 10 s read each behind this lock.
|
||||||
|
if self.tried_at is None or now - self.tried_at >= CACHE_S:
|
||||||
|
self.tried_at = now
|
||||||
states = await self.hass.states()
|
states = await self.hass.states()
|
||||||
if states is not None:
|
if states is not None:
|
||||||
rows = (row(s) for s in states)
|
rows = (row(s) for s in states)
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
"fields": {
|
"fields": {
|
||||||
"solar": {"name": "Solar inverters", "description": "Each solar inverter's power sensor, which the add-on records but does not use for control."},
|
"solar": {"name": "Solar inverters", "description": "Each solar inverter's power sensor, which the add-on records but does not use for control."},
|
||||||
"contract": {"name": "Your contract", "description": "How your supplier prices each kWh you take from the grid."},
|
"contract": {"name": "Your contract", "description": "How your supplier prices each kWh you take from the grid."},
|
||||||
"region": {"name": "Region", "description": "Fills in the grid costs, levies and VAT for the year shown, so check them on your bill."},
|
"region": {"name": "Region", "description": "Fills in grid costs, levies and VAT for the year shown, but not yet for Wallonia or Brussels, so check your bill."},
|
||||||
"fallback": {"name": "Price when the feed is down", "description": "Used when the price source does not answer."},
|
"fallback": {"name": "Price when the feed is down", "description": "Used when the price source does not answer."},
|
||||||
"fixed_price": {"name": "Price per kWh, all in", "description": "Energy, supplier costs, grid costs, levies and VAT together."},
|
"fixed_price": {"name": "Price per kWh, all in", "description": "Energy, supplier costs, grid costs, levies and VAT together."},
|
||||||
"day_price": {"name": "Day price, weekdays 07:00 to 22:00", "description": "All in, as on your bill."},
|
"day_price": {"name": "Day price, weekdays 07:00 to 22:00", "description": "All in, as on your bill."},
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
"choices": {
|
"choices": {
|
||||||
"contract": {"none": "No prices", "fixed": "Fixed price", "daynight": "Day and night", "dynamic": "Dynamic, hourly", "custom": "Other time windows"},
|
"contract": {"none": "No prices", "fixed": "Fixed price", "daynight": "Day and night", "dynamic": "Dynamic, hourly", "custom": "Other time windows"},
|
||||||
"fallback": {"none": "None", "fixed": "One price", "daynight": "Day and night", "custom": "Other time windows"},
|
"fallback": {"none": "None", "fixed": "One price", "daynight": "Day and night", "custom": "Other time windows"},
|
||||||
"region": {"flanders": "Flanders", "own": "I enter my own figure"}
|
"region": {"flanders": "Flanders, Fluvius Antwerpen", "own": "I enter my own figure"}
|
||||||
},
|
},
|
||||||
"applies": {
|
"applies": {
|
||||||
"restart": {"name": "Applies after a restart"},
|
"restart": {"name": "Applies after a restart"},
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ class DeriveError(ValueError):
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# Region presets for "Grid costs, levies and VAT". Year-stamped, updated each
|
# Region presets for "Grid costs, levies and VAT". Year-stamped, updated each
|
||||||
# January (owner, 2026-09-27). Only presets with a sourced figure are listed:
|
# January (owner, 2026-09-27). Only presets with a sourced figure are listed:
|
||||||
# Flanders is app/price.py's Fluvius Antwerpen sum (TARIFF-18). Wallonia and
|
# Flanders is app/price.py's Fluvius Antwerpen sum (TARIFF-18: Eneco
|
||||||
|
# residential card, Flanders, September 2026, VAT included). Wallonia and
|
||||||
# Brussels have a day and a night band (config.yaml) and join with sourced
|
# Brussels have a day and a night band (config.yaml) and join with sourced
|
||||||
# figures; until then those sites choose "own".
|
# figures; until then those sites choose "own".
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -93,9 +94,10 @@ def _roster_encode(m, keep):
|
|||||||
return out
|
return out
|
||||||
out["inverters"] = [{k: v for k, v in c.items() if v is not None}
|
out["inverters"] = [{k: v for k, v in c.items() if v is not None}
|
||||||
for c in cards]
|
for c in cards]
|
||||||
|
# The flat keys beside a list are not read: they stay as stored.
|
||||||
|
out.update({k: copy.deepcopy(keep[k]) for k in _ROSTER_FLAT if k in keep})
|
||||||
if shape == "flat":
|
if shape == "flat":
|
||||||
# CFG-04 rule 9e: a flat power request entity beside a list is refused.
|
# CFG-04 rule 9e: a flat power request entity beside a list is refused.
|
||||||
# The other flat keys stay as stored and are not read.
|
|
||||||
out["setpoint_entity"] = ""
|
out["setpoint_entity"] = ""
|
||||||
return out
|
return out
|
||||||
|
|
||||||
@@ -252,7 +254,10 @@ def _region_decode(o):
|
|||||||
def _region_encode(m, keep):
|
def _region_encode(m, keep):
|
||||||
region = m.get("ui.region")
|
region = m.get("ui.region")
|
||||||
if region == OWN:
|
if region == OWN:
|
||||||
return {"price_taxes_eur_kwh": m.get("price_taxes_eur_kwh")}
|
out = {"price_taxes_eur_kwh": m.get("price_taxes_eur_kwh")}
|
||||||
|
if "price_taxes_profile" in keep: # "own" keeps the stored table
|
||||||
|
out["price_taxes_profile"] = keep["price_taxes_profile"]
|
||||||
|
return out
|
||||||
if region not in REGION_PRESETS:
|
if region not in REGION_PRESETS:
|
||||||
raise DeriveError(f"unknown region {region!r}")
|
raise DeriveError(f"unknown region {region!r}")
|
||||||
p = REGION_PRESETS[region]
|
p = REGION_PRESETS[region]
|
||||||
|
|||||||
@@ -161,6 +161,24 @@ check("Home Assistant down after a good read: the old list, marked old",
|
|||||||
and len(_down["entities"]) == 4 and _down["age_s"] >= 30)
|
and len(_down["entities"]) == 4 and _down["age_s"] >= 30)
|
||||||
check("...and peek() then gives nothing, so no warning rests on an old list",
|
check("...and peek() then gives nothing, so no warning rests on an old list",
|
||||||
_cache.peek() is None)
|
_cache.peek() is None)
|
||||||
|
|
||||||
|
|
||||||
|
async def down_windows():
|
||||||
|
"""One loop throughout: a contended asyncio.Lock binds to its loop."""
|
||||||
|
hass, clock = FakeHass(), Clock()
|
||||||
|
hass.answer = None
|
||||||
|
cache = E.EntityCache(hass, clock=clock)
|
||||||
|
counts = []
|
||||||
|
for step in (0, 1, 30):
|
||||||
|
clock.t += step
|
||||||
|
await asyncio.gather(*(cache.payload() for _ in range(5)))
|
||||||
|
counts.append(hass.reads)
|
||||||
|
return counts
|
||||||
|
|
||||||
|
|
||||||
|
check("five polls while Home Assistant is down cost one read, the polls just "
|
||||||
|
"after wait for the next window, and that window tries once",
|
||||||
|
asyncio.run(down_windows()) == [1, 1, 2])
|
||||||
_h2 = FakeHass()
|
_h2 = FakeHass()
|
||||||
_h2.answer = None
|
_h2.answer = None
|
||||||
_empty = asyncio.run(E.EntityCache(_h2).payload("power"))
|
_empty = asyncio.run(E.EntityCache(_h2).payload("power"))
|
||||||
|
|||||||
@@ -128,6 +128,9 @@ _listed = dict(SITE, setpoint_entity="", inverters=[
|
|||||||
_l1 = edit(_listed, inverters=[dict(_listed["inverters"][0], max_w=1800)])
|
_l1 = edit(_listed, inverters=[dict(_listed["inverters"][0], max_w=1800)])
|
||||||
check("a list of one stays a list, so the inverter keeps its name and stored "
|
check("a list of one stays a list, so the inverter keeps its name and stored "
|
||||||
"state", _l1["inverters"] == [dict(_listed["inverters"][0], max_w=1800)])
|
"state", _l1["inverters"] == [dict(_listed["inverters"][0], max_w=1800)])
|
||||||
|
check("editing a listed inverter keeps the unread flat keys as stored",
|
||||||
|
all(_l1[k] == _listed[k] for k in ("setpoint_entity", "soc_entity",
|
||||||
|
"batt_entity", "max_w")))
|
||||||
check("a name on the flat inverter is refused rather than dropped",
|
check("a name on the flat inverter is refused rather than dropped",
|
||||||
raises(lambda: edit(SITE, inverters=[dict(_card, name="garage")])))
|
raises(lambda: edit(SITE, inverters=[dict(_card, name="garage")])))
|
||||||
_raw = dict(SITE, inverters="garage")
|
_raw = dict(SITE, inverters="garage")
|
||||||
@@ -137,6 +140,10 @@ check("a roster no card can show is kept when another field is saved, and "
|
|||||||
and raises(lambda: edit(_raw, inverters=[_card])))
|
and raises(lambda: edit(_raw, inverters=[_card])))
|
||||||
|
|
||||||
print("\n3. solar rows")
|
print("\n3. solar rows")
|
||||||
|
check("a stored flip reads as a reversed row",
|
||||||
|
U.options_to_ui(dict(DEFAULTS, pv_entity="sensor.pv",
|
||||||
|
pv_invert_entities=["sensor.pv"]))["ui.solar"]
|
||||||
|
== [{"entity": "sensor.pv", "reversed": True}])
|
||||||
check("one row writes pv_entity",
|
check("one row writes pv_entity",
|
||||||
edit(DEFAULTS, ui__solar=[{"entity": "sensor.pv", "reversed": False}])
|
edit(DEFAULTS, ui__solar=[{"entity": "sensor.pv", "reversed": False}])
|
||||||
["pv_entity"] == "sensor.pv")
|
["pv_entity"] == "sensor.pv")
|
||||||
@@ -195,6 +202,11 @@ _own = dict(DEFAULTS, price_taxes_eur_kwh=0.06)
|
|||||||
check("a figure that is no preset reads as 'own', and choosing Flanders "
|
check("a figure that is no preset reads as 'own', and choosing Flanders "
|
||||||
"writes the preset", U.options_to_ui(_own)["ui.region"] == U.OWN
|
"writes the preset", U.options_to_ui(_own)["ui.region"] == U.OWN
|
||||||
and edit(_own, ui__region="flanders")["price_taxes_eur_kwh"] == 0.11736)
|
and edit(_own, ui__region="flanders")["price_taxes_eur_kwh"] == 0.11736)
|
||||||
|
_prof = dict(DEFAULTS, price_taxes_eur_kwh=0.12,
|
||||||
|
price_taxes_profile='{"day": 0.14, "night": 0.1}')
|
||||||
|
check("'own' keeps a stored levies table when the figure is edited",
|
||||||
|
edit(_prof, price_taxes_eur_kwh=0.13)["price_taxes_profile"]
|
||||||
|
== _prof["price_taxes_profile"])
|
||||||
check("'own' stores the typed figure",
|
check("'own' stores the typed figure",
|
||||||
edit(DEFAULTS, ui__region=U.OWN, price_taxes_eur_kwh=0.13)
|
edit(DEFAULTS, ui__region=U.OWN, price_taxes_eur_kwh=0.13)
|
||||||
["price_taxes_eur_kwh"] == 0.13)
|
["price_taxes_eur_kwh"] == 0.13)
|
||||||
|
|||||||
Reference in New Issue
Block a user