From af7e96e5e5f5d72c19d989c033ac0fa07243db2e Mon Sep 17 00:00:00 2001 From: ev03-dev Date: Sun, 15 Nov 2026 15:06:24 +0100 Subject: [PATCH] EV-03 round 4: a power entity that cannot be believed is unreachable - every HA power unit is accepted and converted (mW, W, kW, MW), in the probe and in the reading - a power entity that answers in another unit, with no unit, or not as a number makes the reading unavailable: after 30 s it is "Charger not reachable" (amber row, WARNING, notification after 5 min), with a Why that names the cause (EN and NL) Co-Authored-By: Claude --- goodwe_controller/app/chargers.py | 12 ++- goodwe_controller/app/ctl_inputs.py | 2 +- goodwe_controller/app/homeview.py | 3 + goodwe_controller/app/ui_text/en.json | 3 +- goodwe_controller/app/ui_text/nl.json | 3 +- goodwe_controller/app/validate.py | 6 +- goodwe_controller/test_ev03.py | 125 ++++++++++++++++++++++++-- 7 files changed, 136 insertions(+), 18 deletions(-) diff --git a/goodwe_controller/app/chargers.py b/goodwe_controller/app/chargers.py index 2ec2299..45cd67c 100644 --- a/goodwe_controller/app/chargers.py +++ b/goodwe_controller/app/chargers.py @@ -82,12 +82,12 @@ ROLES = { #: a missing one of these refuses the charger (not the add-on). `power` is one #: of them: without it the car's power is unknown for good, House would show no #: reading for ever and the house profile would drop every sample. -#: The power entity must also read in W or kW (`POWER_UNITS`). +#: The power entity must also read in a Home Assistant power unit (`POWER_UNITS`). REQUIRED = ("current", "readback", "state", "power") _SETPOINT_RE = re.compile(r"^number\.(?P.+)_" + SETPOINT_SUFFIX + "$") _ROLE_ORDER = tuple(ROLES) -POWER_UNITS = ("W", "kW") +POWER_UNITS = ("mW", "W", "kW", "MW") # every HA power unit, converted def census_key(entity) -> str: @@ -155,7 +155,7 @@ def _amps(obj): return None -_W_PER = {"W": 1.0, "kW": 1000.0} +_W_PER = {"mW": 0.001, "W": 1.0, "kW": 1000.0, "MW": 1000000.0} def _watts(obj): @@ -224,6 +224,12 @@ def reading_from_states(states: dict) -> ChargerReading: why = ("login off" if not gone else "no answer from " + ", ".join(gone)) return ChargerReading(available=False, why=why) + # A power entity that answers but cannot be believed (a unit the add-on + # cannot convert, no unit, not a number) makes the reading UNAVAILABLE, so + # it goes down the visible "Charger not reachable" path instead of leaving + # the car's power unknown for good with nothing said. + if _watts(states.get("power")) is None: + return ChargerReading(available=False, why="power unit") letter = mode3_letter(txt("state")) disp = txt("display") cls = display_class(disp) diff --git a/goodwe_controller/app/ctl_inputs.py b/goodwe_controller/app/ctl_inputs.py index 5d303c5..a8677ff 100644 --- a/goodwe_controller/app/ctl_inputs.py +++ b/goodwe_controller/app/ctl_inputs.py @@ -161,7 +161,7 @@ class InputsMixin: and not pr.required_missing: why = "unit" _LOG.warning("The car charger is not set up, because its power " - "does not read in W or kW: CHARGER_POWER_UNIT.") + "has no power unit: CHARGER_POWER_UNIT.") _LOG.debug("The charger power entity reads in %r.", pr.power_unit) elif not pr.ok: why = "not_found" diff --git a/goodwe_controller/app/homeview.py b/goodwe_controller/app/homeview.py index 0012280..4e93ca0 100644 --- a/goodwe_controller/app/homeview.py +++ b/goodwe_controller/app/homeview.py @@ -1261,6 +1261,9 @@ def _car_big(key, c, r, good): def _car_why(key, c, good): brand = (getattr(c.charger, "brand", "") or "Alfen") if key == "unreach": + r = c.car.reading + if r is not None and r.why == "power unit": + return _t("car.why.unreach_unit") a = None if good is None else good.setpoint_a if a is None and good is not None: a = good.allowed_a diff --git a/goodwe_controller/app/ui_text/en.json b/goodwe_controller/app/ui_text/en.json index e710f9a..1b245b1 100644 --- a/goodwe_controller/app/ui_text/en.json +++ b/goodwe_controller/app/ui_text/en.json @@ -312,9 +312,10 @@ "none": "No car is connected.", "full": "The car stopped charging by itself.", "unreach_a": "The charger keeps its last setting, {a} A. Check the {brand} integration in Home Assistant.", + "unreach_unit": "The charger's power is in a unit the add-on cannot use. Check it in Home Assistant.", "unreach": "The add-on cannot read the charger. Check the {brand} integration in Home Assistant.", "not_found": "The add-on cannot find the charger. Check the current setting entity in Settings.", - "unit": "The charger power does not read in W or kW. Check the charger in Settings.", + "unit": "The charger power has no power unit. Check the charger in Settings.", "limit": "The charger limit is above what the charger allows. Lower it in Settings.", "name": "The current setting entity does not fit the charger. Check it in Settings.", "fault": "The charger reports a fault. Check it at the charger itself.", diff --git a/goodwe_controller/app/ui_text/nl.json b/goodwe_controller/app/ui_text/nl.json index ac537ba..188b135 100644 --- a/goodwe_controller/app/ui_text/nl.json +++ b/goodwe_controller/app/ui_text/nl.json @@ -266,9 +266,10 @@ "none": "Er is geen auto aangesloten.", "full": "De auto is zelf gestopt met laden.", "unreach_a": "De laadpaal houdt haar laatste instelling, {a} A. Controleer de {brand}-integratie in Home Assistant.", + "unreach_unit": "De laadpaal meldt vermogen in een onbruikbare eenheid. Controleer de eenheid in Home Assistant.", "unreach": "De add-on kan de laadpaal niet lezen. Controleer de {brand}-integratie in Home Assistant.", "not_found": "De add-on vindt de laadpaal niet. Controleer de entiteit van de laadstroom bij Instellingen.", - "unit": "Het vermogen van de laadpaal is niet in W of kW. Controleer de laadpaal bij Instellingen.", + "unit": "Het vermogen van de laadpaal heeft geen vermogenseenheid. Controleer de laadpaal bij Instellingen.", "limit": "De laadpaalgrens is hoger dan de laadpaal toestaat. Verlaag ze bij Instellingen.", "name": "De entiteit van de laadstroom past niet bij de laadpaal. Controleer ze bij Instellingen.", "fault": "De laadpaal meldt een storing. Controleer ze aan de laadpaal zelf.", diff --git a/goodwe_controller/app/validate.py b/goodwe_controller/app/validate.py index 4082a08..58833ac 100644 --- a/goodwe_controller/app/validate.py +++ b/goodwe_controller/app/validate.py @@ -2049,10 +2049,10 @@ def _charger_notes(notes, opts: dict, entities: dict) -> None: "ook kan gebruiken.") if look.get("power_unit") is not None: _advise(notes, "warn", key, - "The charger's power does not read in W or kW, so the " + "The charger's power does not read in a power unit, so the " "charger is not used.", - nl="Het vermogen van de laadpaal is niet in W of kW, dus ze " - "wordt niet gebruikt.") + nl="Het vermogen van de laadpaal heeft geen vermogenseenheid, dus " + "ze wordt niet gebruikt.") lim = look["limit_a"] if look["ok"]: have = {r for r, _e in look["found"]} diff --git a/goodwe_controller/test_ev03.py b/goodwe_controller/test_ev03.py index 56ef10a..da6cfa7 100644 --- a/goodwe_controller/test_ev03.py +++ b/goodwe_controller/test_ev03.py @@ -259,10 +259,12 @@ check("charging: C, 6.9 kW, 3 phases of 10 A, read-back 10 A, station 16 A", rk = CH.reading_from_states(dict(d, power=st("x", "6.9", "kW"))) check("a power in kW is believed in kW (6 900 W)", abs(rk.power_w - 6900) < 1) rn = CH.reading_from_states(dict(d, power=st("x", "6900"))) -check("a power with no unit is NOT believed (absent, never a guess)", - rn.power_w is None and rn.available) +check("a power with no unit is NOT believed: the reading is unavailable " + "(cause 'power unit'), never a guess", + rn.power_w is None and not rn.available and rn.why == "power unit") rn = CH.reading_from_states(dict(d, power=st("x", "6900", "V"))) -check("a power in the wrong unit is absent", rn.power_w is None) +check("a power in the wrong unit is not believed either (unavailable)", + rn.power_w is None and not rn.available and rn.why == "power unit") re_ = CH.reading_from_states(dict(d, energy=st("x", "4004850", "Wh"))) check("energy in Wh is believed in Wh (4004.85 kWh)", abs(re_.energy_kwh - 4004.85) < 1e-6) @@ -948,6 +950,7 @@ check("an absent number goes out as HA's own 'None' payload, and the PV rule " print("\n10. ROUND 2: A MISSING POWER READING IS AN UNKNOWN TERM, NEVER AN ERROR") # --------------------------------------------------------------------------- # import traceback # noqa: E402 +from named_absence import nth # noqa: E402 def bad_power(kind): @@ -982,6 +985,8 @@ def bad_power(kind): asyncio.run(rin(c_, 1008.0)) # 1 s after the good one res["fresh"] = c_.car.fresh(CLOCK[0]) res["avail"] = c_.car.reading.power_w is None + res["reachable"] = c_.car.reading.available + res["why"] = c_.car.reading.why res["term"] = c_.car_term() res["now"] = c_.status_payload()["now"] res["card"] = c_.status_payload()["car"] @@ -995,9 +1000,14 @@ def bad_power(kind): for kind in ("unavailable", "wrong unit"): r_ = bad_power(kind) - check(f"{kind}: the reading carries no power and is still inside stale_s " - f"(the case that raised)", r_["err"] is None - and r_.get("fresh") is True and r_.get("avail") is True) + check(f"{kind}: EXPECTED BEHAVIOUR (round 4): the reading is UNAVAILABLE " + f"(not 'available without power'), so it goes down the unreachable " + f"path; its cause is {r_.get('why')!r}, and it is still inside " + f"stale_s on this first read", r_["err"] is None + and r_.get("reachable") is False and r_.get("fresh") is True + and r_.get("avail") is True + and r_.get("why") == ("power unit" if kind == "wrong unit" + else "no answer from power")) check(f"{kind}: car_term, /status, the trade cycle and the MQTT values do " f"not raise ({r_['err']})", r_["err"] is None) check(f"{kind}: the term is UNKNOWN (never 0 W): House shows no reading and " @@ -1074,7 +1084,7 @@ check("the settings note for the missing power entity says the charger is not " nts = validate.config_notes(CAR, entities=dict(rows_all, **{PW: dict( rows_all[PW], unit="V")})) check("...and so does a wrong unit", - any(n.tier == "warn" and "W or kW" in n.text for n in nts)) + any(n.tier == "warn" and "power unit" in n.text for n in nts)) class Hide(Site): @@ -1129,8 +1139,8 @@ for why, factory, want in ( and [x["id"] for x in c_x.checks() if x["id"].startswith("charger")] == ["charger_setup"]) check("the unit refusal's Why is plain and in both languages", - "does not read in W or kW" in p_x["car"]["why"] - and "niet in W of kW" in c_x.status_payload(lang="nl")["car"]["why"]) + "no power unit" in p_x["car"]["why"] + and "geen vermogenseenheid" in c_x.status_payload(lang="nl")["car"]["why"]) # unavailable at start: NOT refused, the unreachable path c_u, p_u, _s = world(lambda s_: Hide(s_, grid=7500.0, pv=500.0), CAR, n=3) sim_u = SimCharger() @@ -1144,6 +1154,103 @@ check("a charger that is unavailable from the first read is not refused; it " and c_u2.charger.configured() and c_u2.status_payload()["car"]["state"] == "unreach") +# --------------------------------------------------------------------------- # +print("\n12. ROUND 4: A POWER ENTITY THAT CANNOT BE BELIEVED IS UNREACHABLE") +# --------------------------------------------------------------------------- # +class PowerSite(Site): + """The site, with the charger's power entity overridden: `pw` is + (state, unit) or None for the model's own.""" + + pw = None + + def _all(self): + out = super()._all() + if self.pw is not None and PW in out: + st_, un_ = self.pw + a = {"unit_of_measurement": un_} if un_ else {} + out[PW] = dict(out[PW], state=st_, attributes=a) + return out + + +def story4(segments): + """Run (pw override, cycles) segments on one controller. -> what was + visible at the END of each segment, and the controller.""" + sim_ = SimCharger() + sim_.plug_in() + sim_.set_phases(3) + sim_.set_current(10) + site_ = PowerSite(sim_, grid=7500.0, batt=0.0, pv=500.0) + c_ = build(dict(CAR, pv_forecast_entity="sensor.fc", + battery_modes_enabled=True), site_) + c_.house_profile = _HP() + seen, t_ = [], [3000.0] + + async def go(): + for pw_, n_ in segments: + site_.pw = pw_ + del samples[:] + for _ in range(n_): + sim_.tick(1) + await rin(c_, t_[0]) + await c_.cycle() + t_[0] += 1 + p_ = c_.status_payload() + seen.append({ + "state": p_["car"]["state"], "why": p_["car"]["why"], + "house": p_["now"]["house_w"], "sample": nth(samples, -1), + "rows": [x["id"] for x in c_.checks() + if x["id"].startswith("charger")], + "car_w": c_.car.car_w(c_.car_now()), + "notes": len([x for x in site_.calls if (x[2] or {}).get( + "notification_id") == "goodwe_charger_unreachable"])}) + asyncio.run(go()) + return seen, c_ + + +UNAV, V6900, NOUNIT = ("unavailable", None), ("6900", "V"), ("6900", None) +for label, bad in (("in V", V6900), ("with no unit", NOUNIT)): + seen, c4 = story4([(UNAV, 3), (bad, 35), (None, 5)]) + check(f"unavailable at start-up (found, not refused), then back {label}: " + f"after 30 s the visible state is 'Charger not reachable', with the " + f"amber row, House 'No reading' and the profile sample None", + c4.charger_refused is None and nth(seen, 1)["state"] == "unreach" + and nth(seen, 1)["rows"] == ["charger"] and nth(seen, 1)["house"] is None + and nth(seen, 1)["sample"] is None and nth(seen, 1)["car_w"] is None) + check(f"...and the Why names the cause (a unit the add-on cannot use)", + "unit the add-on cannot use" in nth(seen, 1)["why"]) + check(f"...and it recovers when a good unit returns (Charging, House and " + f"the profile back)", nth(seen, 2)["state"] == "charging" + and nth(seen, 2)["rows"] == [] and nth(seen, 2)["house"] is not None + and nth(seen, 2)["sample"] is not None) +seen, c4 = story4([(None, 10), (V6900, 40), (None, 5)]) +check("unit changed W -> V at run time: before, Charging; after 30 s, " + "'Charger not reachable', the row, House and profile unknown", + nth(seen, 0)["state"] == "charging" and nth(seen, 1)["state"] == "unreach" + and nth(seen, 1)["rows"] == ["charger"] and nth(seen, 1)["house"] is None + and nth(seen, 1)["sample"] is None and nth(seen, 2)["state"] == "charging") +_c_nl = story4([(None, 10), (V6900, 40)])[1] +check("...and the Dutch Why names it too", "onbruikbare eenheid" + in _c_nl.status_payload(lang="nl")["car"]["why"]) +seen, c4 = story4([(None, 10), (V6900, 320)]) +check("...and after 5 minutes there is exactly one notification", + nth(seen, 1)["notes"] == 1) +seen, c4 = story4([(None, 6), (("6900000", "mW"), 10)]) +check("W -> mW is accepted, converted exactly (6 900 000 mW is 6 900 W): " + "still Charging, no row, House excludes the car, the profile too", + nth(seen, 1)["state"] == "charging" and nth(seen, 1)["rows"] == [] + and abs(nth(seen, 1)["car_w"] - 6900.0) < 1e-6 and nth(seen, 1)["house"] is not None + and nth(seen, 1)["sample"] is not None) +seen, c4 = story4([(("0.0069", "MW"), 10)]) +check("MW is accepted (0.0069 MW is 6 900 W)", + abs(nth(seen, 0)["car_w"] - 6900.0) < 1e-6 and nth(seen, 0)["state"] == "charging") +seen, c4 = story4([(("6.9", "kW"), 10)]) +check("kW still is (6.9 kW is 6 900 W)", abs(nth(seen, 0)["car_w"] - 6900.0) < 1e-6) +check("the start-up probe accepts every HA power unit and refuses V", + all(validate.charger_probe_for(SET, dict(rows_all, **{PW: dict( + rows_all[PW], unit=u)}))["ok"] for u in ("mW", "W", "kW", "MW")) + and not validate.charger_probe_for(SET, dict(rows_all, **{PW: dict( + rows_all[PW], unit="V")}))["ok"]) + print() if fails: print(f"{len(fails)} FAILED")