Compare commits
2
Commits
632be44f6c
...
TEL-04
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b343da8e3 | ||
|
|
e663e10245 |
@@ -20,6 +20,20 @@ across the three sources.
|
|||||||
|
|
||||||
Still defaults to `off`; an existing install is unaffected until it opts in.
|
Still defaults to `off`; an existing install is unaffected until it opts in.
|
||||||
|
|
||||||
|
⚠️ **`sensor.p1_sample_age_s` is published on `ha_signed`, but must not yet be
|
||||||
|
thresholded by the ESP32 stale-input watchdog.** On the HA WebSocket paths the
|
||||||
|
age is stamped from `state_changed`, so it measures time since the value
|
||||||
|
*changed*, not since the meter *reported* - and Home Assistant exposes no
|
||||||
|
arrival signal for a repeated reading (no `state_changed`, no `last_reported`
|
||||||
|
movement on either serialiser, and `state_reported` is not subscribable over
|
||||||
|
the WebSocket). Measured on the ENV-01 rig against the real HomeWizard
|
||||||
|
integration. `ha_dsmr` mostly escapes it because a telegram moves several
|
||||||
|
entities at once; `ha_signed` has one, so a healthy meter under a flat load is
|
||||||
|
indistinguishable from a dead one. Our own capture has the house meter going
|
||||||
|
42.2 s and 97.0 s between changes. Raising `meter_max_age_s` does not fix that,
|
||||||
|
it only chooses which error you get; the fix is an arrival stamp from the meter
|
||||||
|
itself and is a separate ticket. Full detail in DOCS.md.
|
||||||
|
|
||||||
## 0.3.0
|
## 0.3.0
|
||||||
|
|
||||||
**SAFETY-04.** The control law's integrator is now an explicit accumulator,
|
**SAFETY-04.** The control law's integrator is now an explicit accumulator,
|
||||||
|
|||||||
@@ -70,6 +70,18 @@ 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
|
`meter_source` is that the sign convention is derived in one tested place rather
|
||||||
than in YAML nobody reviews underneath a safety input.
|
than in YAML nobody reviews underneath a safety input.
|
||||||
|
|
||||||
|
> ⚠️ **`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
|
||||||
|
> registers, and the meter actually fitted here does not — it is the HomeWizard
|
||||||
|
> P1 that `ha_signed` reads. They are covered by the unit checks in `test_p1.py`
|
||||||
|
> and by an end-to-end test against a fake Home Assistant, and nothing more.
|
||||||
|
>
|
||||||
|
> This is recorded because the realistic way it bites is someone debugging a
|
||||||
|
> meter problem months from now treating those two paths as proven and looking
|
||||||
|
> for the fault elsewhere. If you are the first person to point one at a real
|
||||||
|
> meter, expect to find something, and please update this note when you do.
|
||||||
|
|
||||||
| option | default | meaning |
|
| 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 |
|
||||||
@@ -81,7 +93,16 @@ than in YAML nobody reviews underneath a safety input.
|
|||||||
| `p1_phase_import_entities` | `[]` | L1..L3, in order. Needed for the capacity-tariff peak on a three-phase connection |
|
| `p1_phase_import_entities` | `[]` | L1..L3, in order. Needed for the capacity-tariff peak on a three-phase connection |
|
||||||
| `p1_phase_export_entities` | `[]` | L1..L3, in order |
|
| `p1_phase_export_entities` | `[]` | L1..L3, in order |
|
||||||
| `p1_net_entity` | | `ha_signed` only. The **signed** net-power sensor: `+` import, `-` export |
|
| `p1_net_entity` | | `ha_signed` only. The **signed** net-power sensor: `+` import, `-` export |
|
||||||
| `p1_phase_net_entities` | `[]` | `ha_signed` only. L1..L3, in order, each signed the same way. Needed for the capacity-tariff peak on a three-phase connection |
|
| `p1_phase_net_entities` | `[]` | `ha_signed` only. L1..L3, in order, each signed the same way. Needed for the capacity-tariff peak on a three-phase connection. **The list length must equal `meter_phases`** |
|
||||||
|
|
||||||
|
Both per-phase lists are checked against `meter_phases` **once at startup**: a
|
||||||
|
list of the wrong length disables P1 ingestion with an error in the log, rather
|
||||||
|
than letting every telegram fail its phase-count check one at a time. Leaving
|
||||||
|
the list empty is fine and is not an error — you simply get no per-phase
|
||||||
|
figures, and therefore no capacity-tariff peak. On a three-phase connection
|
||||||
|
that is a much bigger omission than it looks: on a surveyed reading here the
|
||||||
|
phases carried 2769 W of import while the connection netted 187 W, so the
|
||||||
|
billed quantity is understated roughly fifteenfold if the phases are missing.
|
||||||
|
|
||||||
#### How long a dead meter takes to reach 0 W
|
#### How long a dead meter takes to reach 0 W
|
||||||
|
|
||||||
@@ -139,13 +160,37 @@ The entity is only created when `meter_source` is not `off`. With P1 ingestion
|
|||||||
disabled there is nothing feeding it, and an age sensor climbing with no ingester
|
disabled there is nothing feeding it, and an age sensor climbing with no ingester
|
||||||
behind it would trip the firmware watchdog on a system that is working fine.
|
behind it would trip the firmware watchdog on a system that is working fine.
|
||||||
|
|
||||||
> **Known limit, `mqtt_p1` only.** The age measures *arrival*, not change. On the
|
> **Known limit, `mqtt_p1`.** The age measures *arrival*. On the MQTT path a
|
||||||
> two HA WebSocket paths (`ha_dsmr`, `ha_signed`) that is exactly right: a frozen
|
> bridge that is stuck republishing its last telegram keeps arriving, so the age
|
||||||
> meter emits no `state_changed`, so nothing arrives and the age climbs. On the
|
> stays near zero and a frozen meter still looks fresh. Detecting *that* needs a
|
||||||
> MQTT path a bridge that is stuck republishing its last telegram keeps arriving,
|
> change-detector rather than an arrival-detector, and it is not in this version.
|
||||||
> so the age stays near zero and a frozen meter still looks fresh. Detecting
|
|
||||||
> *that* needs a change-detector rather than an arrival-detector, and it is not
|
> ⚠️ **Known limit, `ha_signed` — do not drive a watchdog off this age yet.**
|
||||||
> in this version. Prefer an HA WebSocket source where both are available.
|
> On the HA WebSocket paths the age is stamped when a `state_changed` arrives,
|
||||||
|
> which means it measures *time since the value last changed*, not time since
|
||||||
|
> the meter last reported. Home Assistant offers nothing better: a repeated
|
||||||
|
> reading produces no `state_changed`, does **not** advance `last_reported` on
|
||||||
|
> either the REST or the WebSocket serialiser, and `state_reported` cannot be
|
||||||
|
> subscribed to over the WebSocket at all (`Event filter is required for event
|
||||||
|
> state_reported`). All three measured on the ENV-01 rig against the real
|
||||||
|
> HomeWizard integration with the meter frozen: 0 `state_changed` in 70 s and no
|
||||||
|
> timestamp movement anywhere.
|
||||||
|
>
|
||||||
|
> `ha_dsmr` mostly escapes this because a DSMR telegram updates several entities
|
||||||
|
> and something in the set almost always moves. **`ha_signed` has exactly one
|
||||||
|
> entity, so a healthy meter under a flat load is indistinguishable from a dead
|
||||||
|
> one.** This is not hypothetical: in our own captures
|
||||||
|
> (`sim/scenarios/ha-p1_meter_active_power-2026-08-20.json`) the real house meter
|
||||||
|
> went **42.2 s and 97.0 s** between changes, and 23 Aug peaks at 29.1 s — all
|
||||||
|
> past the default `meter_max_age_s` of 30.
|
||||||
|
>
|
||||||
|
> So `sensor.p1_sample_age_s` on `ha_signed` is safe to *read*, and it is
|
||||||
|
> correct whenever the value is moving, but it must not yet be thresholded by
|
||||||
|
> 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.
|
||||||
|
|
||||||
### Control
|
### Control
|
||||||
|
|
||||||
|
|||||||
@@ -722,6 +722,25 @@ class HaSignedSource(HaDsmrSource):
|
|||||||
ingest timestamping, meter_max_age_s, the clock-recomputed age sensor, and
|
ingest timestamping, meter_max_age_s, the clock-recomputed age sensor, and
|
||||||
`unavailable` treated as a missing reading rather than 0 W.
|
`unavailable` treated as a missing reading rather than 0 W.
|
||||||
|
|
||||||
|
⚠️ THE AGE ON THIS TRANSPORT MEASURES TIME SINCE THE VALUE CHANGED, not time
|
||||||
|
since the meter reported, and on one entity those are very different things.
|
||||||
|
Home Assistant offers no arrival signal for a repeated reading: it emits no
|
||||||
|
`state_changed`, it does not advance `last_reported` on either serialiser,
|
||||||
|
and `state_reported` cannot be subscribed to over the websocket at all
|
||||||
|
("Event filter is required for event state_reported"). All three measured on
|
||||||
|
the ENV-01 rig against the real HomeWizard integration with the meter frozen
|
||||||
|
- 0 state_changed in 70 s, no timestamp movement anywhere.
|
||||||
|
|
||||||
|
`ha_dsmr` mostly escapes it because a DSMR telegram moves several entities at
|
||||||
|
once. This transport has ONE, so a healthy meter under a flat load looks
|
||||||
|
exactly like a dead one - and our own capture has the real meter going 42.2 s
|
||||||
|
and 97.0 s between changes, both past the default max_age_s of 30. Hence
|
||||||
|
DOCS.md: sensor.p1_sample_age_s is correct while the value moves and must not
|
||||||
|
yet be thresholded by the firmware watchdog on this transport. Raising
|
||||||
|
meter_max_age_s does not fix it, it only chooses which of the two errors you
|
||||||
|
get. The fix is an arrival stamp from the meter itself - reading the
|
||||||
|
HomeWizard local API rather than an HA entity - which is a separate ticket.
|
||||||
|
|
||||||
⚠️ The debounce is inherited but does nothing useful here, and that is fine:
|
⚠️ 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
|
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
|
coalesce and no window in which a new reading sits beside a stale one. It
|
||||||
@@ -811,10 +830,27 @@ def build_source(opts: dict, ingest: P1Ingest, session, broker: dict | None):
|
|||||||
"phase_export": opts.get("p1_phase_export_entities") or [],
|
"phase_export": opts.get("p1_phase_export_entities") or [],
|
||||||
})
|
})
|
||||||
if source == SOURCE_HA_SIGNED:
|
if source == SOURCE_HA_SIGNED:
|
||||||
return HaSignedSource(session, ingest, {
|
net = str(opts.get("p1_net_entity", "") or "").strip()
|
||||||
"net": opts.get("p1_net_entity", ""),
|
phase_net = [str(e).strip() for e in
|
||||||
"phase_net": opts.get("p1_phase_net_entities") or [],
|
(opts.get("p1_phase_net_entities") or []) if str(e).strip()]
|
||||||
})
|
# ⚠️ Both of these are checked ONCE here rather than per telegram. A
|
||||||
|
# misconfigured source otherwise fails silently in the only way that
|
||||||
|
# looks exactly like a healthy one that has not been sent anything yet:
|
||||||
|
# no samples, a climbing age, and the firmware watchdog holding the
|
||||||
|
# battery at 0 W with nothing in the log saying why.
|
||||||
|
if not net:
|
||||||
|
_LOG.error("meter_source %s needs p1_net_entity - P1 ingestion "
|
||||||
|
"disabled (sensor.p1_sample_age_s would otherwise be "
|
||||||
|
"announced with nothing feeding it)", SOURCE_HA_SIGNED)
|
||||||
|
return None
|
||||||
|
if phase_net and len(phase_net) != ingest.phases:
|
||||||
|
_LOG.error("p1_phase_net_entities has %d entities but meter_phases "
|
||||||
|
"is %d - P1 ingestion disabled. Every telegram would be "
|
||||||
|
"rejected on the phase-count check.",
|
||||||
|
len(phase_net), ingest.phases)
|
||||||
|
return None
|
||||||
|
return HaSignedSource(session, ingest,
|
||||||
|
{"net": net, "phase_net": phase_net})
|
||||||
if source == SOURCE_MQTT:
|
if source == SOURCE_MQTT:
|
||||||
broker = broker or {}
|
broker = broker or {}
|
||||||
return MqttP1Source(ingest, str(opts.get("meter_mqtt_topic", "")),
|
return MqttP1Source(ingest, str(opts.get("meter_mqtt_topic", "")),
|
||||||
|
|||||||
@@ -813,13 +813,18 @@ check("sensor.p1_sample_age_s is a live number on this transport too",
|
|||||||
print("ha_signed: selection by config")
|
print("ha_signed: selection by config")
|
||||||
|
|
||||||
check("ha_signed is enabled", is_enabled({"meter_source": SOURCE_HA_SIGNED}) is True)
|
check("ha_signed is enabled", is_enabled({"meter_source": SOURCE_HA_SIGNED}) is True)
|
||||||
built = build_source({"meter_source": SOURCE_HA_SIGNED,
|
# ⚠️ `sel`, not `built` - that name is the build() wrapper defined at the top of
|
||||||
|
# this file, and rebinding it here silently disarms every check appended below
|
||||||
|
# this line. Caught in review: an added check went `TypeError: 'HaSignedSource'
|
||||||
|
# object is not callable` and aborted the suite, which is the exact failure the
|
||||||
|
# wrapper exists to prevent, reintroduced by a name collision.
|
||||||
|
sel = build_source({"meter_source": SOURCE_HA_SIGNED,
|
||||||
"p1_net_entity": "sensor.p1_meter_active_power"},
|
"p1_net_entity": "sensor.p1_meter_active_power"},
|
||||||
P1Ingest(), None, None)
|
P1Ingest(), None, None)
|
||||||
check("meter_source ha_signed selects the signed transport",
|
check("meter_source ha_signed selects the signed transport",
|
||||||
isinstance(built, HaSignedSource))
|
isinstance(sel, HaSignedSource))
|
||||||
check("...wired to p1_net_entity, and subscribed to exactly that one entity",
|
check("...wired to p1_net_entity, and subscribed to exactly that one entity",
|
||||||
built.ids == {"sensor.p1_meter_active_power"})
|
sel.ids == {"sensor.p1_meter_active_power"})
|
||||||
# ⚠️ The three modes must not bleed into each other: ha_dsmr must keep ignoring
|
# ⚠️ The three modes must not bleed into each other: ha_dsmr must keep ignoring
|
||||||
# p1_net_entity, or a half-configured install silently reads the wrong sensor.
|
# p1_net_entity, or a half-configured install silently reads the wrong sensor.
|
||||||
plain = build_source({"meter_source": SOURCE_HA,
|
plain = build_source({"meter_source": SOURCE_HA,
|
||||||
@@ -833,6 +838,30 @@ check("meter_source off still selects nothing",
|
|||||||
check("an unrecognised meter_source selects nothing rather than guessing",
|
check("an unrecognised meter_source selects nothing rather than guessing",
|
||||||
build_source({"meter_source": "ha_signd"}, P1Ingest(), None, None) is None)
|
build_source({"meter_source": "ha_signd"}, P1Ingest(), None, None) is None)
|
||||||
|
|
||||||
|
# ⚠️ Caught once at startup, not once per telegram. A source that is wired up
|
||||||
|
# wrong otherwise fails in the one way indistinguishable from a healthy source
|
||||||
|
# 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.
|
||||||
|
check("a blank p1_net_entity is refused rather than silently never receiving",
|
||||||
|
build_source({"meter_source": SOURCE_HA_SIGNED, "p1_net_entity": ""},
|
||||||
|
P1Ingest(), None, None) is None)
|
||||||
|
check("...and whitespace does not sneak past it",
|
||||||
|
build_source({"meter_source": SOURCE_HA_SIGNED, "p1_net_entity": " "},
|
||||||
|
P1Ingest(), None, None) is None)
|
||||||
|
check("a phase list that disagrees with meter_phases is refused at startup",
|
||||||
|
build_source({"meter_source": SOURCE_HA_SIGNED, "p1_net_entity": "sensor.n",
|
||||||
|
"p1_phase_net_entities": ["sensor.a", "sensor.b"]},
|
||||||
|
P1Ingest(phases=3), None, None) is None)
|
||||||
|
check("a phase list that agrees with meter_phases is accepted",
|
||||||
|
isinstance(build_source(
|
||||||
|
{"meter_source": SOURCE_HA_SIGNED, "p1_net_entity": "sensor.n",
|
||||||
|
"p1_phase_net_entities": ["sensor.a", "sensor.b", "sensor.c"]},
|
||||||
|
P1Ingest(phases=3), None, None), HaSignedSource))
|
||||||
|
check("no phase list at all is still fine - per-phase billing is optional",
|
||||||
|
isinstance(build_source(
|
||||||
|
{"meter_source": SOURCE_HA_SIGNED, "p1_net_entity": "sensor.n"},
|
||||||
|
P1Ingest(phases=3), None, None), HaSignedSource))
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
print("the age sensor must not exist when P1 is off")
|
print("the age sensor must not exist when P1 is off")
|
||||||
# ⚠️ This is a fleet-wide regression guard, not a nicety. The ESP32 watchdog
|
# ⚠️ This is a fleet-wide regression guard, not a nicety. The ESP32 watchdog
|
||||||
|
|||||||
Reference in New Issue
Block a user