EV-03 round 4: a power entity that cannot be believed is unreachable
checks / unit (pull_request) Successful in 27m11s
infra-pin / fresh (pull_request) Successful in 2s

- 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 <noreply@anthropic.com>
This commit is contained in:
ev03-dev
2026-11-15 15:06:24 +01:00
co-authored by Claude
parent 918d44ab33
commit af7e96e5e5
7 changed files with 136 additions and 18 deletions
+9 -3
View File
@@ -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<x>.+)_" + 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)
+1 -1
View File
@@ -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"
+3
View File
@@ -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
+2 -1
View File
@@ -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.",
+2 -1
View File
@@ -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.",
+3 -3
View File
@@ -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"]}
+116 -9
View File
@@ -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")