diff --git a/goodwe_controller/app/chargers.py b/goodwe_controller/app/chargers.py index 91ab7ec..18be55b 100644 --- a/goodwe_controller/app/chargers.py +++ b/goodwe_controller/app/chargers.py @@ -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: diff --git a/goodwe_controller/app/homeview.py b/goodwe_controller/app/homeview.py index 1850edc..bd66003 100644 --- a/goodwe_controller/app/homeview.py +++ b/goodwe_controller/app/homeview.py @@ -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: diff --git a/goodwe_controller/app/ui_text/en.json b/goodwe_controller/app/ui_text/en.json index 70e300f..bef64e0 100644 --- a/goodwe_controller/app/ui_text/en.json +++ b/goodwe_controller/app/ui_text/en.json @@ -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." } }, diff --git a/goodwe_controller/app/ui_text/nl.json b/goodwe_controller/app/ui_text/nl.json index 03da3f2..3dd0c7b 100644 --- a/goodwe_controller/app/ui_text/nl.json +++ b/goodwe_controller/app/ui_text/nl.json @@ -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." } }, diff --git a/goodwe_controller/test_ev03.py b/goodwe_controller/test_ev03.py index 062e211..12870b6 100644 --- a/goodwe_controller/test_ev03.py +++ b/goodwe_controller/test_ev03.py @@ -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") diff --git a/goodwe_controller/tools/chargersim.py b/goodwe_controller/tools/chargersim.py index 8f1d842..2b53394 100644 --- a/goodwe_controller/tools/chargersim.py +++ b/goodwe_controller/tools/chargersim.py @@ -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}",