EV-03 round 3: the power entity is required and reads in W or kW
checks / unit (pull_request) Successful in 27m42s
infra-pin / fresh (pull_request) Successful in 1s

A charger with no usable power entity is refused as "not set up", like a
missing setpoint, read-back or state entity, so House and the house profile
behave as with no charger. An entity that is merely unavailable at probe time
is still the unreachable path. NL: help text "met rust", chip "Laadpaalstoring".

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
ev03-dev
2026-11-15 13:35:09 +01:00
co-authored by Claude
parent 678ea13895
commit 918d44ab33
8 changed files with 166 additions and 20 deletions
+22 -5
View File
@@ -79,11 +79,15 @@ ROLES = {
"error": ("sensor", "display_error_number_socket_1"),
"login": ("binary_sensor", "https_api_login_status"),
}
#: a missing one of these refuses the charger (not the add-on)
REQUIRED = ("current", "readback", "state")
#: 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`).
REQUIRED = ("current", "readback", "state", "power")
_SETPOINT_RE = re.compile(r"^number\.(?P<x>.+)_" + SETPOINT_SUFFIX + "$")
_ROLE_ORDER = tuple(ROLES)
POWER_UNITS = ("W", "kW")
def census_key(entity) -> str:
@@ -151,7 +155,7 @@ def _amps(obj):
return None
_W_PER = {"mW": 0.001, "W": 1.0, "kW": 1000.0, "MW": 1000000.0}
_W_PER = {"W": 1.0, "kW": 1000.0}
def _watts(obj):
@@ -258,6 +262,9 @@ class ChargerProbe:
missing: tuple = ()
station_a: float | None = None
derived: bool = True # False: the setpoint's name does not fit
power_unit: str | None = None # the power entity's unit, when it is
# reading a number and that unit is not W or kW (an unavailable entity
# has no unit to judge: that is the unreachable path, not a refusal)
@property
def required_missing(self) -> tuple:
@@ -265,7 +272,8 @@ class ChargerProbe:
@property
def ok(self) -> bool:
return self.derived and not self.required_missing
return (self.derived and not self.required_missing
and self.power_unit is None)
def probe_from(setpoint, exists) -> ChargerProbe:
@@ -278,9 +286,18 @@ def probe_from(setpoint, exists) -> ChargerProbe:
(found if exists(ents[role]) is not None else missing).append(
(role, ents[role]))
st = {r: exists(e) for r, e in ents.items()}
raw, unit, _ = _state_obj(st.get("power"))
bad_unit = None
if raw is not None and unit not in POWER_UNITS:
try:
float(raw)
bad_unit = unit or "none"
except ValueError:
pass
return ChargerProbe(str(setpoint), tuple(found), tuple(missing),
station_a=_first(_amps(st.get("station_active")),
_amps(st.get("station"))))
_amps(st.get("station"))),
power_unit=bad_unit)
# --------------------------------------------------------------------------- #
+7 -1
View File
@@ -157,7 +157,13 @@ class InputsMixin:
whose limit is above the station limit, is REFUSED: the add-on starts
and the Car card says so. Said once."""
why = None
if not pr.ok:
if not pr.ok and pr.power_unit is not None \
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.")
_LOG.debug("The charger power entity reads in %r.", pr.power_unit)
elif not pr.ok:
why = "not_found"
_LOG.warning("The car charger is not set up, because Home "
"Assistant has no entity it needs: CHARGER_NOT_FOUND.")
+2 -1
View File
@@ -1268,7 +1268,8 @@ def _car_why(key, c, good):
return _t("car.why.unreach_a", a=f"{a:.0f}", brand=brand)
return _t("car.why.unreach", brand=brand)
if key == "setup":
why = {"not_found": "not_found", "limit": "limit"}.get(
why = {"not_found": "not_found", "limit": "limit",
"unit": "unit"}.get(
c.charger_refused or "", "name")
return _t("car.why." + why)
return _t("car.why." + (key if key else "wait"))
+1 -1
View File
@@ -45,7 +45,7 @@
<div class="small muted" id="car-today"></div>
<div class="small car-lab" data-t="car.mode"></div>
<div class="seg" role="radiogroup" id="car-mode" data-t-label="car.mode"><button type="button" role="radio" aria-checked="true" data-t="car.mode_off"></button></div>
<p class="tiny muted" style="margin:6px 0 0" data-t="car.help_off"></p>
<p class="tiny muted" style="margin:6px 0 0" data-t="car.help_off.description"></p>
<div class="car-why small"><span class="muted" data-t="car.why"></span><span id="car-why"></span></div>
</div>
+2 -1
View File
@@ -90,7 +90,7 @@
"loading": "Loading the forecast."
},
"peak": {"name": "Peak limit", "this_month": "This month's peak", "limit": "Limit", "none": "None recorded yet", "unreadable": "Cannot be read now"},
"car": {"name": "Car", "mode": "Mode", "mode_off": "Off", "help_off": "The add-on leaves the charger alone.", "why": "Why"},
"car": {"name": "Car", "mode": "Mode", "mode_off": "Off", "help_off": {"description": "The add-on leaves the charger alone."}, "why": "Why"},
"attention": {"name": "Needs attention", "nothing": "Nothing to look at", "to_look": "to look at", "details": "Details", "alarm": "Alarm", "warning": "Warning", "info": "Info"},
"control": {"name": "Control", "stop": "Stop control", "start": "Start control", "maint_start": "Start a battery cycle now", "maint_abort": "Stop the battery cycle", "docs": "Add-on documentation", "more": "More actions"},
"details": {"name": "Details for installers", "rows": "Status rows", "checks": "Checks", "event": "Last event"},
@@ -314,6 +314,7 @@
"unreach_a": "The charger keeps its last setting, {a} A. Check the {brand} integration 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.",
"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.",
+3 -2
View File
@@ -87,7 +87,7 @@
"loading": "De prognose wordt geladen."
},
"peak": {"name": "Piekgrens", "this_month": "Piek van deze maand", "limit": "Grens", "none": "Nog niets gemeten", "unreadable": "Nu niet leesbaar"},
"car": {"name": "Auto", "mode": "Modus", "mode_off": "Uit", "help_off": "De add-on laat de laadpaal ongemoeid.", "why": "Waarom"},
"car": {"name": "Auto", "mode": "Modus", "mode_off": "Uit", "help_off": {"description": "De add-on laat de laadpaal met rust."}, "why": "Waarom"},
"attention": {"name": "Vraagt aandacht", "nothing": "Niets om na te kijken", "to_look": "na te kijken", "details": "Details", "alarm": "Alarm", "warning": "Waarschuwing", "info": "Info"},
"control": {"name": "Regeling", "stop": "Regeling stoppen", "start": "Regeling starten", "maint_start": "Nu een batterijcyclus starten", "maint_abort": "Batterijcyclus stoppen", "docs": "Documentatie van de add-on", "more": "Meer acties"},
"details": {"name": "Details voor installateurs", "rows": "Statusregels", "checks": "Controles", "event": "Laatste gebeurtenis"},
@@ -255,7 +255,7 @@
"pill": {"running": "Actief", "shadow": "Schaduwmodus", "stopped": "Gestopt", "alarm": "Heeft u nu nodig", "waiting": "Wacht op Home Assistant", "warning": "Actief, met een waarschuwing"},
"banner_sub": {"running": "De batterijen volgen het plan hieronder.", "shadow": "De plannen hieronder tonen wat de add-on zou doen.", "stopped": "Start de regeling hieronder als u klaar bent.", "alarm": "Zie Vraagt aandacht hieronder voor wat u moet doen.", "waiting": "De regeling gaat vanzelf verder zodra Home Assistant antwoordt.", "warning": "Zie Vraagt aandacht hieronder."},
"car": {
"state": {"charging": "Laadt", "waiting": "Aangesloten, wacht", "none": "Geen auto aangesloten", "full": "Auto is vol", "unreach": "Laadpaal onbereikbaar", "setup": "Laadpaal niet ingesteld", "fault": "Laadpaal storing", "wait": "Nog geen meting"},
"state": {"charging": "Laadt", "waiting": "Aangesloten, wacht", "none": "Geen auto aangesloten", "full": "Auto is vol", "unreach": "Laadpaal onbereikbaar", "setup": "Laadpaal niet ingesteld", "fault": "Laadpaalstoring", "wait": "Nog geen meting"},
"big": {"waiting": "Aangesloten, laadt niet.", "none": "Niets te laden.", "full": "De auto is vol.", "seen": "Laatst gezien om {t}.", "no_reading": "Geen meting.", "setup": "Niet ingesteld.", "fault": "De laadpaal meldt een storing.", "wait": "Nog geen meting."},
"phase_one": "1 fase",
"phase_many": "{n} fasen",
@@ -268,6 +268,7 @@
"unreach_a": "De laadpaal houdt haar laatste instelling, {a} A. Controleer de {brand}-integratie 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.",
"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.",
+21 -5
View File
@@ -1991,6 +1991,7 @@ def charger_probe_rows(opts: dict, entities: dict | None) -> dict:
pr = chargers.probe_from(setpoint, state_obj)
return {"ok": pr.ok, "found": [list(x) for x in pr.found],
"missing": [list(x) for x in pr.missing], "limit_a": pr.station_a,
"power_unit": pr.power_unit,
"state": "ok" if pr.ok else "missing"}
@@ -2032,11 +2033,26 @@ def _charger_notes(notes, opts: dict, entities: dict) -> None:
return
for role, entity in look["missing"]:
required = role in chargers.REQUIRED
_advise(notes, "warn" if required else "info", key,
f"Home Assistant has no entity {entity}, which the charger "
f"needs.",
nl=f"Home Assistant kent {entity} niet, die de laadpaal "
"nodig heeft.")
# A missing required entity (current, read-back, car state or power)
# means the charger is not used: the Car card says "not set up".
if required:
_advise(notes, "warn", key,
f"Home Assistant has no entity {entity}, which the "
"charger needs, so it is not used.",
nl=f"Home Assistant kent {entity} niet, die de laadpaal "
"nodig heeft, dus ze wordt niet gebruikt.")
else:
_advise(notes, "info", key,
f"Home Assistant has no entity {entity}, which the "
"charger can also use.",
nl=f"Home Assistant kent {entity} niet, die de laadpaal "
"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 "
"charger is not used.",
nl="Het vermogen van de laadpaal is niet in W of kW, dus ze "
"wordt niet gebruikt.")
lim = look["limit_a"]
if look["ok"]:
have = {r for r, _e in look["found"]}
+108 -4
View File
@@ -981,7 +981,7 @@ def bad_power(kind):
CLOCK[0] = 1000.0 + 8
asyncio.run(rin(c_, 1008.0)) # 1 s after the good one
res["fresh"] = c_.car.fresh(CLOCK[0])
res["avail"] = c_.car.reading.available and c_.car.reading.power_w is None
res["avail"] = c_.car.reading.power_w is None
res["term"] = c_.car_term()
res["now"] = c_.status_payload()["now"]
res["card"] = c_.status_payload()["car"]
@@ -995,8 +995,8 @@ def bad_power(kind):
for kind in ("unavailable", "wrong unit"):
r_ = bad_power(kind)
check(f"{kind}: the reading is available with no power, and still inside "
f"stale_s (the case that raised)", r_["err"] is None
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}: car_term, /status, the trade cycle and the MQTT values do "
f"not raise ({r_['err']})", r_["err"] is None)
@@ -1033,13 +1033,117 @@ check("fault card (EN): chip, big line, Why, amber",
and pf["car"]["why"] == "The charger reports a fault. Check it at the "
"charger itself.")
pfn = c_f.status_payload(lang="nl")
check("fault card (NL)", pfn["car"]["chip"] == "Laadpaal storing"
check("fault card (NL)", pfn["car"]["chip"] == "Laadpaalstoring"
and pfn["car"]["big"] == "De laadpaal meldt een storing."
and pfn["car"]["why"] == "De laadpaal meldt een storing. Controleer ze "
"aan de laadpaal zelf.")
check("the MQTT state says it too", c_f.car_values()["car_state"]
== "Charger fault")
# --------------------------------------------------------------------------- #
print("\n11. ROUND 3: THE POWER ENTITY IS REQUIRED, AND READS IN W OR kW")
# --------------------------------------------------------------------------- #
PW = "sensor.wallbox_active_power_total_socket_1"
rows_all = {eid: {"entity_id": eid, "state": s["state"],
"unit": s["attributes"].get("unit_of_measurement")}
for eid, s in SimCharger().states().items()}
no_power = {k: v for k, v in rows_all.items() if k != PW}
look = validate.charger_probe_for(SET, no_power)
check("the probe refuses a charger with no power entity",
not look["ok"] and ["power", PW] in look["missing"])
look = validate.charger_probe_for(SET, dict(rows_all, **{PW: dict(
rows_all[PW], unit="V")}))
check("...and one whose power reads in a wrong unit (V)",
not look["ok"] and look["power_unit"] == "V")
check("...and one with no unit at all",
validate.charger_probe_for(SET, dict(rows_all, **{PW: dict(
rows_all[PW], unit=None)}))["power_unit"] == "none")
check("kW is accepted (converted), W too",
validate.charger_probe_for(SET, dict(rows_all, **{PW: dict(
rows_all[PW], unit="kW")}))["ok"]
and validate.charger_probe_for(SET, rows_all)["ok"])
look = validate.charger_probe_for(SET, dict(rows_all, **{PW: dict(
rows_all[PW], state="unavailable", unit=None)}))
check("a power entity that is UNAVAILABLE at probe time is not a refusal (it "
"is the unreachable path)", look["ok"] and not look["missing"])
nts = validate.config_notes(CAR, entities=no_power)
check("the settings note for the missing power entity says the charger is not "
"used (a warning, not info)",
any(n.tier == "warn" and PW in n.text and "not used" in n.text
for n in nts))
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))
class Hide(Site):
"""The site, with some of the charger's entities hidden or changed."""
def __init__(self, charger, hide=(), unit=None, **kw):
super().__init__(charger, **kw)
self.hide, self.unit = set(hide), unit
def _all(self):
out = {k: v for k, v in super()._all().items() if k not in self.hide}
if self.unit and PW in out:
out[PW] = dict(out[PW], attributes={"unit_of_measurement":
self.unit})
return out
def world(site_factory, opts, n=10):
"""(controller, the Home now block, the last profile sample)."""
del samples[:]
sim_ = SimCharger()
sim_.plug_in()
sim_.set_phases(3)
sim_.set_current(10)
site_ = site_factory(sim_)
c_ = build(dict(opts, pv_forecast_entity="sensor.fc",
battery_modes_enabled=True), site_)
c_.house_profile = _HP()
asyncio.run(run_cycles(c_, site_, n=n, sim=sim_))
return c_, c_.status_payload(), samples[-1] if samples else "none"
c_none, p_none, s_none = world(lambda s_: Site(s_, grid=7500.0, pv=500.0),
BASE)
for why, factory, want in (
("no power entity", lambda s_: Hide(s_, hide={PW}, grid=7500.0,
pv=500.0), "not_found"),
("a power entity in V", lambda s_: Hide(s_, unit="V", grid=7500.0,
pv=500.0), "unit")):
c_x, p_x, s_x = world(factory, CAR)
check(f"{why}: the charger is refused as 'not set up' ({c_x.charger_refused})",
c_x.charger_refused == want and not c_x.charger.configured()
and p_x["car"]["state"] == "setup"
and p_x["car"]["chip"] == "Charger not set up")
check(f"{why}: House and the house profile are as with no charger at all "
f"(House {p_x['now']['house_w']}, sample {s_x})",
p_x["now"] == p_none["now"] and s_x == s_none
and p_x["now"]["house_w"] is not None and s_x is not None)
check(f"{why}: the add-on still runs (the inverter is written) and one "
f"amber row says so", any(e == "number.sp" for e, _v in
c_x.hass.sets)
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"])
# 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()
sim_u.plug_in()
sim_u.go_unavailable()
site_u = Site(sim_u)
c_u2 = build(CAR, site_u)
asyncio.run(run_cycles(c_u2, site_u, n=40, sim=sim_u))
check("a charger that is unavailable from the first read is not refused; it "
"is the unreachable path", c_u2.charger_refused is None
and c_u2.charger.configured()
and c_u2.status_payload()["car"]["state"] == "unreach")
print()
if fails:
print(f"{len(fails)} FAILED")