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 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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"]}
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user