diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index 80443af..8141987 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -2,6 +2,46 @@ ## Unreleased +**TEL-05.** A fourth `meter_source`, `homewizard_local`, which polls a +HomeWizard P1's **own local API** (`GET /api/v1/data`) instead of watching a +Home Assistant entity. Set `p1_host` to the meter's address; `meter_poll_s` +(default 5 s, the meter's own update rate) sets the cadence. + +✅ **This is the first transport whose `sensor.p1_sample_age_s` measures when +the meter *reported*, and therefore the first one a firmware watchdog may +threshold.** Every HTTP response is an arrival: the meter answered, now, with +its current reading, and whether the *number* moved is not consulted. Home +Assistant cannot express that at all — a repeated reading emits no +`state_changed`, advances `last_reported` on neither serialiser, and +`state_reported` is not subscribable ("Event filter is required"). On +`ha_signed` that made a healthy meter under a flat load indistinguishable from +a dead one, and our own capture of this house's meter goes 42.2 s and 97.0 s +between changes — both past the default `meter_max_age_s` of 30, i.e. a false +trip to 0 W on a meter that is fine. If you have a HomeWizard P1, move to this +mode. + +Everything TEL-01 established is reused, not re-implemented: ingest +timestamping, `meter_max_age_s`, the clock-recomputed age, the plausibility +bounds and the §20 unsigned-decode rejection, and the same `split_signed` sign +convention `ha_signed` uses. A failed or timed-out poll submits nothing, so it +is a *missing* reading — never 0 W — and it does not reset the age. + +Verified on the ENV-01 rig against `sim/hwsim.py`, steady and with `--fault +freeze` injected. Still defaults to `off`. + +⚠️ **An arrival stamp still cannot see a *frozen* meter**, and no arrival +detector can: a meter answering `200 OK` forever with a stale number is +arriving. The local API does expose what the HA path never had — the +`total_power_*_kwh` registers stop advancing — and the transport tracks it as +`unchanged_s`, but that is deliberately **not** folded into the age and not +thresholded: this controller regulates grid power toward ~0 W, and at a +converged −10 W the export register needs six minutes to move by its 1 Wh +resolution while the power figure legitimately repeats. Thresholding it at 30 s +would rebuild the false-trip limit cycle at the exact operating point we aim +for. Freeze detection needs the low-power case solved first, separately. It is +shown on the status page's P1 line instead — leaving it unthresholded only +holds up if a human can read it, so now they can. + **TEL-04.** A third `meter_source`, `ha_signed`, reading **one signed** Home Assistant entity: positive = import, negative = export. That is the shape a HomeWizard P1 publishes (`sensor.p1_meter_active_power`), and it is the meter diff --git a/goodwe_controller/DOCS.md b/goodwe_controller/DOCS.md index fbcf5aa..a6f8718 100644 --- a/goodwe_controller/DOCS.md +++ b/goodwe_controller/DOCS.md @@ -70,6 +70,13 @@ Either way, do not build the missing shape out of template sensors. The point of `meter_source` is that the sign convention is derived in one tested place rather than in YAML nobody reviews underneath a safety input. +> ✅ **If your meter is a HomeWizard P1, use `homewizard_local`, not +> `ha_signed`.** It reads the same meter and produces the same numbers, but it +> polls the meter directly instead of watching a Home Assistant entity — and +> that is the difference between an age sensor a watchdog can threshold and one +> it cannot. See "`sensor.p1_sample_age_s`" below; `ha_signed` remains for +> installs where the meter is only reachable through Home Assistant. + > ⚠️ **`ha_dsmr` and `mqtt_p1` have never processed a telegram from real > hardware.** No meter in this installation uses either one. Both were written > to the assumption in `specs.md` §5.2 that a Belgian P1 exposes two unsigned @@ -84,9 +91,11 @@ than in YAML nobody reviews underneath a safety input. | option | default | meaning | |---|---|---| -| `meter_source` | `off` | `off` keeps `meter_entity`. `ha_dsmr` subscribes to the DSMR integration over the HA WebSocket; `mqtt_p1` reads a topic; `ha_signed` subscribes to one signed entity over the HA WebSocket | +| `meter_source` | `off` | `off` keeps `meter_entity`. `ha_dsmr` subscribes to the DSMR integration over the HA WebSocket; `mqtt_p1` reads a topic; `ha_signed` subscribes to one signed entity over the HA WebSocket; `homewizard_local` polls a HomeWizard P1's own local API, bypassing Home Assistant | | `meter_phases` | 1 | 1 or 3. Must match the telegram, or every telegram is rejected and logged | | `meter_max_age_s` | 30 | Beyond this the reading is stale and grid power reads as *missing*. On its own it does **not** command 0 W — see the timing note below. It is also the longest a reading is held forward into the 15-minute average | +| `meter_poll_s` | 5 | `homewizard_local` only. Seconds between polls. **Must be well under `meter_max_age_s`** — see below | +| `p1_host` | | `homewizard_local` only. The meter's own address, `host` or `host:port` (e.g. `192.168.2.250`) | | `meter_mqtt_topic` | | `mqtt_p1` only | | `p1_import_entity` | | The **unsigned** consumption sensor. Do not point this at a signed template | | `p1_export_entity` | | The **unsigned** injection sensor | @@ -143,6 +152,55 @@ tested, and guessing a key here means guessing a kilowatt: it is present it is used for the age, which is what stops a retained message replayed on reconnect from presenting a ten-minute-old reading as current. +#### `homewizard_local` — polling the meter instead of Home Assistant + +Set `p1_host` to the meter's address and the add-on does `GET /api/v1/data` on +it every `meter_poll_s` seconds, reading `active_power_w` (signed, same +convention as `ha_signed`) and the three `active_power_l{1,2,3}_w` fields. Home +Assistant is not involved: no entity, no WebSocket, no integration to +mis-configure. Per-phase figures are used only when the meter serves all +`meter_phases` of them — a single-phase meter returns `null` for L2/L3, and the +connection-level reading is still accepted on its own. + +**Why this mode exists:** every HTTP response is an *arrival*. The meter +answered, now, with its current reading — whether or not the number moved. That +is the signal `sensor.p1_sample_age_s` needs and the one Home Assistant cannot +give it at all (see the note below). It is also simply fewer moving parts: the +five-second cadence is the meter's own, rather than an integration's polling of +it re-published as a state change. + +**Cadence.** The age is never fresher than the poll interval, so: + +| | | +|---|---| +| meter's own update rate | ~5.0 s (measured 4.97 s) | +| `meter_poll_s` default | 5 s — nothing to gain below the meter's own rate | +| `meter_max_age_s` default | 30 s, i.e. six polls of headroom | +| `meter_poll_s >= meter_max_age_s` | **refused at startup** — every reading would be stale before its successor arrived | +| `meter_poll_s > meter_max_age_s / 2` | warned — one missed poll makes the reading stale | + +A failed poll — timeout, connection refused, non-200, unparseable body — is a +**missing** reading. It submits nothing, so the reading does not become 0 W, the +last good value and its timestamp are left alone, and the age goes on climbing. +That is exactly what a dead meter should look like. + +**What it still cannot see: a frozen meter.** A meter that answers `200 OK` +forever with a stale number is arriving, so no arrival detector — this one +included — can tell it from a healthy one. The local API does expose the raw +material the HA path never had (the `total_power_*_kwh` registers stop +advancing), and the transport tracks it as `unchanged_s`, but it is deliberately +*not* folded into the age and *not* thresholded: this controller regulates grid +power toward ~0 W, and at a converged −10 W the export register needs six +minutes to move by its 1 Wh resolution while the power figure legitimately +repeats. Thresholding that at 30 s would rebuild the false-trip limit cycle at +the exact operating point the controller aims for. Freeze detection is a +separate problem and needs the low-power case solved first. + +You read it yourself instead: the add-on's status page shows it on the P1 line, +as `… 4 rejected, measurement unchanged for 312 s`. On a house drawing real +power that figure stays in the seconds; minutes of it while the load is clearly +not near zero is the meter to go and look at. + #### `sensor.p1_sample_age_s` Published over MQTT discovery whenever a broker is available: **seconds since the @@ -189,8 +247,19 @@ behind it would trip the firmware watchdog on a system that is working fine. > the ESP32 stale-input watchdog: a quiet house would trip the battery to 0 W. > Raising `meter_max_age_s` is **not** the fix — the two conditions produce an > identical signal, so a bigger number only chooses which of the two errors you -> get. The real fix is an arrival stamp the meter itself provides, i.e. reading -> the HomeWizard local API directly rather than through an HA entity. +> get. The real fix is an arrival stamp the meter itself provides — and that now +> exists: **`meter_source: homewizard_local`**. If you have a HomeWizard P1, +> switch to it. If your meter is only reachable through Home Assistant, this +> limit still applies to you and the watchdog threshold still must not be armed. + +**Where the age is trustworthy:** + +| mode | the age measures | safe to threshold from firmware | +|---|---|---| +| `homewizard_local` | time since the meter **answered** | **yes** — every HTTP response is an arrival | +| `ha_dsmr` | time since one of several entities changed | no — statistically usually fine, which is a masked bug, not an absent one | +| `ha_signed` | time since the one entity changed | **no** — see above | +| `mqtt_p1` | time since a message arrived | arrivals yes, but a stuck bridge republishing keeps arriving | ### Control diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index 13d0403..2d23337 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -97,6 +97,10 @@ class Controller: self.p1 = P1Ingest(phases=int(opts.get("meter_phases", 1)), max_age_s=float(opts.get("meter_max_age_s", 30))) self.p1_enabled = is_enabled(opts) + # Set by amain() once the transport is built, so the status page can show + # what only the transport knows (homewizard_local's unchanged_s). Stays + # None when P1 is off, or under a transport that has no such counter. + self.p1_source = None # live state self.auto = bool(store.data.get("auto", opts.get("auto_start", False))) @@ -365,10 +369,22 @@ class Controller: + (f" - last error: {self.p1.last_error}" if self.p1.last_error else "")}) else: + # ⚠️ unchanged_s is REPORTED, never thresholded and never folded + # into the age - see HomeWizardLocalSource.unchanged_s for why + # (at the converged -10 W this controller aims for, a 1 Wh + # register needs ~6 minutes to move, so any limit false-trips at + # the exact operating point we target). The whole argument for + # leaving it unthresholded is that a human interprets it, which + # requires a human being able to see it - so here it is. getattr: + # only homewizard_local has one, and p1_source is None until + # amain() builds the transport. + unchanged = getattr(self.p1_source, "unchanged_s", None) out.append({"ok": True, "warn": False, "text": f"P1 meter ({o.get('meter_source')}): {self.p1.net_w:g} W, " f"{age:.0f} s old, {self.p1.samples} telegrams, " - f"{self.p1.parse_errors} rejected"}) + f"{self.p1.parse_errors} rejected" + + (f", measurement unchanged for {unchanged:.0f} s" + if unchanged is not None else "")}) rows = [("battery SoC", self.soc, o.get("soc_entity")), ("battery power", self.batt, o.get("batt_entity"))] if not self.p1_enabled: @@ -540,6 +556,7 @@ async def amain() -> None: # unit - would be computed from a fraction of the data. p1_source = build_source(opts, controller.p1, session, broker) if p1_source is not None: + controller.p1_source = p1_source # so checks() can report on it tasks.append(asyncio.create_task(p1_source.run())) await stop.wait() diff --git a/goodwe_controller/app/p1.py b/goodwe_controller/app/p1.py index 46b52ad..3efe5e3 100644 --- a/goodwe_controller/app/p1.py +++ b/goodwe_controller/app/p1.py @@ -57,6 +57,7 @@ _LOG = logging.getLogger("goodwe.p1") SOURCE_HA = "ha_dsmr" SOURCE_MQTT = "mqtt_p1" SOURCE_HA_SIGNED = "ha_signed" +SOURCE_HOMEWIZARD = "homewizard_local" QUARTER_S = 900 @@ -88,7 +89,7 @@ class P1Sample: ingest_ts: datetime # tz-aware UTC, set at ingest ingest_mono: float # time.monotonic() at ingest - see age_s() telegram_ts: datetime | None # from the telegram, where the source has one - source: str # SOURCE_HA | SOURCE_MQTT | SOURCE_HA_SIGNED + source: str # one of the SOURCE_* constants above import_w: float # unsigned magnitude, as the meter reports it export_w: float # unsigned magnitude net_w: float # import_w - export_w (+ import, - export) @@ -798,6 +799,236 @@ class HaSignedSource(HaDsmrSource): return False +# --------------------------------------------------------------------------- # +# transport 4: the HomeWizard P1's own local API +# --------------------------------------------------------------------------- # +HOMEWIZARD_PATH = "/api/v1/data" +HOMEWIZARD_PORT = 80 + + +def parse_homewizard(doc, phases: int, *, now: datetime | None = None, + ingest_mono: float | None = None) -> P1Sample: + """One `GET /api/v1/data` document -> a sample. Raises P1Error. + + The meter's own JSON, of which we read four keys: + + {"active_power_w": -5710.0, # SIGNED, + import - export + "active_power_l1_w": ..., "active_power_l2_w": ..., "..._l3_w": ..., + "total_power_import_kwh": 1234.567, "total_power_export_kwh": 890.123} + + Same shape as `ha_signed` - one signed connection figure plus signed + per-phase figures - so it goes through the same `split_signed` and the same + `make_sample`. Nothing about the sign convention is re-derived here. + + ⚠️ No telegram timestamp, because the API carries none, and that is correct + rather than a gap: the document was produced by the meter in the moment it + answered, so the ingest stamp IS the measurement time. This is the whole + reason the transport exists - see HomeWizardLocalSource. + + ⚠️ Per-phase figures are taken only when the meter serves ALL `phases` of + them. A single-phase HomeWizard returns `active_power_l2_w: null`, and a + partial set must not become a fabricated tuple. Unlike the HA transports + there is no "wait for the rest" case to worry about: every field here came + out of ONE response, so a missing phase cannot be a stale phase, and the + connection-level reading is still a genuine, complete measurement. Dropping + the whole sample over an absent per-phase field would turn a healthy meter + into a climbing age, which is the exact false-trip this ticket removes. + """ + if not isinstance(doc, dict): + raise P1Error(f"meter response is not a JSON object ({type(doc).__name__})") + + imp, exp = split_signed(doc.get("active_power_w")) + + pi = pe = None + # ⚠️ range(phases), not the legs the meter served: a 3-phase meter configured + # as meter_phases: 1 yields a per-phase tuple covering ONE leg of three. + # Control is unaffected (it uses the connection figure), but the + # capacity-tariff peak is understated. Filed separately - not fixed here. + legs = [doc.get(f"active_power_l{i + 1}_w") for i in range(phases)] + if all(v is not None for v in legs): + pairs = [split_signed(v) for v in legs] + pi = [a for a, _ in pairs] + pe = [b for _, b in pairs] + + return make_sample(SOURCE_HOMEWIZARD, imp, exp, phases=phases, + phase_import_w=pi, phase_export_w=pe, + ingest_ts=now, ingest_mono=ingest_mono) + + +def measurement_fingerprint(doc) -> tuple: + """The part of a response that a genuinely new measurement has to move. + + Power plus both energy registers. A meter under any real load advances a + kWh counter (1 Wh resolution: ~10 s at 350 W), so this changes even when the + power figure happens to repeat. Used ONLY for `unchanged_s` - see there for + why it must not be allowed anywhere near the age. + """ + if not isinstance(doc, dict): + return () + return (doc.get("active_power_w"), + doc.get("total_power_import_kwh"), + doc.get("total_power_export_kwh")) + + +class HomeWizardLocalSource: + """Polls the meter's own local HTTP API instead of a Home Assistant entity. + + ⚠️ THIS IS THE ONLY TRANSPORT WHOSE AGE MEASURES ARRIVAL. That is the entire + reason it exists, and it is not an optimisation - it is the difference + between an age sensor a firmware watchdog may threshold and one it may not. + + Every HTTP response is a genuine arrival: the meter answered, now, with its + current reading. Whether the NUMBER moved is irrelevant, so a healthy meter + under a flat load resets the age exactly like a busy one. Home Assistant + cannot provide this at all - it emits `state_changed` only on a change, does + not advance `last_reported` on either serialiser for a repeat, and refuses a + `state_reported` subscription outright ("Event filter is required"). Measured + on the ENV-01 rig over 70 s of a frozen meter, and independently against the + live house over ten repeated readings. So on `ha_signed` the age means "time + since the value changed", and our own capture of this house's meter has + 42.2 s and 97.0 s between changes - both past the default `meter_max_age_s` + of 30, i.e. a false trip to 0 W on a perfectly healthy meter. + + Everything else is TEL-01's pipeline unchanged: ingest timestamping, + `meter_max_age_s`, the clock-recomputed age, the plausibility bounds, and a + failed read treated as a MISSING reading rather than 0 W. + + ⚠️ A failed or timed-out poll submits nothing. It is a rejection, so the + last good sample and its stamp stay exactly where they were and the age goes + on climbing - which is precisely the signal a dead meter should produce. + + ⚠️ POLL CADENCE vs `meter_max_age_s`. The age can only be as fresh as the + poll interval, so `meter_poll_s` must sit well under `meter_max_age_s` or the + reading flaps in and out of staleness on a healthy meter. The real meter + updates every ~5.0 s (NOTES.md:640 measures 4.97 s), so polling faster than + that buys nothing but re-reads. Default 5 s against a 30 s max age; the + startup check in build_source refuses `meter_poll_s >= meter_max_age_s` and + warns past half of it. + + ponytail: a plain `asyncio.sleep` loop rather than a scheduler, and no + backoff. A poll that fails costs one rejection and is retried on the next + tick; a meter that is down stays down and the age reports it. The ceiling is + a meter so slow that polls overlap - the request timeout is held under the + poll interval so they cannot. + """ + + def __init__(self, session, ingest: P1Ingest, host: str, *, + port: int = HOMEWIZARD_PORT, poll_s: float = 5.0, + timeout_s: float | None = None): + self.session = session + self.ingest = ingest + self.host, self.port = host, int(port) + self.poll_s = float(poll_s) + # ⚠️ Held under the poll interval on purpose: a timeout longer than the + # cadence queues polls behind a hung meter, and a backlog of requests + # all landing at once would stamp several arrivals for one measurement. + self.timeout_s = float(timeout_s) if timeout_s else max(1.0, self.poll_s * 0.8) + self.url = f"http://{self.host}:{self.port}{HOMEWIZARD_PATH}" + self.connected = False + self.polls = 0 + self.poll_errors = 0 + self._fingerprint: tuple | None = None + self._changed_mono: float | None = None + + @property + def unchanged_s(self) -> float | None: + """Seconds since the meter last served a DIFFERENT measurement. + + ⚠️ Diagnostic only, and deliberately NOT folded into + `sensor.p1_sample_age_s`. A frozen meter (SAFETY-01's six-minute + runaway) answers 200 OK with a stale document forever, so no arrival + detector can see it - only the document standing still can, and this is + that signal. It is exposed rather than acted on because it is NOT safe + as a 30 s watchdog input: this controller regulates grid power toward + ~0 W, and at a converged -10 W the export register takes six minutes to + advance by its 1 Wh resolution while the power figure legitimately + repeats. Thresholding that would rebuild the very limit cycle the + arrival stamp just removed, at the exact operating point we aim for. + Whoever wires a freeze detector must handle the low-power case first. + """ + if self._changed_mono is None: + return None + return max(0.0, time.monotonic() - self._changed_mono) + + async def run(self) -> None: + """Long-lived task: poll, submit, sleep, forever. + + ⚠️ Nothing but cancellation may end this loop. An escaping exception + would kill the poll task for the lifetime of the add-on, and it would do + it QUIETLY: the failure mode is safe (no submissions, the age climbs, the + watchdog holds the battery at 0 W) but it looks identical to a dead + meter, so the operator goes hunting the wrong device. Retry on the next + tick instead - poll_s is already the retry cadence, so no backoff. + """ + _LOG.info("P1 ingest: polling %s every %.1fs", self.url, self.poll_s) + while True: + try: + await self.poll_once() + except asyncio.CancelledError: + raise + except Exception as err: # noqa: BLE001 - the task must outlive it + _LOG.warning("P1 meter poll %s: %s - retrying in %.1fs", self.url, + str(err) or type(err).__name__, self.poll_s) + await asyncio.sleep(self.poll_s) + + async def poll_once(self) -> bool: + """One GET. Returns True if a sample was accepted.""" + self.polls += 1 + try: + async with self.session.get( + self.url, + timeout=aiohttp.ClientTimeout(total=self.timeout_s)) as resp: + if resp.status != 200: + raise P1Error(f"meter returned HTTP {resp.status}") + # content_type=None: the meter's own firmware is the authority on + # what it serves, and refusing a reading over a Content-Type + # header would be a fabricated outage. + # ⚠️ Kept because a real HomeWizard firmware that ever answers + # text/plain would otherwise read as a dead meter and hold the + # battery at 0 W. (This was once recorded here as an equivalent + # mutant - it is not. aiohttp's json_response always sets + # application/json, but web.Response(text=...) defaults to + # text/plain, so the fake meter CAN serve valid JSON under the + # wrong header, and test_p1.py now does.) + doc = await resp.json(content_type=None) + except asyncio.CancelledError: + raise + except Exception as err: # noqa: BLE001 - any read failure is a gap + self.connected = False + self.poll_errors += 1 + # ⚠️ str() of a bare asyncio.TimeoutError is the EMPTY STRING, so + # f"...: {err}" renders "last error:" and then nothing - on the + # status page, on a hung meter, at the moment the battery has just + # dropped to 0 W and someone is reading that line to find out why. + # The class name is the only thing that says "it timed out". + # ⚠️ str(err), not `err or ...`: an exception object is ALWAYS truthy, + # empty message or not, so the `or` would never reach the fallback. + self.ingest.reject( + f"meter poll {self.url}: {str(err) or type(err).__name__}") + return False + + self.connected = True + try: + sample = parse_homewizard(doc, self.ingest.phases) + # ⚠️ Submitted unconditionally, INCLUDING a document identical to the + # last one. The response is the arrival; the number is not. + # Inside the try on purpose: submit() outside it would put a raise on + # a path with no handler at all, killing the poll task permanently - + # safely (the age climbs) but silently. run() backstops the rest. + self.ingest.submit(sample) + self._note(doc) + except P1Error as err: + self.ingest.reject(err) + return False + return True + + def _note(self, doc) -> None: + fp = measurement_fingerprint(doc) + if self._changed_mono is None or fp != self._fingerprint: + self._fingerprint = fp + self._changed_mono = time.monotonic() + + # --------------------------------------------------------------------------- # # selection # --------------------------------------------------------------------------- # @@ -851,6 +1082,55 @@ def build_source(opts: dict, ingest: P1Ingest, session, broker: dict | None): return None return HaSignedSource(session, ingest, {"net": net, "phase_net": phase_net}) + if source == SOURCE_HOMEWIZARD: + host = str(opts.get("p1_host", "") or "").strip() + # A pasted browser URL is the obvious way to fill this in wrong. + host = host.removeprefix("http://").removeprefix("https://").rstrip("/") + host, _, port_s = host.partition(":") + # ⚠️ Checked ONCE here, like the ha_signed entity ids and for the same + # reason: a misconfigured source otherwise fails in the one way that + # looks exactly like a healthy one nobody has sent anything to yet - no + # samples, a climbing age, the watchdog holding the battery at 0 W, and + # nothing in the log saying why. + if not host: + _LOG.error("meter_source %s needs p1_host (the meter's own address, " + "e.g. 192.168.2.250) - P1 ingestion disabled", + SOURCE_HOMEWIZARD) + return None + try: + port = int(port_s) if port_s else HOMEWIZARD_PORT + except ValueError: + _LOG.error("p1_host %r has an unparseable port - P1 ingestion disabled", + opts.get("p1_host")) + return None + try: + # ⚠️ Not `or 5`: that turns an explicit 0 into the default, and a + # cadence of 0 is a misconfiguration that must be reported, not + # quietly corrected into something that looks like it was asked for. + raw = opts.get("meter_poll_s", 5) + poll_s = float(5 if raw is None or raw == "" else raw) + except (TypeError, ValueError): + _LOG.error("meter_poll_s %r is not a number - P1 ingestion disabled", + opts.get("meter_poll_s")) + return None + if poll_s <= 0: + _LOG.error("meter_poll_s must be positive - P1 ingestion disabled") + return None + # ⚠️ The age can never be fresher than the poll interval. At or past + # max_age_s every reading is stale before its successor arrives, so the + # controller would sit permanently on missing inputs while the meter is + # perfectly healthy - refuse it rather than ship that. + if poll_s >= ingest.max_age_s: + _LOG.error("meter_poll_s %.1f is not under meter_max_age_s %.1f - every " + "reading would go stale before the next poll. P1 ingestion " + "disabled.", poll_s, ingest.max_age_s) + return None + if poll_s > ingest.max_age_s / 2: + _LOG.warning("meter_poll_s %.1f leaves no room under meter_max_age_s " + "%.1f: one missed poll makes the reading stale. The meter " + "updates every ~5 s; 5 s against 30 s is the tested pair.", + poll_s, ingest.max_age_s) + return HomeWizardLocalSource(session, ingest, host, port=port, poll_s=poll_s) if source == SOURCE_MQTT: broker = broker or {} return MqttP1Source(ingest, str(opts.get("meter_mqtt_topic", "")), @@ -858,5 +1138,6 @@ def build_source(opts: dict, ingest: P1Ingest, session, broker: dict | None): broker.get("username"), broker.get("password")) if source: _LOG.error("meter_source %r is not one of %s - P1 ingestion disabled", - source, ", ".join((SOURCE_HA, SOURCE_MQTT, SOURCE_HA_SIGNED))) + source, ", ".join((SOURCE_HA, SOURCE_MQTT, SOURCE_HA_SIGNED, + SOURCE_HOMEWIZARD))) return None diff --git a/goodwe_controller/config.yaml b/goodwe_controller/config.yaml index d868d65..75be509 100644 --- a/goodwe_controller/config.yaml +++ b/goodwe_controller/config.yaml @@ -45,10 +45,21 @@ options: # integration's entities over the HA WebSocket; mqtt_p1 reads the topic below; # ha_signed reads ONE signed HA entity (+ import / - export), which is what a # HomeWizard P1 publishes and what ha_dsmr cannot consume. + # homewizard_local skips Home Assistant entirely and polls the meter's own + # local API. ⚠️ It is the ONLY mode whose sensor.p1_sample_age_s measures when + # the meter REPORTED rather than when the value last CHANGED - HA emits + # nothing at all for a repeated reading - so it is the only mode a firmware + # watchdog may threshold. See DOCS.md. meter_source: "off" meter_phases: 1 meter_max_age_s: 30 meter_mqtt_topic: "" + # homewizard_local only. The meter's own address, `host` or `host:port`. + p1_host: "" + # homewizard_local only. Seconds between polls. Must be well under + # meter_max_age_s - the age is never fresher than this interval - and there is + # nothing to gain below the meter's own ~5.0 s update rate (NOTES.md:640). + meter_poll_s: 5 # The two UNSIGNED Belgian registers. The EMS derives net power from them # (import - export); do NOT point these at a signed template sensor. p1_import_entity: "" @@ -109,14 +120,16 @@ schema: batt_invert: bool setpoint_entity: str - meter_source: list(off|ha_dsmr|mqtt_p1|ha_signed) + meter_source: list(off|ha_dsmr|mqtt_p1|ha_signed|homewizard_local) # ⚠️ 2 is accepted by this range but is not a real Belgian connection. A # telegram whose phase count disagrees is rejected at ingest and logged, so a # mis-set 2 shows up immediately as "0 telegrams accepted" rather than as a # quietly wrong number. meter_phases: int(1,3) meter_max_age_s: int(5,300) + meter_poll_s: int(1,60) meter_mqtt_topic: str? + p1_host: str? p1_import_entity: str? p1_export_entity: str? p1_phase_import_entities: diff --git a/goodwe_controller/test_p1.py b/goodwe_controller/test_p1.py index c5ad246..fdd24d2 100644 --- a/goodwe_controller/test_p1.py +++ b/goodwe_controller/test_p1.py @@ -11,6 +11,7 @@ computes the wrong quarter-hour figure whenever the telegram cadence changes. """ import asyncio +import json import sys import time from datetime import datetime, timedelta, timezone @@ -18,9 +19,11 @@ from datetime import datetime, timedelta, timezone import aiohttp # already required by app.p1, so this adds no new dependency from app.p1 import ( - P1Error, P1Ingest, HaDsmrSource, HaSignedSource, QuarterAverager, - SOURCE_HA, SOURCE_HA_SIGNED, SOURCE_MQTT, - build_source, is_enabled, make_sample, parse_mqtt_payload, split_signed, + P1Error, P1Ingest, HaDsmrSource, HaSignedSource, HomeWizardLocalSource, + QuarterAverager, + SOURCE_HA, SOURCE_HA_SIGNED, SOURCE_HOMEWIZARD, SOURCE_MQTT, + build_source, is_enabled, make_sample, parse_homewizard, parse_mqtt_payload, + split_signed, ) fails = [] @@ -862,6 +865,432 @@ check("no phase list at all is still fine - per-phase billing is optional", {"meter_source": SOURCE_HA_SIGNED, "p1_net_entity": "sensor.n"}, P1Ingest(phases=3), None, None), HaSignedSource)) +# --------------------------------------------------------------------------- # +print("homewizard_local: parsing the meter's own /api/v1/data document") +# The same JSON `sim/hwsim.py` serves and the same JSON the real meter at +# 192.168.2.250 serves. One SIGNED connection figure plus three signed legs, so +# it goes through the same split_signed as ha_signed - the sign convention is +# not re-derived on this transport, it is reused. + + +def hw_doc(w, l1=None, l2=None, l3=None, imp_kwh=1234.567, exp_kwh=890.123): + return {"wifi_ssid": "sim", "smr_version": 50, "meter_model": "SIM-P1", + "total_power_import_kwh": imp_kwh, "total_power_export_kwh": exp_kwh, + "active_power_w": w, + "active_power_l1_w": l1, "active_power_l2_w": l2, + "active_power_l3_w": l3, "total_gas_m3": 0.0} + + +s = parse_homewizard(hw_doc(775.0, l1=775.0), 1) +check("the overnight base load (+775 W) is import on this transport too", + s.net_w == 775.0 and s.import_w == 775.0 and s.export_w == 0.0) +check("the sample is tagged homewizard_local", s.source == SOURCE_HOMEWIZARD) +s = parse_homewizard(hw_doc(-5710.0, l1=-5710.0), 1) +check("the midday PV peak (-5710 W) is export, not a 5.7 kW draw", + s.net_w == -5710.0 and s.export_w == 5710.0) +check("a single-phase document still yields its one leg", s.per_phase_w == (-5710.0,)) + +# The TEL-04 three-phase survey reading, served the HomeWizard way. +s = parse_homewizard(hw_doc(187.0, 2301.0, 468.0, -2582.0), 3) +check("signed legs keep the exporting phase negative", + s.per_phase_w == (2301.0, 468.0, -2582.0)) +check("per-phase import clamps the exporting leg out of the billed figure", + s.per_phase_import_w == (2301.0, 468.0, 0.0)) +check("the legs sum to 2769 W while the connection nets 187 W", + sum(s.per_phase_import_w) == 2769.0 and s.net_w == 187.0) + +# ⚠️ A single-phase HomeWizard serves `active_power_l2_w: null`. Unlike the HA +# transports there is no "wait for the rest" case - every field came out of ONE +# response, so a missing leg cannot be a STALE leg. Dropping the whole sample +# over it would turn a healthy meter into a climbing age, which is the exact +# false trip this transport exists to remove. +def parsed(doc, phases=1): + """parse_homewizard(), with an escaping exception turned into a visible value. + + Same reason as `built()` above: if the "all legs or none" guard goes missing, + parsing raises, and without this the suite aborts on a traceback instead of + reddening the NAMED check that says which rule died. + """ + try: + return parse_homewizard(doc, phases) + except Exception as err: # noqa: BLE001 - a raise here is itself the failure + print(f" parse_homewizard raised {type(err).__name__}: {err}") + return err + + +s = parsed(hw_doc(500.0, l1=500.0), 3) +check("a meter serving fewer legs than meter_phases still gives a reading", + getattr(s, "net_w", None) == 500.0 and getattr(s, "per_phase_w", "?") is None) +check("...and does not fabricate a partial phase tuple", + getattr(s, "per_phase_import_w", "?") is None) + +raises("a response that is not a JSON object is rejected", + lambda: parse_homewizard([1, 2, 3], 1)) +raises("a document with no active_power_w is rejected, not read as 0 W", + lambda: parse_homewizard({"total_gas_m3": 0.0}, 1)) +raises("a null active_power_w is rejected", lambda: parse_homewizard(hw_doc(None), 1)) +raises("a string active_power_w is rejected", lambda: parse_homewizard(hw_doc("775"), 1)) +raises("a NaN active_power_w is rejected", + lambda: parse_homewizard(hw_doc(float("nan")), 1)) +raises("the 64954 signed-decode contamination is refused here too", + lambda: parse_homewizard(hw_doc(64954.0), 1)) +raises("a non-numeric leg is rejected rather than quietly dropped", + lambda: parse_homewizard(hw_doc(600.0, "2000", 0.0, 100.0), 3)) + +# --------------------------------------------------------------------------- # +print("homewizard_local: every HTTP response is an arrival") +# THE WHOLE TICKET. sensor.p1_sample_age_s has to mean "time since the meter +# REPORTED", not "time since the value CHANGED". Home Assistant cannot express +# the first: a repeated reading emits no state_changed, advances last_reported +# on neither serialiser, and state_reported cannot be subscribed to at all +# ("Event filter is required"). Measured on the ENV-01 rig over 70 s of a frozen +# meter and again against the live house over ten repeated readings. Our own +# capture of this house's meter goes 42.2 s and 97.0 s between changes, both past +# the default meter_max_age_s of 30 - i.e. the age sensor would command 0 W on a +# perfectly healthy meter. Polling the meter itself removes that: the response is +# the arrival, and the number in it is not consulted. + + +class _FakeMeter: + """hwsim in eight lines: serves whatever document it is told to, or a fault.""" + + def __init__(self, doc=None): + self.doc = doc + self.status = 200 + self.body = None # set to raw text to serve something unparseable + self.delay = 0.0 # set to seconds to imitate a meter that hangs + self.hits = 0 + + async def handle(self, request): + from aiohttp import web + self.hits += 1 + if self.delay: + await asyncio.sleep(self.delay) + # ⚠️ web.Response(text=...) defaults to text/plain. That is not just the + # "unparseable body" path: fed VALID json it serves a good document under + # the wrong mimetype, which is the only way to reach the content_type + # guard in poll_once - json_response can never produce it. + if self.body is not None: + return web.Response(text=self.body, status=self.status) + return web.json_response(self.doc, status=self.status) + + +async def _hw_rig(fn): + """Run `fn(make_source, meter)` against a real HTTP server on localhost.""" + from aiohttp import web + meter = _FakeMeter(hw_doc(350.0, l1=350.0)) + app = web.Application() + app.router.add_get("/api/v1/data", meter.handle) + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, "127.0.0.1", 0) + await site.start() + port = site._server.sockets[0].getsockname()[1] + async with aiohttp.ClientSession() as sess: + try: + return await fn(sess, port, meter) + finally: + await runner.cleanup() + + +async def _arrivals(sess, port, meter): + ing = P1Ingest(phases=1, max_age_s=30.0) + src = HomeWizardLocalSource(sess, ing, "127.0.0.1", port=port, poll_s=1.0) + out = {} + out["first"] = await src.poll_once() + out["stamp1"] = ing.last.ingest_mono + # Four more polls of the IDENTICAL document - the meter has not moved a watt. + for _ in range(4): + await asyncio.sleep(0.05) + await src.poll_once() + out["ing"], out["src"], out["meter"] = ing, src, meter + return out + + +r = asyncio.run(_hw_rig(_arrivals)) +ing, src = r["ing"], r["src"] +check("a poll of the meter's own API builds a sample", + r["first"] is True and ing.net_w == 350.0) +# ⚠️ THE acceptance criterion. Five identical readings, five arrivals. On +# ha_signed this whole sequence produces exactly ONE state_changed and then +# silence, and the age climbs to 30 s on a meter that is answering perfectly. +check("five identical readings are five arrivals, not one", + ing.samples == 5 and r["meter"].hits == 5 and src.polls == 5) +check("an unchanged value still stamps a NEW arrival time", + ing.last.ingest_mono > r["stamp1"]) +check("...so the age resets on a response carrying an unchanged number", + ing.published_age_s < 1.0 and ing.stale is False) +check("no arrival was ever a parse error", ing.parse_errors == 0) + +# --------------------------------------------------------------------------- # +print("homewizard_local: frozen vs steady, the pair HA cannot separate") +# ⚠️ Read this before changing anything here. A frozen meter (hwsim --fault +# freeze) answers 200 OK forever with a stale document. It is ARRIVING. So the +# age - an arrival detector, correctly - reads fresh on both, and that is not a +# defect in the age, it is the definition of the signal. What the local API adds +# that Home Assistant never had is the ENERGY REGISTERS: a meter under real load +# advances total_power_import_kwh (1 Wh resolution, ~10 s at 350 W) even when the +# power figure repeats, and a frozen one does not. That is the discriminator, and +# it is exposed as `unchanged_s` - deliberately NOT folded into the age, because +# this controller regulates grid power toward ~0 W and at a converged -10 W the +# export register needs six minutes to move. Thresholding unchanged_s at 30 s +# would rebuild the false-trip limit cycle at the exact operating point we aim +# for. See DOCS.md and HomeWizardLocalSource.unchanged_s. + + +async def _freeze_vs_steady(sess, port, meter): + ing_f = P1Ingest(phases=1, max_age_s=30.0) + frozen = HomeWizardLocalSource(sess, ing_f, "127.0.0.1", port=port, poll_s=1.0) + meter.doc = hw_doc(350.0, l1=350.0) # --fault freeze: never moves + for i in range(4): + if i: + await asyncio.sleep(0.1) # sleep BEFORE, so unchanged_s + await frozen.poll_once() # is read the instant a poll lands + + ing_s = P1Ingest(phases=1, max_age_s=30.0) + steady = HomeWizardLocalSource(sess, ing_s, "127.0.0.1", port=port, poll_s=1.0) + kwh = 1234.567 + for i in range(4): + if i: + await asyncio.sleep(0.1) + # A steady 350 W house: the power figure repeats, the register climbs. + kwh += 0.001 + meter.doc = hw_doc(350.0, l1=350.0, imp_kwh=round(kwh, 3)) + await steady.poll_once() + return (ing_f, frozen), (ing_s, steady) + + +(ing_f, frozen), (ing_s, steady) = asyncio.run(_hw_rig(_freeze_vs_steady)) +check("a frozen meter keeps arriving, so both read the same power", + ing_f.net_w == 350.0 and ing_s.net_w == 350.0) +# ⚠️ Recorded as a rule, not a shortcoming: the age is an ARRIVAL detector and a +# frozen meter genuinely is arriving. Anyone tempted to make the age catch freeze +# is about to reintroduce the false trip on a steady house. +check("the age cannot separate them, and is fresh on both", + ing_f.published_age_s < 1.0 and ing_s.published_age_s < 1.0) +check("the frozen meter's measurement stands still", frozen.unchanged_s > 0.25) +check("...while the steady meter's energy register keeps advancing", + steady.unchanged_s < 0.05) +check("so the two ARE separable on the local API, which HA could not do", + frozen.unchanged_s > steady.unchanged_s * 3) +check("unchanged_s is None before the first response ever lands", + HomeWizardLocalSource(None, P1Ingest(), "h").unchanged_s is None) + +# --------------------------------------------------------------------------- # +print("homewizard_local: a failed poll is a missing reading, never 0 W") + + +async def _failures(sess, port, meter): + ing = P1Ingest(phases=1, max_age_s=30.0) + src = HomeWizardLocalSource(sess, ing, "127.0.0.1", port=port, poll_s=1.0) + await src.poll_once() + # Age the good sample by hand so a reset would be unmistakable. + ing.submit(make_sample(SOURCE_HOMEWIZARD, 350.0, 0.0, phases=1, + ingest_mono=time.monotonic() - 90.0)) + out = {} + meter.status = 500 + out["http500"] = await src.poll_once() + out["conn_after_500"] = src.connected + meter.status, meter.body = 200, "gateway" + out["notjson"] = await src.poll_once() + meter.body = None + meter.doc = {"total_gas_m3": 0.0} # 200 OK, no power in it + out["nopower"] = await src.poll_once() + out["ing"], out["src"] = ing, src + + # And a meter that is not listening at all: `--fault down`. + dead = HomeWizardLocalSource(sess, P1Ingest(phases=1, max_age_s=30.0), + "127.0.0.1", port=1, poll_s=1.0) + out["down"] = await dead.poll_once() + out["dead"] = dead + return out + + +r = asyncio.run(_hw_rig(_failures)) +ing, src = r["ing"], r["src"] +check("an HTTP 500 is not a reading", r["http500"] is False) +# A source that was connected and then failed must SAY it is disconnected - +# otherwise the diagnostic reads healthy while the age climbs, which is exactly +# the "connected: True, parse_errors: 0, samples: 0" state the rig recorded. +check("a poll that fails clears the connected flag", r["conn_after_500"] is False) +check("a 200 OK carrying something that is not JSON is not a reading", + r["notjson"] is False) +check("a 200 OK with no active_power_w in it is not a reading", r["nopower"] is False) +check("a meter that refuses the connection is not a reading", + r["down"] is False and r["dead"].connected is False) +check("every failure was counted as a parse error", ing.parse_errors == 3) +check("...and the transport records the transport-level ones separately", + src.poll_errors == 2 and src.polls == 4) +# ⚠️ The rule the safety chain rests on. A failed poll must not manufacture a +# balanced house, and must not reset the clock the watchdog reads. +check("a failed poll does not reset the age", ing.published_age_s > 89) +check("a failed poll leaves the last good value in place, and it is not 0 W", + ing.last.net_w == 350.0 and ing.stale is True and ing.net_w is None) +check("no failed poll ever became a sample", ing.samples == 2) +check("a 200 OK marks the transport connected even when its body is refused", + src.connected is True) + +# --------------------------------------------------------------------------- # +print("homewizard_local: a wrong header, a hung meter, a submit that throws") +# Three failure shapes that all end the same way if they are mishandled - no +# sample, a climbing age, the battery at 0 W - and each of which would send the +# operator hunting the wrong device. + + +async def _header_and_timeout(sess, port, meter): + out = {} + # Valid JSON under text/plain: exactly what content_type=None is for. + meter.body = json.dumps(hw_doc(350.0, l1=350.0)) + ing = P1Ingest(phases=1, max_age_s=30.0) + src = HomeWizardLocalSource(sess, ing, "127.0.0.1", port=port, poll_s=1.0) + out["mimetype"] = await src.poll_once() + out["ing"] = ing + meter.body = None + + # A meter that takes the connection and then does not answer. + meter.delay = 0.6 + ing_t = P1Ingest(phases=1, max_age_s=30.0) + slow = HomeWizardLocalSource(sess, ing_t, "127.0.0.1", port=port, + poll_s=1.0, timeout_s=0.05) + out["timeout"] = await slow.poll_once() + out["timeout_err"] = ing_t.last_error + meter.delay = 0.0 + return out + + +r = asyncio.run(_hw_rig(_header_and_timeout)) +# ⚠️ A real firmware answering text/plain must not read as a dead meter. Drop +# content_type=None from poll_once and this goes red with "unexpected mimetype". +check("valid JSON under the wrong Content-Type is still a reading", + r["mimetype"] is True and r["ing"].samples == 1 and r["ing"].net_w == 350.0) +# ⚠️ str(asyncio.TimeoutError()) is the EMPTY STRING. Without the class-name +# fallback the status page reads "last error:" and then nothing, on a hung +# meter, at the moment someone is reading that line to find out why the battery +# went to 0 W. +check("a timed-out poll names the fault instead of logging an empty reason", + r["timeout"] is False and "TimeoutError" in (r["timeout_err"] or "")) + + +async def _submit_rejects(sess, port, meter): + """submit() raising P1Error must be a rejection, not an escaping exception.""" + class _P1Boom(P1Ingest): + def submit(self, sample): + raise P1Error("register went backwards") + + ing = _P1Boom(phases=1, max_age_s=30.0) + src = HomeWizardLocalSource(sess, ing, "127.0.0.1", port=port, poll_s=1.0) + try: + ok = await src.poll_once() + except Exception as err: # noqa: BLE001 - an escape IS the failure + ok = err + return ok, ing.parse_errors + + +ok, errs = asyncio.run(_hw_rig(_submit_rejects)) +check("a submit that rejects the sample is handled, not left to escape", + ok is False and errs == 1) + + +async def _submit_explodes(sess, port, meter): + """And an UNEXPECTED raise must not kill the poll task for good.""" + class _Boom(P1Ingest): + def submit(self, sample): + raise RuntimeError("kaboom") + + src = HomeWizardLocalSource(sess, _Boom(phases=1, max_age_s=30.0), + "127.0.0.1", port=port, poll_s=0.05) + task = asyncio.create_task(src.run()) + await asyncio.sleep(0.3) + alive = not task.done() + task.cancel() + # ⚠️ BaseException, and the same reason built() exists: if run() loses its + # guard the task is already dead HOLDING the RuntimeError, and awaiting it + # re-raises - aborting the whole suite with a traceback instead of reddening + # the check that names the rule. The death is what `alive` records; catching + # it here is bookkeeping, not leniency. + try: + await task + except BaseException: # noqa: BLE001 + pass + return alive, src.polls + + +# ⚠️ The silent-death case. A dead poll task fails SAFE - the age climbs and the +# controller commands 0 W - but it looks exactly like a dead meter, so the +# operator spends the outage power-cycling hardware that was never at fault. +alive, polls = asyncio.run(_hw_rig(_submit_explodes)) +check("a raise inside a poll does not permanently kill the polling task", + alive is True and polls > 1) + +# --------------------------------------------------------------------------- # +print("homewizard_local: selection by config") + +check("homewizard_local is enabled", is_enabled({"meter_source": SOURCE_HOMEWIZARD}) is True) +sel = build_source({"meter_source": SOURCE_HOMEWIZARD, "p1_host": "192.168.2.250"}, + P1Ingest(), None, None) +check("meter_source homewizard_local selects the polling transport", + isinstance(sel, HomeWizardLocalSource)) +# ⚠️ getattr, not attribute access, for the same reason `built()` exists: a +# startup guard that goes wrong returns None here, and `None.url` would abort the +# suite with a traceback instead of reddening the check that names the rule. +check("...pointed at the meter's own local API on the default port", + getattr(sel, "url", None) == "http://192.168.2.250:80/api/v1/data") +check("the default cadence is the meter's own ~5 s update rate", + getattr(sel, "poll_s", None) == 5.0) +# ⚠️ The request must not outlive the poll interval: a backlog of queued requests +# behind a slow meter would land several arrivals for one measurement. +check("the request timeout is held under the poll interval", + getattr(sel, "timeout_s", 99) < getattr(sel, "poll_s", 0)) + +sel = build_source({"meter_source": SOURCE_HOMEWIZARD, "p1_host": "10.0.0.9:8080"}, + P1Ingest(), None, None) +check("an explicit host:port is honoured, which is how the sim is reached", + getattr(sel, "url", None) == "http://10.0.0.9:8080/api/v1/data") +sel = build_source({"meter_source": SOURCE_HOMEWIZARD, + "p1_host": "http://192.168.2.250/"}, P1Ingest(), None, None) +check("a pasted browser URL still resolves to the right host", + getattr(sel, "url", None) == "http://192.168.2.250:80/api/v1/data") + +# ⚠️ Caught once at startup, not once per poll - a source wired up wrong +# otherwise fails in the one way indistinguishable from a healthy one nobody has +# polled yet: no samples, a climbing age, the watchdog at 0 W, nothing in the log. +check("a blank p1_host is refused rather than silently never polling", + build_source({"meter_source": SOURCE_HOMEWIZARD, "p1_host": ""}, + P1Ingest(), None, None) is None) +check("...and whitespace does not sneak past it", + build_source({"meter_source": SOURCE_HOMEWIZARD, "p1_host": " "}, + P1Ingest(), None, None) is None) +check("an unparseable port is refused rather than guessed", + build_source({"meter_source": SOURCE_HOMEWIZARD, "p1_host": "meter:eighty"}, + P1Ingest(), None, None) is None) +# ⚠️ The age can never be fresher than the poll interval, so a cadence at or past +# max_age_s means every reading is stale before its successor arrives: the +# controller would sit permanently on missing inputs while the meter is fine. +check("a poll cadence at meter_max_age_s is refused", + build_source({"meter_source": SOURCE_HOMEWIZARD, "p1_host": "m", + "meter_poll_s": 30}, P1Ingest(max_age_s=30), None, None) is None) +check("...and one past it too", + build_source({"meter_source": SOURCE_HOMEWIZARD, "p1_host": "m", + "meter_poll_s": 45}, P1Ingest(max_age_s=30), None, None) is None) +check("a cadence with headroom is accepted", + isinstance(build_source({"meter_source": SOURCE_HOMEWIZARD, "p1_host": "m", + "meter_poll_s": 5}, P1Ingest(max_age_s=30), + None, None), HomeWizardLocalSource)) +check("a zero cadence is refused rather than spinning", + build_source({"meter_source": SOURCE_HOMEWIZARD, "p1_host": "m", + "meter_poll_s": 0}, P1Ingest(max_age_s=30), None, None) is None) +check("a non-numeric cadence is refused", + build_source({"meter_source": SOURCE_HOMEWIZARD, "p1_host": "m", + "meter_poll_s": "fast"}, P1Ingest(max_age_s=30), None, None) is None) +# The four modes must not bleed into each other. +check("ha_signed ignores p1_host and still selects the entity transport", + type(build_source({"meter_source": SOURCE_HA_SIGNED, "p1_net_entity": "sensor.n", + "p1_host": "192.168.2.250"}, P1Ingest(), None, None)) + is HaSignedSource) +check("homewizard_local ignores p1_net_entity and needs its own host", + build_source({"meter_source": SOURCE_HOMEWIZARD, + "p1_net_entity": "sensor.n"}, P1Ingest(), None, None) is None) + # --------------------------------------------------------------------------- # print("the age sensor must not exist when P1 is off") # ⚠️ This is a fleet-wide regression guard, not a nicety. The ESP32 watchdog @@ -877,6 +1306,8 @@ check("a missing meter_source is disabled", is_enabled({}) is False) check("an empty meter_source is disabled", is_enabled({"meter_source": ""}) is False) check("ha_dsmr is enabled", is_enabled({"meter_source": SOURCE_HA}) is True) check("mqtt_p1 is enabled", is_enabled({"meter_source": SOURCE_MQTT}) is True) +check("homewizard_local is enabled here too", + is_enabled({"meter_source": SOURCE_HOMEWIZARD}) is True) # The entity id SAFETY-01's firmware subscribes to, pinned by object_id. row = [s for s in SENSORS if s[0] == "p1_age"] @@ -957,6 +1388,60 @@ Controller({"meter_source": SOURCE_HA_SIGNED}, None, _Store(), pub_sig).publish( check("with ha_signed selected, p1_age is published too", "p1_age" in pub_sig.last and isinstance(pub_sig.last["p1_age"], float)) +# ⚠️ And on homewizard_local, where it is the one age FW-01 may actually +# threshold - every other transport's age measures when the VALUE changed. +pub_hw = _Pub() +Controller({"meter_source": SOURCE_HOMEWIZARD}, None, _Store(), pub_hw).publish() +check("with homewizard_local selected, p1_age is published", + "p1_age" in pub_hw.last and isinstance(pub_hw.last["p1_age"], float)) + +# --------------------------------------------------------------------------- # +print("unchanged_s has somewhere an operator can read it") +# ⚠️ The counter is deliberately NOT thresholded and NOT folded into the age - +# at the converged -10 W this controller aims for, a 1 Wh register needs six +# minutes to move, so any limit false-trips at the target operating point. That +# refusal only holds up if a HUMAN can interpret the number instead, and DOCS.md +# tells them "the transport tracks it as unchanged_s". Before this, nothing ever +# read the transport object back: connected, polls, poll_errors and unchanged_s +# were all write-only, and the documented signal existed nowhere an operator +# could see it. + + +def p1_rows(ctl): + """The P1 status line(s), or the exception that stopped checks() making one. + + Same reason as built(): a status line that raises would abort the suite with + a traceback instead of reddening the check that names the rule. + """ + try: + return [c["text"] for c in ctl.checks() if c["text"].startswith("P1 meter")] + except Exception as err: # noqa: BLE001 - a raise here is itself the failure + print(f" checks() raised {type(err).__name__}: {err}") + return err + + +def _fed(source): + ctl = Controller({"meter_source": source}, None, _Store(), _Pub()) + ctl.p1.submit(make_sample(source, 350.0, 0.0, phases=1, + ingest_mono=time.monotonic())) + return ctl + + +hw_ctl = _fed(SOURCE_HOMEWIZARD) +hw_src = HomeWizardLocalSource(None, hw_ctl.p1, "127.0.0.1") +hw_src._note(hw_doc(350.0, l1=350.0)) +hw_ctl.p1_source = hw_src # what amain() does once the transport exists +rows = p1_rows(hw_ctl) +check("the healthy P1 status line reports the transport's unchanged_s", + isinstance(rows, list) and len(rows) == 1 and "unchanged" in rows[0]) + +# ⚠️ getattr, not attribute access: p1_source is None until amain() builds one, +# and ha_dsmr/ha_signed/mqtt_p1 have no such counter at all. Reaching for it +# directly would turn the whole status page into a 500 on every other transport. +ha_rows = p1_rows(_fed(SOURCE_HA)) +check("...and the line is unharmed on a transport that has no such counter", + isinstance(ha_rows, list) and len(ha_rows) == 1 and "unchanged" not in ha_rows[0]) + print() if fails: print(f"{len(fails)} of {total} FAILED: {', '.join(fails)}")