Compare commits
9
Commits
4bd659c499
..
TEL-04
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b343da8e3 | ||
|
|
e663e10245 | ||
|
|
632be44f6c | ||
|
|
8b51a51e20 | ||
|
|
c24bc0a011 | ||
|
|
80402c978f | ||
|
|
6c980e87b0 | ||
|
|
505a847d85 | ||
|
|
f498d5fa54 |
@@ -0,0 +1,10 @@
|
|||||||
|
# This add-on is deployed to a Linux container. core.autocrlf=true on the
|
||||||
|
# authoring box gave the checkout CRLF, so a plain copy shipped CRLF files -
|
||||||
|
# run.sh with CRLF is a "bad interpreter" failure, and any hash-based drift
|
||||||
|
# check between repo and deployment fails for a reason that has nothing to do
|
||||||
|
# with the code. Deploy with:
|
||||||
|
# git -c core.autocrlf=false archive release/1.0 goodwe_controller | tar -x
|
||||||
|
# which is how 0.3.0 went out, byte-identical to the blobs.
|
||||||
|
* text=auto eol=lf
|
||||||
|
*.png binary
|
||||||
|
*.gz binary
|
||||||
@@ -1,5 +1,75 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
**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
|
||||||
|
actually fitted here - which neither TEL-01 transport can read, because
|
||||||
|
`ha_dsmr` needs two unsigned registers and refuses a negative one, i.e. every
|
||||||
|
exporting telegram. Set `p1_net_entity`, and `p1_phase_net_entities` for the
|
||||||
|
per-phase capacity-tariff figures on a three-phase connection.
|
||||||
|
|
||||||
|
Everything TEL-01 established is inherited rather than re-implemented - the
|
||||||
|
new transport is a subclass of the `ha_dsmr` one overriding only which
|
||||||
|
entities it wants and how they become a sample. So ingest timestamping,
|
||||||
|
`meter_max_age_s`, `sensor.p1_sample_age_s` recomputed against the clock and
|
||||||
|
republished once a second, the plausibility bounds, and `unavailable` /
|
||||||
|
`unknown` treated as a *missing reading and never 0 W* all behave identically
|
||||||
|
across the three sources.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
**SAFETY-04.** The control law's integrator is now an explicit accumulator,
|
||||||
|
bounded independently of the output clamp instead of inheriting whatever
|
||||||
|
headroom the clamp happened to leave. It also freezes while the inverter is
|
||||||
|
not tracking, rather than continuing to wind up against a command nothing is
|
||||||
|
acting on. `integrator_max_w` (default `0`) governs the bound; `0` means
|
||||||
|
"follow `max_w`", which is the existing behaviour.
|
||||||
|
|
||||||
|
Behaviour is unchanged at the defaults - a 4,928-case equivalence sweep
|
||||||
|
against the previous control law confirms it decides identically at
|
||||||
|
`integrator_max_w: 0`.
|
||||||
|
|
||||||
|
**TEL-01.** P1 meter ingestion, so a Belgian P1's two unsigned registers
|
||||||
|
(consumption, injection) no longer need a hand-written signed template
|
||||||
|
sensor: the subtraction moves into the add-on, done once and tested. Two
|
||||||
|
transports, chosen with the new `meter_source` option: `ha_dsmr` subscribes
|
||||||
|
to the DSMR integration over the HA WebSocket, `mqtt_p1` reads a topic.
|
||||||
|
Defaults to `off`, which keeps the existing `meter_entity` path untouched -
|
||||||
|
nothing changes for an install that does not opt in.
|
||||||
|
|
||||||
|
Enabling it publishes `sensor.p1_sample_age_s`: seconds since the newest
|
||||||
|
accepted telegram, recomputed against the clock and republished roughly once
|
||||||
|
a second rather than only when a telegram lands. That is deliberate - Home
|
||||||
|
Assistant only pushes a state on change, so a meter sitting at a genuinely
|
||||||
|
constant reading would otherwise look identical to a dead one. Watching the
|
||||||
|
age instead means a frozen meter shows a climbing age, not a flat line. The
|
||||||
|
firmware watchdog subscribes to this exact entity id.
|
||||||
|
|
||||||
|
Known limits, both already in DOCS.md: on `mqtt_p1`, a bridge stuck
|
||||||
|
republishing its last telegram still "arrives", so the age cannot detect
|
||||||
|
that particular failure - prefer `ha_dsmr` where both are available. And a
|
||||||
|
dead P1 meter takes 45 s to reach 0 W commanded (30 s for `meter_max_age_s`
|
||||||
|
to call the reading stale, then 15 s of `stale_input_s` on top), which is
|
||||||
|
`meter_max_age_s` and `stale_input_s` stacking, not either one alone.
|
||||||
|
|
||||||
## 0.2.1
|
## 0.2.1
|
||||||
|
|
||||||
`target_grid_w` (default -10 W): what the meter should rest at. The deadband
|
`target_grid_w` (default -10 W): what the meter should rest at. The deadband
|
||||||
|
|||||||
+73
-12
@@ -51,14 +51,40 @@ phase having charged nothing.
|
|||||||
### P1 meter ingestion
|
### P1 meter ingestion
|
||||||
|
|
||||||
`meter_entity` above expects one signed sensor, which usually means a template
|
`meter_entity` above expects one signed sensor, which usually means a template
|
||||||
someone wrote by hand. A Belgian P1 meter does not publish one: it publishes two
|
someone wrote by hand. Setting `meter_source` moves the whole derivation into
|
||||||
**unsigned** registers, consumption and injection. Setting `meter_source` moves
|
the add-on, where it is done once and tested, and replaces `meter_entity`
|
||||||
that subtraction into the add-on, where it is done once and tested, and replaces
|
entirely.
|
||||||
`meter_entity` entirely.
|
|
||||||
|
Which mode you want depends on what your P1 reader publishes, and there are two
|
||||||
|
shapes in the wild:
|
||||||
|
|
||||||
|
- **Two unsigned registers**, consumption and injection, which is what a Belgian
|
||||||
|
P1 read over DSMR gives you → `ha_dsmr`, or `mqtt_p1` for a bridge. The add-on
|
||||||
|
subtracts them.
|
||||||
|
- **One signed figure**, positive = import and negative = export, which is what
|
||||||
|
a HomeWizard P1 gives you (`sensor.p1_meter_active_power`) → `ha_signed`. The
|
||||||
|
add-on splits it. `ha_dsmr` **cannot** read this: it wants two registers and
|
||||||
|
rejects a negative one outright, which is every exporting telegram.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
> ⚠️ **`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 |
|
| `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_phases` | 1 | 1 or 3. Must match the telegram, or every telegram is rejected and logged |
|
| `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_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_mqtt_topic` | | `mqtt_p1` only |
|
| `meter_mqtt_topic` | | `mqtt_p1` only |
|
||||||
@@ -66,6 +92,17 @@ that subtraction into the add-on, where it is done once and tested, and replaces
|
|||||||
| `p1_export_entity` | | The **unsigned** injection sensor |
|
| `p1_export_entity` | | The **unsigned** injection sensor |
|
||||||
| `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_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
|
||||||
|
|
||||||
@@ -123,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
|
||||||
> `ha_dsmr` path that is exactly right: a frozen meter emits no `state_changed`,
|
> bridge that is stuck republishing its last telegram keeps arriving, so the age
|
||||||
> so nothing arrives and the age climbs. On the MQTT path a bridge that is stuck
|
> stays near zero and a frozen meter still looks fresh. Detecting *that* needs a
|
||||||
> republishing its last telegram keeps arriving, so the age stays near zero and a
|
> change-detector rather than an arrival-detector, and it is not in this version.
|
||||||
> frozen meter still looks fresh. Detecting *that* needs a change-detector rather
|
|
||||||
> than an arrival-detector, and it is not in this version. Prefer `ha_dsmr` where
|
> ⚠️ **Known limit, `ha_signed` — do not drive a watchdog off this age yet.**
|
||||||
> 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
|
||||||
|
|
||||||
|
|||||||
+158
-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
|
peak, the optimizer, the control loop's sign. So three things happen here and
|
||||||
nowhere else.
|
nowhere else.
|
||||||
|
|
||||||
1. The IMPORT/EXPORT DERIVATION. A Belgian P1 meter exposes two UNSIGNED
|
1. The IMPORT/EXPORT DERIVATION. A Belgian P1 read over DSMR exposes two
|
||||||
registers - consumption and injection - never one signed figure. Net power
|
UNSIGNED registers - consumption and injection. Net power is
|
||||||
is `import_w - export_w`, positive = import, and that subtraction is done
|
`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
|
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
|
template the user has to write"). A second copy of it somewhere else is a
|
||||||
second chance to invert the control loop.
|
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
|
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
|
with no age is a value that cannot be trusted (§5.2), and staleness is the
|
||||||
failsafe trigger (§11.2).
|
failsafe trigger (§11.2).
|
||||||
@@ -47,6 +56,7 @@ _LOG = logging.getLogger("goodwe.p1")
|
|||||||
|
|
||||||
SOURCE_HA = "ha_dsmr"
|
SOURCE_HA = "ha_dsmr"
|
||||||
SOURCE_MQTT = "mqtt_p1"
|
SOURCE_MQTT = "mqtt_p1"
|
||||||
|
SOURCE_HA_SIGNED = "ha_signed"
|
||||||
|
|
||||||
QUARTER_S = 900
|
QUARTER_S = 900
|
||||||
|
|
||||||
@@ -78,7 +88,7 @@ class P1Sample:
|
|||||||
ingest_ts: datetime # tz-aware UTC, set at ingest
|
ingest_ts: datetime # tz-aware UTC, set at ingest
|
||||||
ingest_mono: float # time.monotonic() at ingest - see age_s()
|
ingest_mono: float # time.monotonic() at ingest - see age_s()
|
||||||
telegram_ts: datetime | None # from the telegram, where the source has one
|
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
|
import_w: float # unsigned magnitude, as the meter reports it
|
||||||
export_w: float # unsigned magnitude
|
export_w: float # unsigned magnitude
|
||||||
net_w: float # import_w - export_w (+ import, - export)
|
net_w: float # import_w - export_w (+ import, - export)
|
||||||
@@ -129,6 +139,29 @@ def _watts(value, what: str) -> float:
|
|||||||
return out
|
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,
|
def make_sample(source: str, import_w, export_w, *, phases: int,
|
||||||
phase_import_w=None, phase_export_w=None,
|
phase_import_w=None, phase_export_w=None,
|
||||||
telegram_ts: datetime | None = None,
|
telegram_ts: datetime | None = None,
|
||||||
@@ -668,6 +701,103 @@ class MqttP1Source:
|
|||||||
self.ingest.reject(err)
|
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 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:
|
||||||
|
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
|
# selection
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -699,12 +829,34 @@ def build_source(opts: dict, ingest: P1Ingest, session, broker: dict | None):
|
|||||||
"phase_import": opts.get("p1_phase_import_entities") or [],
|
"phase_import": opts.get("p1_phase_import_entities") or [],
|
||||||
"phase_export": opts.get("p1_phase_export_entities") or [],
|
"phase_export": opts.get("p1_phase_export_entities") or [],
|
||||||
})
|
})
|
||||||
|
if source == SOURCE_HA_SIGNED:
|
||||||
|
net = str(opts.get("p1_net_entity", "") or "").strip()
|
||||||
|
phase_net = [str(e).strip() for e in
|
||||||
|
(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", "")),
|
||||||
broker.get("host"), broker.get("port", 1883),
|
broker.get("host"), broker.get("port", 1883),
|
||||||
broker.get("username"), broker.get("password"))
|
broker.get("username"), broker.get("password"))
|
||||||
if source:
|
if source:
|
||||||
_LOG.error("meter_source %r is not %s or %s - P1 ingestion disabled",
|
_LOG.error("meter_source %r is not one of %s - P1 ingestion disabled",
|
||||||
source, SOURCE_HA, SOURCE_MQTT)
|
source, ", ".join((SOURCE_HA, SOURCE_MQTT, SOURCE_HA_SIGNED)))
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
name: GoodWe RS485 Controller
|
name: GoodWe RS485 Controller
|
||||||
version: "0.2.1"
|
version: "0.3.0"
|
||||||
slug: goodwe_controller
|
slug: goodwe_controller
|
||||||
description: >-
|
description: >-
|
||||||
Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart
|
Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart
|
||||||
@@ -42,7 +42,9 @@ options:
|
|||||||
# --- P1 meter ingestion (specs §5.2 / §14 `meter:`) -------------------------
|
# --- P1 meter ingestion (specs §5.2 / §14 `meter:`) -------------------------
|
||||||
# `off` keeps the original single meter_entity path above, so an existing
|
# `off` keeps the original single meter_entity path above, so an existing
|
||||||
# install is untouched until it opts in. ha_dsmr subscribes to the DSMR
|
# install is untouched until it opts in. ha_dsmr subscribes to the DSMR
|
||||||
# integration's entities over the HA WebSocket; mqtt_p1 reads the topic below.
|
# 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.
|
||||||
meter_source: "off"
|
meter_source: "off"
|
||||||
meter_phases: 1
|
meter_phases: 1
|
||||||
meter_max_age_s: 30
|
meter_max_age_s: 30
|
||||||
@@ -55,6 +57,15 @@ options:
|
|||||||
# three-phase connection; the list length must equal meter_phases.
|
# three-phase connection; the list length must equal meter_phases.
|
||||||
p1_phase_import_entities: []
|
p1_phase_import_entities: []
|
||||||
p1_phase_export_entities: []
|
p1_phase_export_entities: []
|
||||||
|
# ha_signed only. ONE signed net-power sensor: positive = import from the
|
||||||
|
# grid, negative = export to it. Do NOT split it into two template sensors -
|
||||||
|
# the split is done in the add-on (p1.split_signed) precisely so the sign
|
||||||
|
# convention is tested rather than living in unreviewed YAML.
|
||||||
|
p1_net_entity: ""
|
||||||
|
# Optional, in L1..L3 order, each one signed the same way. Same role as
|
||||||
|
# p1_phase_import_entities: the capacity-tariff peak on a three-phase
|
||||||
|
# connection. The list length must equal meter_phases.
|
||||||
|
p1_phase_net_entities: []
|
||||||
|
|
||||||
# --- control ---------------------------------------------------------------
|
# --- control ---------------------------------------------------------------
|
||||||
max_w: 2000
|
max_w: 2000
|
||||||
@@ -98,7 +109,7 @@ schema:
|
|||||||
batt_invert: bool
|
batt_invert: bool
|
||||||
setpoint_entity: str
|
setpoint_entity: str
|
||||||
|
|
||||||
meter_source: list(off|ha_dsmr|mqtt_p1)
|
meter_source: list(off|ha_dsmr|mqtt_p1|ha_signed)
|
||||||
# ⚠️ 2 is accepted by this range but is not a real Belgian connection. A
|
# ⚠️ 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
|
# 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
|
# mis-set 2 shows up immediately as "0 telegrams accepted" rather than as a
|
||||||
@@ -112,6 +123,9 @@ schema:
|
|||||||
- str
|
- str
|
||||||
p1_phase_export_entities:
|
p1_phase_export_entities:
|
||||||
- str
|
- str
|
||||||
|
p1_net_entity: str?
|
||||||
|
p1_phase_net_entities:
|
||||||
|
- str
|
||||||
|
|
||||||
max_w: int(100,5000)
|
max_w: int(100,5000)
|
||||||
gain: float(0.05,1.0)
|
gain: float(0.05,1.0)
|
||||||
|
|||||||
@@ -18,8 +18,9 @@ from datetime import datetime, timedelta, timezone
|
|||||||
import aiohttp # already required by app.p1, so this adds no new dependency
|
import aiohttp # already required by app.p1, so this adds no new dependency
|
||||||
|
|
||||||
from app.p1 import (
|
from app.p1 import (
|
||||||
P1Error, P1Ingest, HaDsmrSource, QuarterAverager, SOURCE_HA, SOURCE_MQTT,
|
P1Error, P1Ingest, HaDsmrSource, HaSignedSource, QuarterAverager,
|
||||||
make_sample, parse_mqtt_payload,
|
SOURCE_HA, SOURCE_HA_SIGNED, SOURCE_MQTT,
|
||||||
|
build_source, is_enabled, make_sample, parse_mqtt_payload, split_signed,
|
||||||
)
|
)
|
||||||
|
|
||||||
fails = []
|
fails = []
|
||||||
@@ -56,6 +57,25 @@ def raises(name, fn):
|
|||||||
fails.append(name)
|
fails.append(name)
|
||||||
|
|
||||||
|
|
||||||
|
def built(src):
|
||||||
|
"""`src.build()`, with any escaping exception turned into a visible value.
|
||||||
|
|
||||||
|
⚠️ Legibility of a RED, not leniency. build() is contracted to return a bool
|
||||||
|
and to funnel every bad telegram through ingest.reject() - a guard that goes
|
||||||
|
missing (say the "is the net entity cached at all" one) makes it raise
|
||||||
|
instead. That still fails the suite, but by aborting it with a traceback at
|
||||||
|
whichever check happened to run first, which costs the next person ten
|
||||||
|
minutes deciding whether the suite is broken or the code is. Returning the
|
||||||
|
exception makes it compare unequal to True/False, so the NAMED check goes red
|
||||||
|
and says which rule died.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return src.build()
|
||||||
|
except Exception as err: # noqa: BLE001 - a raise here is itself the failure
|
||||||
|
print(f" build() raised {type(err).__name__}: {err}")
|
||||||
|
return err
|
||||||
|
|
||||||
|
|
||||||
def sample(net_import, net_export=0.0, at=BASE, phases=1, pi=None, pe=None):
|
def sample(net_import, net_export=0.0, at=BASE, phases=1, pi=None, pe=None):
|
||||||
return make_sample(SOURCE_HA, net_import, net_export, phases=phases,
|
return make_sample(SOURCE_HA, net_import, net_export, phases=phases,
|
||||||
phase_import_w=pi, phase_export_w=pe,
|
phase_import_w=pi, phase_export_w=pe,
|
||||||
@@ -548,6 +568,300 @@ check("the averager integrated the live stream", live.averager.elapsed_s > 0.2)
|
|||||||
check("the primed cache let the first telegram build immediately",
|
check("the primed cache let the first telegram build immediately",
|
||||||
live.samples == 2 and live.last.import_w == 0.0)
|
live.samples == 2 and live.last.import_w == 0.0)
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
print("ha_signed: one signed entity -> the same two magnitudes")
|
||||||
|
# The meter actually fitted at this house is a HomeWizard P1 publishing ONE
|
||||||
|
# signed sensor. ha_dsmr cannot read it - it wants two unsigned registers and
|
||||||
|
# refuses a negative one, which is every exporting telegram.
|
||||||
|
|
||||||
|
check("a positive reading is import", split_signed(1500.0) == (1500.0, 0.0))
|
||||||
|
check("a negative reading is export", split_signed(-900.0) == (0.0, 900.0))
|
||||||
|
check("zero is a balanced reading, not a missing one",
|
||||||
|
split_signed(0.0) == (0.0, 0.0))
|
||||||
|
check("exactly one magnitude is ever non-zero",
|
||||||
|
all(a == 0.0 or b == 0.0 for a, b in
|
||||||
|
(split_signed(v) for v in (-5710.0, -1.0, 0.0, 1.0, 4384.0))))
|
||||||
|
# ⚠️ The split must not change the number. A control loop steered by a value
|
||||||
|
# that was rounded or clamped on the way in is steered by a different meter.
|
||||||
|
check("the split round-trips the signed value exactly",
|
||||||
|
all(make_sample(SOURCE_HA_SIGNED, *split_signed(v), phases=1).net_w == v
|
||||||
|
for v in (-11763.0, -5710.0, -0.5, 0.0, 0.5, 775.0, 4384.0)))
|
||||||
|
|
||||||
|
raises("a non-numeric signed reading is rejected", lambda: split_signed("n/a"))
|
||||||
|
raises("a signed None is rejected, not read as zero", lambda: split_signed(None))
|
||||||
|
raises("a signed NaN is rejected", lambda: split_signed(float("nan")))
|
||||||
|
raises("a signed infinity is rejected", lambda: split_signed(float("inf")))
|
||||||
|
# ⚠️ This one matters MORE on the signed path than on the unsigned one. On
|
||||||
|
# ha_dsmr the §20 contamination is also caught by "unsigned cannot be negative";
|
||||||
|
# here 64954 arrives as a perfectly well-formed positive signed reading and the
|
||||||
|
# plausibility ceiling is the only thing standing in front of it.
|
||||||
|
raises("the 64954 signed-decode contamination is still rejected",
|
||||||
|
lambda: split_signed(64954.0))
|
||||||
|
raises("...and its negative twin too", lambda: split_signed(-64954.0))
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
print("ha_signed: the sign convention, against real captured readings")
|
||||||
|
# ⚠️ Not a datasheet claim. These are literal values out of
|
||||||
|
# sim/scenarios/ha-p1_meter_active_power-2026-08-{20,23}.json, HA recorder
|
||||||
|
# exports of sensor.p1_meter_active_power at this house, copied here rather than
|
||||||
|
# read from that repo so this file still runs on a laptop with nothing installed
|
||||||
|
# (§17). If the convention were inverted, the physics below would be absurd.
|
||||||
|
|
||||||
|
# 2026-08-23T11:46:52Z - the day's most negative reading, 13:46 local, full sun.
|
||||||
|
s = make_sample(SOURCE_HA_SIGNED, *split_signed(-5710.0), phases=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 and s.import_w == 0.0)
|
||||||
|
# 2026-08-19T22:00:00Z - midnight local, 20 Aug's file starts here. No sun.
|
||||||
|
s = make_sample(SOURCE_HA_SIGNED, *split_signed(775.0), phases=1)
|
||||||
|
check("the overnight base load (+775 W) is IMPORT",
|
||||||
|
s.net_w == 775.0 and s.import_w == 775.0 and s.export_w == 0.0)
|
||||||
|
# 2026-08-20T12:20:58Z - 14:20 local, the largest export in either capture.
|
||||||
|
s = make_sample(SOURCE_HA_SIGNED, *split_signed(-11763.0), phases=1)
|
||||||
|
check("the -11763 W midday extreme is export and survives the ceiling",
|
||||||
|
s.net_w == -11763.0 and s.export_w == 11763.0)
|
||||||
|
# 2026-08-23T10:18:28Z - the largest import in the healthy capture.
|
||||||
|
s = make_sample(SOURCE_HA_SIGNED, *split_signed(4384.0), phases=1)
|
||||||
|
check("the +4384 W peak is import", s.net_w == 4384.0 and s.import_w == 4384.0)
|
||||||
|
# The whole convention in one line: night draws, midday feeds back.
|
||||||
|
check("night is positive and midday is negative, which is the convention",
|
||||||
|
split_signed(775.0)[0] > 0 and split_signed(-5710.0)[1] > 0)
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
print("ha_signed transport: building a sample out of one entity state")
|
||||||
|
|
||||||
|
NET = {"net": "sensor.p1_meter_active_power", "phase_net": []}
|
||||||
|
ing = P1Ingest(phases=1, max_age_s=30.0)
|
||||||
|
sig = HaSignedSource(None, ing, NET, token="x")
|
||||||
|
|
||||||
|
check("nothing cached yet builds nothing", built(sig) is False and ing.last is None)
|
||||||
|
sig._absorb("sensor.p1_meter_active_power", "1000")
|
||||||
|
check("one signed entity is a complete telegram on its own",
|
||||||
|
built(sig) is True and ing.net_w == 1000.0)
|
||||||
|
check("the sample is tagged with its own transport",
|
||||||
|
ing.last.source == SOURCE_HA_SIGNED)
|
||||||
|
sig._absorb("sensor.p1_meter_active_power", "-2500")
|
||||||
|
built(sig)
|
||||||
|
check("a negative state lands as a negative net", ing.net_w == -2500.0)
|
||||||
|
|
||||||
|
before = ing.last
|
||||||
|
sig._absorb("sensor.p1_meter_active_power", "unavailable")
|
||||||
|
check("an unavailable signed entity is a parse error", ing.parse_errors == 1)
|
||||||
|
check("an unavailable entity does not build a sample", built(sig) is False)
|
||||||
|
# ⚠️ The rule the whole ticket turns on: a missing reading is MISSING. Resolving
|
||||||
|
# it to 0 W would read as a perfectly balanced house and defeat the staleness
|
||||||
|
# trigger that FW-01's watchdog is built on.
|
||||||
|
check("an unavailable entity leaves the last good sample untouched, not 0 W",
|
||||||
|
ing.last is before and ing.net_w == -2500.0)
|
||||||
|
sig._absorb("sensor.p1_meter_active_power", "unknown")
|
||||||
|
check("an unknown signed entity is treated the same way", ing.parse_errors == 2)
|
||||||
|
sig._absorb("sensor.p1_meter_active_power", "banana")
|
||||||
|
check("a non-numeric signed state is a parse error, not 0 W",
|
||||||
|
ing.parse_errors == 3 and ing.net_w == -2500.0)
|
||||||
|
sig._absorb("sensor.p1_meter_active_power", "64954")
|
||||||
|
check("64954 is refused at the signed transport too",
|
||||||
|
built(sig) is False and ing.parse_errors == 4)
|
||||||
|
sig._absorb("sensor.not_ours", "123")
|
||||||
|
check("an unsubscribed entity is never cached by the signed transport",
|
||||||
|
"sensor.not_ours" not in sig.cache)
|
||||||
|
|
||||||
|
# A rejected reading must not make the age look fresh - the age is what the
|
||||||
|
# firmware watchdog reads, and a rejection is exactly when it must keep climbing.
|
||||||
|
ing = P1Ingest(phases=1, max_age_s=30.0)
|
||||||
|
sig = HaSignedSource(None, ing, dict(NET), token="x")
|
||||||
|
ing.submit(make_sample(SOURCE_HA_SIGNED, 1200, 0, phases=1,
|
||||||
|
ingest_mono=time.monotonic() - 20.0))
|
||||||
|
sig._absorb("sensor.p1_meter_active_power", "unavailable")
|
||||||
|
built(sig)
|
||||||
|
check("a rejected reading does not reset the published age",
|
||||||
|
ing.published_age_s > 19 and ing.net_w == 1200.0)
|
||||||
|
ing.submit(make_sample(SOURCE_HA_SIGNED, 1200, 0, phases=1,
|
||||||
|
ingest_mono=time.monotonic() - 40.0))
|
||||||
|
check("...and the age keeps climbing past max_age_s on its own",
|
||||||
|
ing.stale is True and ing.net_w is None)
|
||||||
|
|
||||||
|
# The three-phase reading the TEL-04 survey recorded at this house: L1 +2301 W,
|
||||||
|
# L2 +468 W, L3 -2582 W, netting +187 W. A signed per-phase set splits the same
|
||||||
|
# way, and the exporting phase must still clamp out of the billed figure.
|
||||||
|
ing3 = P1Ingest(phases=3, max_age_s=30.0)
|
||||||
|
NET3 = {"net": "sensor.p1_meter_active_power",
|
||||||
|
"phase_net": ["sensor.p1_l1", "sensor.p1_l2", "sensor.p1_l3"]}
|
||||||
|
sig3 = HaSignedSource(None, ing3, NET3, token="x")
|
||||||
|
for eid, val in (("sensor.p1_meter_active_power", "187"), ("sensor.p1_l1", "2301")):
|
||||||
|
sig3._absorb(eid, val)
|
||||||
|
check("an incomplete signed phase set waits instead of guessing", built(sig3) is False)
|
||||||
|
sig3._absorb("sensor.p1_l2", "468")
|
||||||
|
sig3._absorb("sensor.p1_l3", "-2582")
|
||||||
|
check("a complete signed three-phase set builds", built(sig3) is True)
|
||||||
|
check("signed per-phase entities keep the exporting phase negative",
|
||||||
|
ing3.last.per_phase_w == (2301.0, 468.0, -2582.0))
|
||||||
|
check("per-phase IMPORT clamps the exporting phase to zero",
|
||||||
|
ing3.last.per_phase_import_w == (2301.0, 468.0, 0.0))
|
||||||
|
check("the phase import sum is 2769 W while the connection nets 187 W",
|
||||||
|
sum(ing3.last.per_phase_import_w) == 2769.0 and ing3.last.net_w == 187.0)
|
||||||
|
check("the signed per-phase tuple length matches meter_phases",
|
||||||
|
len(ing3.last.per_phase_w) == ing3.phases == 3)
|
||||||
|
|
||||||
|
sig_bad = HaSignedSource(None, P1Ingest(phases=3, max_age_s=30.0),
|
||||||
|
{"net": "sensor.net", "phase_net": ["sensor.a", "sensor.b"]},
|
||||||
|
token="x")
|
||||||
|
for eid in ("sensor.net", "sensor.a", "sensor.b"):
|
||||||
|
sig_bad._absorb(eid, "100")
|
||||||
|
check("two phases delivered against meter_phases 3 is rejected, not padded",
|
||||||
|
built(sig_bad) is False and sig_bad.ingest.last is None
|
||||||
|
and sig_bad.ingest.parse_errors == 1)
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
print("ha_signed transport: end to end against a fake Home Assistant")
|
||||||
|
# The transport is a subclass, so this is what proves the INHERITED machinery -
|
||||||
|
# auth, subscribe, the get_states priming rule, the reconnect-emits-nothing
|
||||||
|
# rule - still behaves when only _wanted() and build() were replaced.
|
||||||
|
|
||||||
|
|
||||||
|
async def _e2e_signed():
|
||||||
|
from aiohttp import web
|
||||||
|
import app.p1 as p1mod
|
||||||
|
|
||||||
|
done = asyncio.Event()
|
||||||
|
eid = "sensor.p1_meter_active_power"
|
||||||
|
|
||||||
|
async def fake_ha(request):
|
||||||
|
ws = web.WebSocketResponse()
|
||||||
|
await ws.prepare(request)
|
||||||
|
await ws.send_json({"type": "auth_required", "ha_version": "2026.8"})
|
||||||
|
auth = await ws.receive_json()
|
||||||
|
assert auth["type"] == "auth" and auth["access_token"] == "tok"
|
||||||
|
await ws.send_json({"type": "auth_ok"})
|
||||||
|
sub = await ws.receive_json()
|
||||||
|
assert sub["type"] == "subscribe_events"
|
||||||
|
await ws.send_json({"id": sub["id"], "type": "result", "success": True})
|
||||||
|
get = await ws.receive_json()
|
||||||
|
assert get["type"] == "get_states"
|
||||||
|
await ws.send_json({"id": get["id"], "type": "result", "success": True, "result": [
|
||||||
|
{"entity_id": eid, "state": "775.0"},
|
||||||
|
{"entity_id": "sensor.something_else", "state": "hello"},
|
||||||
|
]})
|
||||||
|
# Two real telegrams, both literal captured values: overnight import,
|
||||||
|
# then the midday export peak.
|
||||||
|
for val in ("775.0", "-5710.0"):
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
await ws.send_json({"type": "event", "event": {"data": {
|
||||||
|
"entity_id": eid,
|
||||||
|
"new_state": {"entity_id": eid, "state": val}}}})
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
await ws.send_json({"type": "event", "event": {"data": {
|
||||||
|
"entity_id": eid,
|
||||||
|
"new_state": {"entity_id": eid, "state": "unavailable"}}}})
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
done.set()
|
||||||
|
return ws
|
||||||
|
|
||||||
|
srv = web.Application()
|
||||||
|
srv.router.add_get("/ws", fake_ha)
|
||||||
|
runner = web.AppRunner(srv)
|
||||||
|
await runner.setup()
|
||||||
|
site = web.TCPSite(runner, "127.0.0.1", 0)
|
||||||
|
await site.start()
|
||||||
|
port = site._server.sockets[0].getsockname()[1]
|
||||||
|
p1mod.WS_URL = f"http://127.0.0.1:{port}/ws"
|
||||||
|
|
||||||
|
ing = P1Ingest(phases=1, max_age_s=30.0)
|
||||||
|
async with aiohttp.ClientSession() as sess:
|
||||||
|
src = HaSignedSource(sess, ing, dict(NET), token="tok")
|
||||||
|
task = asyncio.get_running_loop().create_task(src.run())
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(done.wait(), 20)
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
finally:
|
||||||
|
task.cancel()
|
||||||
|
try:
|
||||||
|
await task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
await runner.cleanup()
|
||||||
|
return ing, src
|
||||||
|
|
||||||
|
|
||||||
|
live, wire = asyncio.run(_e2e_signed())
|
||||||
|
# ⚠️ TWO, not three - the same rule as the ha_dsmr e2e. get_states primes the
|
||||||
|
# cache but must never become a sample: after a Core restart it is a
|
||||||
|
# RestoreEntity value of unknown age, and stamping it with ingest_ts=now reports
|
||||||
|
# a fresh meter that may have been dead for an hour.
|
||||||
|
check("ha_signed does not manufacture a sample from cached HA state",
|
||||||
|
live.samples == 2)
|
||||||
|
check("the signed telegrams arrived over a real websocket",
|
||||||
|
live.last.source == SOURCE_HA_SIGNED)
|
||||||
|
check("the final export telegram nets negative, over the wire",
|
||||||
|
live.last.net_w == -5710.0 and live.last.export_w == 5710.0)
|
||||||
|
check("ha_signed subscribes to the one entity and caches nothing else",
|
||||||
|
wire.ids == {"sensor.p1_meter_active_power"}
|
||||||
|
and "sensor.something_else" not in wire.cache)
|
||||||
|
check("a mid-stream unavailable signed state is a parse error, not a sample",
|
||||||
|
live.parse_errors == 1 and live.samples == 2)
|
||||||
|
# ⚠️ And the cached half is DROPPED, so no later telegram can be assembled out
|
||||||
|
# of a value that stopped reporting.
|
||||||
|
check("an unavailable entity is evicted from the cache", wire.cache == {})
|
||||||
|
check("the last good reading survives the unavailable, and is not 0 W",
|
||||||
|
live.net_w == -5710.0)
|
||||||
|
check("the averager integrated the live signed stream", live.averager.elapsed_s > 0.2)
|
||||||
|
# ⚠️ The entity FW-01 waits on. It must be a number here exactly as it is on the
|
||||||
|
# other transports - the house P1 went 51.1 s and 36.2 s between state changes
|
||||||
|
# overnight, and without this the watchdog false-trips the battery to 0 W.
|
||||||
|
check("sensor.p1_sample_age_s is a live number on this transport too",
|
||||||
|
isinstance(live.published_age_s, float) and live.published_age_s >= 0.0)
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
print("ha_signed: selection by config")
|
||||||
|
|
||||||
|
check("ha_signed is enabled", is_enabled({"meter_source": SOURCE_HA_SIGNED}) is True)
|
||||||
|
# ⚠️ `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"},
|
||||||
|
P1Ingest(), None, None)
|
||||||
|
check("meter_source ha_signed selects the signed transport",
|
||||||
|
isinstance(sel, HaSignedSource))
|
||||||
|
check("...wired to p1_net_entity, and subscribed to exactly that one entity",
|
||||||
|
sel.ids == {"sensor.p1_meter_active_power"})
|
||||||
|
# ⚠️ 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.
|
||||||
|
plain = build_source({"meter_source": SOURCE_HA,
|
||||||
|
"p1_import_entity": "sensor.i", "p1_export_entity": "sensor.e",
|
||||||
|
"p1_net_entity": "sensor.p1_meter_active_power"},
|
||||||
|
P1Ingest(), None, None)
|
||||||
|
check("ha_dsmr still selects the unsigned transport and ignores p1_net_entity",
|
||||||
|
type(plain) is HaDsmrSource and plain.ids == {"sensor.i", "sensor.e"})
|
||||||
|
check("meter_source off still selects nothing",
|
||||||
|
build_source({"meter_source": "off"}, P1Ingest(), None, None) is None)
|
||||||
|
check("an unrecognised meter_source selects nothing rather than guessing",
|
||||||
|
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
|
||||||
@@ -556,7 +870,6 @@ print("the age sensor must not exist when P1 is off")
|
|||||||
# age were published with meter_source off it would climb past 30 s on every
|
# age were published with meter_source off it would climb past 30 s on every
|
||||||
# existing install within half a minute and pin the inverter at 0 W forever.
|
# existing install within half a minute and pin the inverter at 0 W forever.
|
||||||
|
|
||||||
from app.p1 import is_enabled # noqa: E402
|
|
||||||
from app.mqtt import SENSORS, MqttPublisher # noqa: E402
|
from app.mqtt import SENSORS, MqttPublisher # noqa: E402
|
||||||
|
|
||||||
check("meter_source off is disabled", is_enabled({"meter_source": "off"}) is False)
|
check("meter_source off is disabled", is_enabled({"meter_source": "off"}) is False)
|
||||||
@@ -636,6 +949,14 @@ check("with P1 on, p1_age is published", "p1_age" in pub_on.last)
|
|||||||
check("...as a number, so has_state() becomes true only once we feed it",
|
check("...as a number, so has_state() becomes true only once we feed it",
|
||||||
isinstance(pub_on.last["p1_age"], float))
|
isinstance(pub_on.last["p1_age"], float))
|
||||||
|
|
||||||
|
# ⚠️ And on ha_signed identically - this is the whole reason TEL-04 exists. The
|
||||||
|
# age sensor is a hard prerequisite for the FW-01 flash, and it has to appear on
|
||||||
|
# the transport that can actually read the meter in this house.
|
||||||
|
pub_sig = _Pub()
|
||||||
|
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))
|
||||||
|
|
||||||
print()
|
print()
|
||||||
if fails:
|
if fails:
|
||||||
print(f"{len(fails)} of {total} FAILED: {', '.join(fails)}")
|
print(f"{len(fails)} of {total} FAILED: {', '.join(fails)}")
|
||||||
|
|||||||
Reference in New Issue
Block a user