EV-03 round 2: a missing car power is unknown, and mode3 E/F has a state
checks / unit (pull_request) Successful in 26m50s
infra-pin / fresh (pull_request) Successful in 2s

- CarTracker.car_w returns None when the current reading carries no power
  (power entity unavailable or in a non-power unit); it can no longer raise
  into the control cycle, /status or the MQTT values.
- mode3 E and F are the card state "fault" (EN and NL).
- chargersim gains fault().

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
ev03-dev
2026-11-15 12:02:17 +01:00
co-authored by Claude
parent 984f4335a0
commit 678ea13895
6 changed files with 126 additions and 12 deletions
+15 -6
View File
@@ -495,8 +495,8 @@ def census_entity(opts: dict) -> str:
# --------------------------------------------------------------------------- #
# The car's state, kept over time
# --------------------------------------------------------------------------- #
CHARGING, WAITING, NONE_, FULL, UNREACH, SETUP = (
"charging", "waiting", "none", "full", "unreach", "setup")
CHARGING, WAITING, NONE_, FULL, UNREACH, SETUP, FAULT = (
"charging", "waiting", "none", "full", "unreach", "setup", "fault")
@dataclass
@@ -559,11 +559,18 @@ class CarTracker:
return self.last_ok is not None and now - self.last_ok <= self.stale_s
def car_w(self, now: float):
"""The car's power now, or None when the reading is not fresh."""
if not self.fresh(now) or self.reading is None or \
not self.reading.available:
"""The car's power now, or None (unknown) when the reading is not fresh
OR the current reading carries no power. Never the last good figure: a
read that says "no power" contradicts it, and absence is never zero. It
never raises (telemetry has no foot in the control path)."""
r = self.reading
if not self.fresh(now) or r is None or not r.available \
or r.power_w is None:
return None
try:
return max(0.0, float(r.power_w))
except (TypeError, ValueError):
return None
return max(0.0, float(self.reading.power_w))
# -- the two-writer detector ----------------------------------------------
def _writer(self, r, now_utc):
@@ -634,6 +641,8 @@ class CarTracker:
r = self.reading
if r is None or not r.available:
return None if self.last_good is None else self.last_status
if r.state in ("E", "F"): # the charger's own error states
return FAULT
if r.state == "A" or (r.state is None and r.connected is False):
return NONE_
if r.state in ("B", "C", "D") or r.connected:
+1 -1
View File
@@ -1226,7 +1226,7 @@ def _docs_url():
# EV-03: the Car card. READ ONLY: Off is the only mode in this version.
# --------------------------------------------------------------------------- #
_CAR_KIND = {"charging": "ok", "full": "ok", "unreach": "warn",
"setup": "warn"}
"setup": "warn", "fault": "warn"}
def car_state_word(key) -> str:
+3 -2
View File
@@ -301,8 +301,8 @@
"warning": "See Needs attention below."
},
"car": {
"state": {"charging": "Charging", "waiting": "Connected, waiting", "none": "No car connected", "full": "Car is full", "unreach": "Charger not reachable", "setup": "Charger not set up", "wait": "No reading yet"},
"big": {"waiting": "Connected, not charging.", "none": "Nothing to charge.", "full": "The car is full.", "seen": "Last seen at {t}.", "no_reading": "No reading.", "setup": "Not set up.", "wait": "No reading yet."},
"state": {"charging": "Charging", "waiting": "Connected, waiting", "none": "No car connected", "full": "Car is full", "unreach": "Charger not reachable", "setup": "Charger not set up", "fault": "Charger fault", "wait": "No reading yet"},
"big": {"waiting": "Connected, not charging.", "none": "Nothing to charge.", "full": "The car is full.", "seen": "Last seen at {t}.", "no_reading": "No reading.", "setup": "Not set up.", "fault": "The charger reports a fault.", "wait": "No reading yet."},
"phase_one": "1 phase",
"phase_many": "{n} phases",
"today": "{kwh} charged today.",
@@ -316,6 +316,7 @@
"not_found": "The add-on cannot find the charger. Check the current setting entity 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.",
"wait": "Waiting for the first reading."
}
},
+3 -2
View File
@@ -255,8 +255,8 @@
"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", "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.", "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": "Laadpaal storing", "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",
"today": "Vandaag {kwh} geladen.",
@@ -270,6 +270,7 @@
"not_found": "De add-on vindt de laadpaal niet. Controleer de entiteit van de laadstroom 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.",
"wait": "Wacht op de eerste meting."
}
},
+96
View File
@@ -944,6 +944,102 @@ check("an absent number goes out as HA's own 'None' payload, and the PV rule "
"(ABSENT_AS_UNKNOWN is one key) is not widened",
mqtt.ABSENT_AS_UNKNOWN == frozenset({"pv"}))
# --------------------------------------------------------------------------- #
print("\n10. ROUND 2: A MISSING POWER READING IS AN UNKNOWN TERM, NEVER AN ERROR")
# --------------------------------------------------------------------------- #
import traceback # noqa: E402
def bad_power(kind):
"""A controller with one good reading, then (inside stale_s) an AVAILABLE
reading whose power is missing: `unavailable`, or in a non-power unit."""
sim_ = SimCharger()
sim_.plug_in()
sim_.set_phases(3)
sim_.set_current(10)
site_ = Site(sim_, grid=7500.0, batt=0.0, pv=500.0)
opts = dict(CAR, pv_forecast_entity="sensor.fc", battery_modes_enabled=True)
c_ = build(opts, site_)
del samples[:]
c_.house_profile = _HP()
asyncio.run(run_cycles(c_, site_, n=8, sim=sim_))
good_w = c_.car.reading.power_w
pw = f"sensor.{sim_.x}_active_power_total_socket_1"
orig = site_._all
def broken():
out = orig()
if kind == "unavailable":
out[pw] = dict(out[pw], state="unavailable")
else:
out[pw] = dict(out[pw], attributes={"unit_of_measurement": "V"})
return out
site_._all = broken
del samples[:]
res = {"good_w": good_w, "err": None}
try:
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["term"] = c_.car_term()
res["now"] = c_.status_payload()["now"]
res["card"] = c_.status_payload()["car"]
asyncio.run(c_.cycle()) # the trade cycle's profile step
res["sample"] = samples[-1] if samples else "none"
res["vals"] = c_.car_values()
except Exception: # noqa: BLE001
res["err"] = traceback.format_exc().strip().splitlines()[-1]
return res
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
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)
check(f"{kind}: the term is UNKNOWN (never 0 W): House shows no reading and "
f"the profile sample is None", r_["err"] is None
and r_["term"] == (0.0, True) and r_["now"]["house_w"] is None
and r_["sample"] is None)
check(f"{kind}: the card shows no power figure and the MQTT power is the "
f"None payload", r_["err"] is None
and r_["card"]["power_w"] is None
and r_["vals"]["car_power"] == mqtt.PAYLOAD_NONE)
# E and F: the charger's own error states
for letter in ("E", "F"):
rr = CH.ChargerReading(available=True, state=letter, power_w=0.0,
allowed_a=10.0, setpoint_a=10.0)
tk = CH.CarTracker()
tk.observe(rr, 1.0, now_utc(), D)
check(f"mode3 {letter} is the defined state 'fault', not a blank",
tk.status(1.0) == "fault")
sim_f = SimCharger()
sim_f.plug_in()
sim_f.set_current(10)
site_f = Site(sim_f)
c_f = build(CAR, site_f)
asyncio.run(run_cycles(c_f, site_f, n=4, sim=sim_f))
sim_f.fault("E")
sim_f.tick(1)
asyncio.run(rin(c_f, 1010.0))
pf = c_f.status_payload()
check("fault card (EN): chip, big line, Why, amber",
pf["car"]["chip"] == "Charger fault" and pf["car"]["kind"] == "warn"
and pf["car"]["big"] == "The charger reports a fault."
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"
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()
if fails:
print(f"{len(fails)} FAILED")
+8 -1
View File
@@ -72,6 +72,10 @@ class SimCharger:
def set_phases(self, n):
self.phases_allowed = 3 if n == 3 else 1
def fault(self, letter="E"):
"""mode3 E or F: the charger's own error states."""
self.state, self.power_w = letter, 0.0
def go_unavailable(self, on=True):
self.unavailable = bool(on)
@@ -90,6 +94,9 @@ class SimCharger:
def tick(self, seconds=1.0):
for _ in range(int(max(1, round(seconds)))):
if self.state in ("E", "F"): # a fault: nothing flows, it stays
self.power_w = 0.0
continue
if self.state != "A":
self.state = "C" if self.target_w() > 0 else "B"
tgt = self.target_w()
@@ -125,7 +132,7 @@ class SimCharger:
st(f"sensor.{x}_mode3_state_socket_1", f"STATE_{self.state}"),
st(f"sensor.{x}_display_state_socket_1",
{"A": "Available", "B": "EV Connected",
"C": "Charging Normal"}[self.state]),
"C": "Charging Normal"}.get(self.state, "Error")),
st(f"sensor.{x}_active_power_total_socket_1",
f"{self.power_w:.0f}", "W"),
st(f"sensor.{x}_meter_reading_socket_1", f"{self.kwh:.2f}",