TEL-05: read the meter, not Home Assistant's opinion of the meter
A fourth meter_source, `homewizard_local`, polling a HomeWizard P1's own local API (GET /api/v1/data) instead of watching an HA entity. The point is the age sensor. sensor.p1_sample_age_s is FW-01's watchdog input, and on every transport we had it measured "time since the value CHANGED", not "time since the meter REPORTED". Home Assistant offers nothing better: a repeated reading emits no state_changed, advances last_reported on neither serialiser, and state_reported cannot be subscribed to at all. Measured twice - 70 s of a frozen meter on the ENV-01 rig, and ten repeated readings against the live house. 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, so the age sensor would have commanded 0 W on a perfectly healthy meter. Here every HTTP response is an arrival. The meter answered, now, with its current reading; whether the number moved is not consulted. Five identical readings are five arrivals. Reuses TEL-01's pipeline rather than restructuring it: same split_signed sign convention as ha_signed, same make_sample, same ingest stamping, meter_max_age_s, clock-recomputed age, plausibility bounds and the §20 unsigned-decode rejection. A failed or timed-out poll submits nothing, so it is a missing reading - never 0 W - and does not reset the age. meter_poll_s (default 5 s, the meter's own rate) is checked against meter_max_age_s once at startup, like the ha_signed entity ids. ⚠️ An arrival stamp cannot see a FROZEN meter, and no arrival detector can - one answering 200 OK with a stale number is arriving. The local API does expose what HA 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 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 would rebuild the false-trip limit cycle at the exact operating point we aim for. test_p1.py 179 -> 236 checks. Still defaults to off. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
This commit is contained in:
co-authored by
Claude Opus 5
parent
ad9c5772a4
commit
98109a9b91
+255
-2
@@ -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,208 @@ 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
|
||||
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."""
|
||||
_LOG.info("P1 ingest: polling %s every %.1fs", self.url, self.poll_s)
|
||||
while True:
|
||||
await self.poll_once()
|
||||
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.
|
||||
# ⚠️ Known EQUIVALENT MUTANT: dropping this argument leaves all
|
||||
# 236 checks green, because aiohttp's own json_response - which
|
||||
# the fake meter in test_p1.py uses - always sets
|
||||
# application/json, so no test can serve valid JSON under a
|
||||
# wrong header. Recorded here rather than left for the next
|
||||
# reviewer to rediscover. It is 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.
|
||||
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
|
||||
self.ingest.reject(f"meter poll {self.url}: {err}")
|
||||
return False
|
||||
|
||||
self.connected = True
|
||||
try:
|
||||
sample = parse_homewizard(doc, self.ingest.phases)
|
||||
except P1Error as err:
|
||||
self.ingest.reject(err)
|
||||
return False
|
||||
# ⚠️ Submitted unconditionally, INCLUDING a document identical to the
|
||||
# last one. The response is the arrival; the number is not.
|
||||
self.ingest.submit(sample)
|
||||
self._note(doc)
|
||||
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 +1054,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 +1110,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
|
||||
|
||||
Reference in New Issue
Block a user