diff --git a/goodwe_controller/app/haentities.py b/goodwe_controller/app/haentities.py index d6f0784..695ef6f 100644 --- a/goodwe_controller/app/haentities.py +++ b/goodwe_controller/app/haentities.py @@ -51,13 +51,17 @@ class EntityCache: self.clock = clock self.rows: dict | None = None self.read_at: float | None = None + self.tried_at: float | None = None self._lock = asyncio.Lock() async def get(self) -> tuple[dict | None, float | None]: """(rows by entity id, age in s), or (None, None) if never read.""" async with self._lock: # one HA read however many pickers ask 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() if states is not None: rows = (row(s) for s in states) diff --git a/goodwe_controller/app/ui_text/en.json b/goodwe_controller/app/ui_text/en.json index d3244fb..dd39981 100644 --- a/goodwe_controller/app/ui_text/en.json +++ b/goodwe_controller/app/ui_text/en.json @@ -20,7 +20,7 @@ "fields": { "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."}, - "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."}, "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."}, @@ -29,7 +29,7 @@ "choices": { "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"}, - "region": {"flanders": "Flanders", "own": "I enter my own figure"} + "region": {"flanders": "Flanders, Fluvius Antwerpen", "own": "I enter my own figure"} }, "applies": { "restart": {"name": "Applies after a restart"}, diff --git a/goodwe_controller/app/uimodel.py b/goodwe_controller/app/uimodel.py index f5f9c5a..fddf36e 100644 --- a/goodwe_controller/app/uimodel.py +++ b/goodwe_controller/app/uimodel.py @@ -31,7 +31,8 @@ class DeriveError(ValueError): # --------------------------------------------------------------------------- # # 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: -# 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 # figures; until then those sites choose "own". # --------------------------------------------------------------------------- # @@ -93,9 +94,10 @@ def _roster_encode(m, keep): return out out["inverters"] = [{k: v for k, v in c.items() if v is not None} 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": # 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"] = "" return out @@ -252,7 +254,10 @@ def _region_decode(o): def _region_encode(m, keep): region = m.get("ui.region") 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: raise DeriveError(f"unknown region {region!r}") p = REGION_PRESETS[region] diff --git a/goodwe_controller/test_haentities.py b/goodwe_controller/test_haentities.py index a562b97..30ffb81 100644 --- a/goodwe_controller/test_haentities.py +++ b/goodwe_controller/test_haentities.py @@ -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) check("...and peek() then gives nothing, so no warning rests on an old list", _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.answer = None _empty = asyncio.run(E.EntityCache(_h2).payload("power")) diff --git a/goodwe_controller/test_uimodel.py b/goodwe_controller/test_uimodel.py index 1267d62..62702d0 100644 --- a/goodwe_controller/test_uimodel.py +++ b/goodwe_controller/test_uimodel.py @@ -128,6 +128,9 @@ _listed = dict(SITE, setpoint_entity="", inverters=[ _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 " "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", raises(lambda: edit(SITE, inverters=[dict(_card, name="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]))) 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", edit(DEFAULTS, ui__solar=[{"entity": "sensor.pv", "reversed": False}]) ["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 " "writes the preset", U.options_to_ui(_own)["ui.region"] == U.OWN 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", edit(DEFAULTS, ui__region=U.OWN, price_taxes_eur_kwh=0.13) ["price_taxes_eur_kwh"] == 0.13)