TEL-04: a third meter_source for a single signed entity
TEL-01 shipped ha_dsmr and mqtt_p1, and neither can read the meter that is
actually fitted here. The house has a HomeWizard P1 exposing ONE signed
entity, sensor.p1_meter_active_power (+ import, - export); ha_dsmr wants two
unsigned registers and refuses a negative one outright, which is every
exporting telegram. So sensor.p1_sample_age_s could not be produced at this
site, and FW-01's watchdog needs it - measured, not theoretical: the house P1
went 51.1 s and 36.2 s without a state change overnight, both past
meter_max_age_s 30, so without the age sensor the watchdog would false-trip
the battery to 0 W.
Adds meter_source: ha_signed, reading p1_net_entity (and optionally
p1_phase_net_entities in L1..L3 order for the capacity-tariff peak). The
derivation is split_signed(), sitting next to make_sample's subtraction for
the same reason it does - the moment a user is asked to write two template
sensors that split a signed value, the sign convention is back in unreviewed
YAML underneath a safety input, which is exactly what TEL-01 removed.
The transport is a subclass of HaDsmrSource overriding only _wanted() and
build(), so every rule TEL-01 established is inherited rather than
re-implemented: ingest timestamping, meter_max_age_s, the clock-recomputed
sensor.p1_sample_age_s republished ~1 Hz, the plausibility ceiling, the
"prime the cache from get_states but never build a sample out of it" rule,
"a reconnect emits nothing", and unavailable/unknown treated as a MISSING
reading and never as 0 W.
Defaults to off. An existing install is unaffected until it opts in.
test_p1.py: 122 -> 174 checks. Includes an end-to-end run of the new
transport against a fake Home Assistant websocket, and the sign convention
asserted against real captured readings from
sim/scenarios/ha-p1_meter_active_power-2026-08-{20,23}.json (-5710 W at
13:46 local under full sun is export; +775 W at midnight is import).
Non-vacuity: ten mutations of the new rules, each applied alone and reverted
byte-identical. Nine turn the suite red. The tenth - splitting the per-phase
signed values rather than passing them through - is an equivalent mutant,
because make_sample subtracts the two lists again and does not sign-check
per-phase figures. That is recorded in a ponytail: comment at the site rather
than left for the next reviewer to rediscover.
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
c24bc0a011
commit
8b51a51e20
+122
-6
@@ -4,13 +4,22 @@ Everything downstream trusts this module: the safety checks, the capacity-tariff
|
||||
peak, the optimizer, the control loop's sign. So three things happen here and
|
||||
nowhere else.
|
||||
|
||||
1. The IMPORT/EXPORT DERIVATION. A Belgian P1 meter exposes two UNSIGNED
|
||||
registers - consumption and injection - never one signed figure. Net power
|
||||
is `import_w - export_w`, positive = import, and that subtraction is done
|
||||
1. The IMPORT/EXPORT DERIVATION. A Belgian P1 read over DSMR exposes two
|
||||
UNSIGNED registers - consumption and injection. Net power is
|
||||
`import_w - export_w`, positive = import, and that subtraction is done
|
||||
exactly once, here (spec §5.2: "the derivation is the EMS's job, not a
|
||||
template the user has to write"). A second copy of it somewhere else is a
|
||||
second chance to invert the control loop.
|
||||
|
||||
Some P1 readers - the HomeWizard P1 among them - publish the OTHER shape:
|
||||
one SIGNED figure, positive = import, and no unsigned registers at all.
|
||||
`split_signed()` fans that back out into the same two magnitudes, so there
|
||||
is still exactly one internal representation and one sign convention. ⚠️ It
|
||||
lives here, next to the subtraction, for the same reason the subtraction
|
||||
does: the moment a user is asked to write two template sensors that split a
|
||||
signed value, the sign convention is back in unreviewed YAML underneath a
|
||||
safety input, which is precisely what §5.2 moved into the EMS.
|
||||
|
||||
2. THE INGEST TIMESTAMP. Every accepted sample is stamped on arrival. A value
|
||||
with no age is a value that cannot be trusted (§5.2), and staleness is the
|
||||
failsafe trigger (§11.2).
|
||||
@@ -47,6 +56,7 @@ _LOG = logging.getLogger("goodwe.p1")
|
||||
|
||||
SOURCE_HA = "ha_dsmr"
|
||||
SOURCE_MQTT = "mqtt_p1"
|
||||
SOURCE_HA_SIGNED = "ha_signed"
|
||||
|
||||
QUARTER_S = 900
|
||||
|
||||
@@ -78,7 +88,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: str # SOURCE_HA | SOURCE_MQTT | SOURCE_HA_SIGNED
|
||||
import_w: float # unsigned magnitude, as the meter reports it
|
||||
export_w: float # unsigned magnitude
|
||||
net_w: float # import_w - export_w (+ import, - export)
|
||||
@@ -129,6 +139,29 @@ def _watts(value, what: str) -> float:
|
||||
return out
|
||||
|
||||
|
||||
def split_signed(net_w) -> tuple[float, float]:
|
||||
"""One signed figure -> the (import, export) magnitudes the module speaks.
|
||||
|
||||
The inverse of make_sample's subtraction, and the easy direction: no second
|
||||
register to disagree with, so there is nothing to mix a fresh reading with a
|
||||
stale one. `+` is import, `-` is export - verified in test_p1.py against real
|
||||
captured readings from this house's own meter, not against a datasheet.
|
||||
|
||||
⚠️ Exactly one of the two comes out non-zero. Splitting into `(max(v,0),
|
||||
max(-v,0))` rather than clamping keeps `import_w - export_w == v` exactly, so
|
||||
the signed value the meter published survives the round trip bit for bit -
|
||||
a control loop must not be steered by a number that changed on the way in.
|
||||
|
||||
⚠️ Validation is `_watts`, the same gate the unsigned path uses: NaN,
|
||||
infinity, non-numbers and the §20 open-question-5 unsigned-decode
|
||||
contamination (64954 for -582 W) are all refused here rather than believed.
|
||||
A signed source makes that check MORE important, not less - on this path
|
||||
64954 is not obviously wrong the way a negative "unsigned" register is.
|
||||
"""
|
||||
v = _watts(net_w, "net")
|
||||
return (v, 0.0) if v >= 0 else (0.0, -v)
|
||||
|
||||
|
||||
def make_sample(source: str, import_w, export_w, *, phases: int,
|
||||
phase_import_w=None, phase_export_w=None,
|
||||
telegram_ts: datetime | None = None,
|
||||
@@ -668,6 +701,84 @@ class MqttP1Source:
|
||||
self.ingest.reject(err)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# transport 3: Home Assistant WebSocket, one signed entity
|
||||
# --------------------------------------------------------------------------- #
|
||||
class HaSignedSource(HaDsmrSource):
|
||||
"""The same websocket, subscribed to ONE signed power entity.
|
||||
|
||||
For readers that publish net power as a single signed figure - a HomeWizard
|
||||
P1's `sensor.p1_meter_active_power`, positive = import - rather than the two
|
||||
unsigned DSMR registers. This is the meter actually installed at the house,
|
||||
and `ha_dsmr` cannot read it: it needs two registers and refuses a negative
|
||||
one outright, which is every exporting telegram.
|
||||
|
||||
⚠️ A subclass, not a copy. The connect / auth / subscribe / reconnect /
|
||||
`_absorb` machinery above is transport, not shape, and it has already been
|
||||
debugged once - notably "prime the cache from get_states but never build a
|
||||
sample out of it" and "a reconnect emits nothing". Only `_wanted` (which
|
||||
entity ids) and `build` (how they become a sample) differ, so only those two
|
||||
are overridden. Everything TEL-01 established therefore applies unchanged:
|
||||
ingest timestamping, meter_max_age_s, the clock-recomputed age sensor, and
|
||||
`unavailable` treated as a missing reading rather than 0 W.
|
||||
|
||||
⚠️ The debounce is inherited but does nothing useful here, and that is fine:
|
||||
one telegram is one entity, so there is no burst of per-entity events to
|
||||
coalesce and no window in which a new reading sits beside a stale one. It
|
||||
costs one scheduled sleep per telegram at ~0.2 Hz. Left in place rather than
|
||||
special-cased, because a second code path through build() is a second place
|
||||
for the sign to go wrong.
|
||||
"""
|
||||
|
||||
def _wanted(self) -> set[str]:
|
||||
out = set()
|
||||
if self.entities.get("net"):
|
||||
out.add(self.entities["net"])
|
||||
out.update(e for e in self.entities.get("phase_net") or [] if e)
|
||||
return out
|
||||
|
||||
def build(self) -> bool:
|
||||
"""Assemble one sample from the cache. Returns True if one was accepted."""
|
||||
net_id = self.entities.get("net")
|
||||
if net_id not in self.cache:
|
||||
return False
|
||||
pn = [self.cache.get(e) for e in self.entities.get("phase_net") or []]
|
||||
if pn and None in pn:
|
||||
return False # incomplete phase set: wait, do not guess
|
||||
try:
|
||||
imp, exp = split_signed(self.cache[net_id])
|
||||
pi = pe = None
|
||||
if pn:
|
||||
# ponytail: this split is arithmetically redundant today -
|
||||
# make_sample subtracts the two lists again and does not
|
||||
# sign-check per-phase figures, so handing it the signed values
|
||||
# with a zero export list produces the identical tuple. Verified:
|
||||
# mutating it that way leaves all 174 checks green, i.e. no test
|
||||
# can tell the difference, and it is recorded here rather than
|
||||
# left as a silent equivalent mutant for the next reviewer to
|
||||
# rediscover. Kept because `phase_import_w` means a MAGNITUDE:
|
||||
# a negative in it is the double-signing that make_sample refuses
|
||||
# outright for the connection-level registers, and the day that
|
||||
# check is extended per-phase the shortcut breaks the meter, not
|
||||
# the test.
|
||||
pairs = [split_signed(v) for v in pn]
|
||||
pi = [a for a, _ in pairs]
|
||||
pe = [b for _, b in pairs]
|
||||
self.ingest.submit(make_sample(
|
||||
SOURCE_HA_SIGNED, imp, exp,
|
||||
phases=self.ingest.phases,
|
||||
phase_import_w=pi, phase_export_w=pe,
|
||||
# ⚠️ No telegram_ts, for the same reason as ha_dsmr: HA's
|
||||
# last_changed is when the VALUE changed, which on a steady meter
|
||||
# is minutes ago while the telegram is current. sensor.
|
||||
# p1_sample_age_s is what covers a genuinely frozen meter.
|
||||
))
|
||||
return True
|
||||
except P1Error as err:
|
||||
self.ingest.reject(err)
|
||||
return False
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# selection
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -699,12 +810,17 @@ def build_source(opts: dict, ingest: P1Ingest, session, broker: dict | None):
|
||||
"phase_import": opts.get("p1_phase_import_entities") or [],
|
||||
"phase_export": opts.get("p1_phase_export_entities") or [],
|
||||
})
|
||||
if source == SOURCE_HA_SIGNED:
|
||||
return HaSignedSource(session, ingest, {
|
||||
"net": opts.get("p1_net_entity", ""),
|
||||
"phase_net": opts.get("p1_phase_net_entities") or [],
|
||||
})
|
||||
if source == SOURCE_MQTT:
|
||||
broker = broker or {}
|
||||
return MqttP1Source(ingest, str(opts.get("meter_mqtt_topic", "")),
|
||||
broker.get("host"), broker.get("port", 1883),
|
||||
broker.get("username"), broker.get("password"))
|
||||
if source:
|
||||
_LOG.error("meter_source %r is not %s or %s - P1 ingestion disabled",
|
||||
source, SOURCE_HA, SOURCE_MQTT)
|
||||
_LOG.error("meter_source %r is not one of %s - P1 ingestion disabled",
|
||||
source, ", ".join((SOURCE_HA, SOURCE_MQTT, SOURCE_HA_SIGNED)))
|
||||
return None
|
||||
|
||||
Reference in New Issue
Block a user