1b343da8e3d30928aaafa2dc64f159ef42aee23d
18
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
1b343da8e3 |
DOCS: say plainly that ha_dsmr and mqtt_p1 have never seen real hardware
No meter in this installation uses either transport. Both were written to specs.md 5.2's assumption that a Belgian P1 exposes two unsigned registers, which the meter actually fitted here does not - it is the HomeWizard P1 that ha_signed reads. Their only coverage is test_p1.py and an end-to-end test against a fake Home Assistant. Deliberately not called "experimental". That word says the design is unfinished, which is not the defect and is vaguer than the truth; these are complete and reviewed, they have simply never had a real telegram through them. The failure this note is guarding against is a future session debugging a meter problem, treating those two paths as proven, and looking elsewhere. Placed where the mode is chosen rather than in a footnote, because the choice is the decision it should inform. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa |
||
|
|
e663e10245 |
TEL-04 review: unshadow the helper, validate config at startup, and record
what the rig proved about the age sensor
Review findings 1, 3, 5 and 6. Finding 2 is deliberately untouched - it is
its own ticket.
3. `built` was rebound at test_p1.py:816 by `built = build_source(...)`,
silently disarming the build() wrapper for anything appended below it.
Renamed to `sel`. Reproduced the reviewer's failure before fixing:
appending a check that calls built() after that line gives
`TypeError: 'HaSignedSource' object is not callable` and aborts at 163 of
180; with the rename the same probe reaches 180 and passes.
5. DOCS.md now states the "length must equal meter_phases" constraint that
config.yaml already carried, plus what leaving the list empty actually
costs: on the surveyed reading the phases carry 2769 W of import while the
connection nets 187 W, so the tariff quantity is understated ~15x.
6. build_source now checks the ha_signed wiring once at startup instead of
once per telegram: a blank p1_net_entity, or a phase list whose length
disagrees with meter_phases, logs an error and disables ingestion. Both
otherwise fail in the single way indistinguishable from a healthy source
nobody has fed yet - no samples, a climbing age, the watchdog holding the
battery at 0 W, and nothing in the log.
1. THE AGE SENSOR. Measured on the ENV-01 rig against the real HomeWizard
integration, meter frozen via hwsim's `?fault=freeze` seam (cleared in a
finally:, rig verified restored):
- websocket state_changed for the meter over 70 s : 0
- last_reported advanced (REST serialiser) : no
- last_reported advanced (websocket serialiser) : no
- subscribe_events(state_reported) : rejected,
"Event filter is required for event state_reported"
So Home Assistant exposes NO arrival signal for a repeated reading, and
the proposed fix - stamp from last_reported via subscribe_entities - is
not available. subscribe_entities listens only to EVENT_STATE_CHANGED, and
as_compressed_state carries no last_reported at all.
The age is therefore "time since the value changed", which on ha_dsmr is
mostly harmless (a telegram moves several entities) and on ha_signed is
not: one entity means a healthy meter under a flat load is
indistinguishable from a dead one. Recorded loudly in DOCS.md, in the
HaSignedSource docstring and in the CHANGELOG, with the measured 42.2 s
and 97.0 s gaps from our own capture.
meter_max_age_s is deliberately NOT widened. The two conditions produce an
identical signal, so a larger number does not separate them - it only
chooses which of the two errors you get, and it would disarm the watchdog
for a genuinely dead meter as well. The honest fix is an arrival stamp the
meter itself provides.
test_p1.py: 174 -> 179 checks, all green. Other three suites unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
|
||
|
|
632be44f6c |
TEL-04: make a missing build() guard fail legibly instead of aborting
Review point from the non-vacuity run. Deleting build()'s "is the net entity cached at all" guard makes build() raise KeyError rather than return False. That still failed the suite, but by aborting it with a traceback at whichever check ran first - a red that costs the next person ten minutes deciding whether the suite is broken or the code is. New `built()` helper in test_p1.py wraps build() and turns an escaping exception into a returned value, so the comparison against True/False fails by name. Applied only to the ha_signed section; TEL-01's own checks are untouched. Mutation 4 before: 0 named checks red, aborted at check 123 of 174. Mutation 4 after: 2 named checks red - "nothing cached yet builds nothing" and "an unavailable entity does not build a sample" - all 174 reached. Still 174 checks, all green, and the other nine mutations are unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa |
||
|
|
8b51a51e20 |
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
|
||
|
|
6c980e87b0 |
DEPLOY-01: bump version to 0.3.0, changelog for SAFETY-04 and TEL-01
Fixes the version collision noticed while planning DEPLOY-01: release/1.0 still carried version 0.2.1, identical to what is already running on the live system, so Home Assistant would not have offered the update at all. - config.yaml: version 0.2.1 -> 0.3.0 (minor: TEL-01 adds a feature, SAFETY-04 changes the control law's internals) - CHANGELOG.md: 0.3.0 entry for SAFETY-04 and TEL-01, in the existing voice No code under app/ touched, no option defaults changed. Verified: meter_source: off, integrator_max_w: 0, target_grid_w: -10 all unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa |
||
|
|
4bd659c499 |
TEL-01 review fixes: stop the age sensor tripping installs that have no meter
T-1, and it was a fleet-wide trip to zero. publish() emitted p1_age unconditionally, and published_age_s counts from P1Ingest.__init__ when no sample has ever arrived. With meter_source defaulting to off, every existing install would have published sensor.p1_sample_age_s climbing without bound; the ESP32 does `has_state() && state >= max_age_s` and forces the layer-1 failsafe, so each of them would have pinned its inverter at 0 W within 30 s. Exactly the opposite of the zero-regression the off default was for. The key is now omitted from the payload AND from MQTT discovery when P1 is off, so the entity does not exist at all - which is the status quo, and what has_state() is testing for. The predicate is one function, is_enabled(), because the grid reading, the task start and the discovery announcement have to agree or this comes back. T-2, connect no longer manufactures a sample. get_states returns whatever HA currently holds, which after a Core restart is a RestoreEntity value of unknown age; stamping it with ingest_ts=now reset the age and reported a fresh meter that could have been dead for an hour. run()'s own docstring already said a reconnect must emit nothing - the code disagreed with it, and a test asserted the violation. The cache is still primed, so the first real state_changed builds a complete sample; the age just stays honest until one arrives. T-3, gaps are no longer filled with the last held value. The averager held a sample forward across any interval, so a meter dying at 5 kW and returning ten minutes later credited 5 kW x 600 s to the capacity-tariff accumulator - a fabricated peak on a permanent record. The hold is capped at max_age_s: past that the stretch is walked so block boundaries still land correctly, but nothing accumulates and elapsed does not grow, which is what finally makes the comment about a gap dragging the billed average down true. Same threshold for control and billing: a reading too old to steer by is too old to bill by. T-4, the out-of-order/duplicate guard is covered. It was untested, and the reason is worth recording: the obvious assertion passes without the guard, because the negative interval is separately refused by the covered > 0 test. What the guard prevents is the timestamp REWIND, which only shows up one sample later as a re-integrated window. The test now goes one sample later. T-6, DOCS was wrong about latency. meter_max_age_s and stale_input_s stack, so meter death to 0 W is 45 s and not 30. Documented as a table with both clocks. Also documented the T-5 asymmetry rather than papering over it: the age measures arrival, not change, so a stuck MQTT bridge republishing its last telegram still looks fresh. Correct on ha_dsmr, not detectable on mqtt_p1 without a change-detector. Written up as a known limit. Writing the T-1 test caught a second defect in the test itself: it recorded only MQTT topics, and object_id lives in the payload, so "the age sensor is not announced" had been passing for the wrong reason. test_p1.py: 99 -> 122 checks. 14 mutations run, all 14 red, files restored byte-identical - including one per fix above. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa |
||
|
|
f47f1f0129 |
TEL-01: P1 ingestion, with the derivation and the age the EMS owns
A Belgian P1 meter publishes two UNSIGNED registers, not one signed figure. Until now the add-on asked the installer to bridge that gap with a template sensor, which put the sign convention of the whole control loop in a text box. This moves it into the EMS: net = import - export, derived once, in one place, with a test that fails if anyone inverts it. Two transports behind one contract, chosen by `meter_source`: the HA WebSocket subscribing to the DSMR integration's entities, and MQTT on a configurable topic. Everything downstream reads P1Ingest, so switching is a config edit. `meter_source: off` is the default and keeps the existing meter_entity path, so no installed system changes until it opts in. The other half is the timestamp. Every accepted sample is stamped at ingest with a monotonic clock, `meter_max_age_s` is applied to it, and the age is published as sensor.p1_sample_age_s for the ESP32's stale-input watchdog. That entity is recomputed against the clock every second rather than only when a telegram lands, because HA pushes state only on change: a meter frozen at a constant reading emits nothing and looks, to anything watching the value, exactly like a meter that has died. The age tells them apart. Deliberately absent: any fallback to an inverter-side power figure. The inverter's own AC power correlates 0.998 with battery power and 0.09 with the real meter, so failing over to it means regulating against your own output. A gap stays a gap - a reconnect emits no synthetic sample, and a rejected telegram never resolves to 0 W or refreshes the timestamp. Quarter-hour averages are time-weighted over clock-aligned blocks rather than a mean of samples, so a cadence change cannot bias the capacity-tariff figure, and only offtake is accumulated so a quarter of pure export averages to 0 kW. Per-phase import is kept separately: on an unbalanced three-phase load the phase sum and the connection net are different numbers, and only one of them is billed. test_p1.py: 99 checks, runnable with a bare interpreter and no meter. Includes an end-to-end run of the HA transport against a fake Home Assistant websocket. Stacked on SAFETY-04; nothing here touches control.py. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa |
||
|
|
680461c9bf |
SAFETY-04: record the coverage audit as a comment, with its invariant
The audit found a dead mechanism twice, both times a clamp standing in for the mechanism under test, so the technique has to survive this ticket. Not as a script: the only cheap way to automate it is to key on source lines, that goes stale silently, and a green audit that has quietly stopped testing anything is this ticket's own failure mode one level up. Automating it properly would mean decomposing compute() to make its statements separately addressable, which is a refactor of the most safety-critical function in the repo for the benefit of test tooling. So it goes in as a comment block next to the checks it describes, carrying the commit it was measured at, the thirteen figures, and the invariant with the teeth in it: every mechanism must be noticed by at least two checks when it is deleted, and adding a mechanism means re-running the audit. A comment cannot go stale-green, because it never claims to be running. Also recorded: fixtures must sit clear of every rail they are not testing, which is the rule both misses violated; and the `python -B` / clear-pycache discipline, with the reason (CPython invalidates on source mtime-in-seconds plus size, so a same-second same-size rewrite reuses stale bytecode) and the reason it casts no doubt on the figures (the error is one-directional, so every number is a lower bound). Figures are the lead's independent reproduction. I re-measured the one that differed: the detector is 11 for `saturated_now = False` and 10 for the weaker `frozen = False` form, so the table names the form. test_control.py stays at 55 checks, all passing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa |
||
|
|
389d9ecd6d |
SAFETY-04: stop the clamps standing in for the mechanisms under test
AC 3 - "integration freezes while saturated" - had no non-vacuous test. Deleting the integrator freeze outright failed 0 of 55 checks: the two checks that name it used a fixture at max_w 2000 with the integrator bound following it, so a wound value was truncated back to exactly 2000 and the assertion passed on the clamp instead. Fixture lifted to max_w 5000, clear of every rail. Deleting the freeze now fails 2. Then swept the whole function for the same pattern, one mechanism at a time: delete it, count which checks notice. It found a second instance - the OUTPUT clamp. `clamped to max_w` and `clamped to -max_w` were both satisfied by the integrator bound truncating first, so removing the output clamp failed only the reason-string check. Those two fixtures now set integrator_max_w above max_w so the mechanism they name is the binding one; the output clamp goes from 1 failure to 3. Every mechanism in compute() is now caught by a check that names it: freeze 2, integrator clamp 6, bound-follows-max_w 4, output clamp 3, slew 4, output freeze 2, deadband 5, quantise 2, detector 10, duration 2, counter reset 6, grid bias 3, None-seeding 6. No mechanism at zero. Method note: the audit disables bytecode caching. Rewriting control.py inside one second leaves a stale app/__pycache__ entry and silently under-reports - it under-reported one mutation as 2 failures where the true figure is 6. test_control.py stays at 55 checks, all passing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa |
||
|
|
53d301b920 |
SAFETY-04: exactly zero is its own case in the freeze tie-break
`min(moved, i_w) if i_w > 0 else max(moved, i_w)` files i_w == 0.0 under rising-only, so the first push toward charging from exactly zero was blocked permanently - the S-1 deadlock again, mirrored in sign. main.py resets i_w to exactly 0.0 on every stop and every reseed, so it is a normal state. Zero is now handled explicitly and both directions are allowed: nothing is wound, so "may not wind further" has no referent, and a first step from zero is bounded by the gain, the output clamp and the slew limit like any other. Measured before the fix, at i_w == 0.0 and frozen: 12 800 of 25 920 ticks held the integrator and 8 304 of those changed the emitted command, worst case abandoning a 2 kW charge into a 4 kW export. Note this is NOT the same as the reported symptom: at prev_w == 0 the command holds at 0 W either way, because the output freeze forbids starting a charge while saturated, and that rule is release/1.0's and unchanged. There is now a test asserting it deliberately. Tests. The durable part is a property rather than more points: over 13 041 frozen states the integrator may be held ONLY by a correction pushing it further from zero on the side it already sits, and any other hold fails. Both signs at exactly 0.0. Mirrors added everywhere the suite tested one direction of two - freeze wind/unwind while charging, i_w=-100, the export-direction runaway, the negative clamp and slew. DOCS: the cycles-vs-seconds deviation is now written down as a deviation - the "> 10 s" criterion is not met as literally written, a cycle is one CHANGED meter reading, and there is no guaranteed wall-clock window. test_control.py: 43 -> 55 checks, all passing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa |
||
|
|
7123aa00a4 |
SAFETY-04: revive the clamp reason, and compare reasons in the sweep
`want = i_w` after the integrator bound, so at the default limit == max_w the output clamp can never fire and `reason == "clamped"` had become unreachable. Observability only today - nothing gates on the string - but SAFETY-03 exists to alarm on exactly that engagement, so its hook was dead before it was built. The integrator bound now reports "i-clamped", and that is the signal SAFETY-03 must watch: it is the one that fires on a default install. "clamped" stays reachable for a configuration that lets the integrator run above the rail, where both fire and the output clamp - which describes the value actually emitted - is the one reported. Two names because the two events want different alarms: the loop winding, versus a command that came out over the rating. The real fix is the second half. The equivalence sweep compared (target_w, sat_count), which is how a dead reason survived 3024 cases. It now compares (target_w, sat_count, frozen, reason) and it catches this defect: dropping the emit turns it red. Deliberate rename aliased explicitly, so any OTHER reason divergence still fails. Result of adding reason to the tuple: 105 of 3024 cases differ, and every one of them is the i-clamped/clamped rename. Zero value divergences, `frozen` included. Nothing else surfaced. test_control.py: 41 -> 43 checks, all passing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa |
||
|
|
e46175559b |
SAFETY-04 review fixes: the freeze deadlocked, the bound was too loose
S-1. The frozen branch admitted a correction only if it shrank |i_w|. That is unsatisfiable for BOTH signs of error whenever |correction| > 2*|i_w|, i.e. whenever the integrator is near zero, so the loop stopped moving and the freeze could never clear - it clears when the inverter tracks, and not tracking is what saturation means. Measured: 0 W held into a 2 kW import indefinitely, where release/1.0 recovers on the next cycle. Re-encoded as the same asymmetric rule the output freeze has always used: may not wind further in the direction it is already pushing, may fall, cross zero or reverse. Same interpretation, an encoding that cannot deadlock. S-2. integrator_max_w defaulted to 1.5x max_w, which ADDED windup: in release/1.0 the accumulator was the post-clamp command and could never pass the rail. Default is now "follow max_w" (config 0 = unset). Measured on the 4000 W load-drop sim, first cycle after the drop: 1000 W at the new default, 1800 W at 3000. DOCS row inverted - the useful direction is below max_w, and the 14 768 W anecdote is a vendor controller, not evidence about this code. S-3. The claim that i_w=None preserved release/1.0 exactly was false, because the S-1 gate ran regardless of seeding. It is true again, and now asserted rather than asserted-about: 3024-case exhaustive comparison against a transcription of the old law, over both freeze states, both signs and either side of the deadband. Added the carried-i_w convergence/overshoot sim that the shipped configuration was missing. S-4. Cycles are distinct meter values, not seconds: cycle() runs only when the meter reading changes, so the window has no wall-clock bound. Comment and DOCS corrected; the stall is detection latency, not a windup hazard, because the same condition stalls the whole loop. test_control.py: 33 -> 41 checks, all passing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa |
||
|
|
37bac79ad8 |
SAFETY-04: clamp the integrator, not only the output
The loop was in velocity form: the accumulator WAS the commanded power, so "clamp the integrator" and "clamp the output" were the same line of code and could not be set apart. This makes the accumulator an explicit carried value (`i_w`), bounds it with its own `integrator_max_w`, and keeps the output clamp where it was. Killing either mechanism now still leaves the other holding - which is the point of the ticket, and what the new regression test asserts. Freeze semantics: while saturated the integrator may unwind but not wind further. A strict freeze would strand the command at whatever it reached, because the condition that releases it is the inverter tracking again, and not tracking is exactly what saturation means. The integrator is re-seeded from the arbiter's actual output whenever the loop did not get what it asked for, so entering any failsafe (all of which resolve to 0 W) zeroes it, and the first cycle after release does not dump the stale period as power. test_control.py: 24 -> 33 checks, all passing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa |
||
|
|
5184a2cfc2 |
Rest the meter just below zero, not at zero
The deadband is a one-way ratchet: any resting point inside it holds indefinitely. Import and export are separate registers on the meter, so a loop resting at +14 W bills 0.34 kWh/day while behaving perfectly. target_grid_w (default -10 W) moves that residue onto the export register. Worst billed rest point drops from 15 W to under 5 W. Behaviour is unchanged at target_grid_w: 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
017b798fe6 |
Precedence arbiter: one rule instead of an if/else ladder
Two controllers writing one actuator is the failure this system exists to
avoid. "Exactly one writer" was true, but only as a convention held up by
careful reading - which does not survive an EV charger and a heat pump wanting
the same battery.
Strategies now return claims and arbiter.py resolves them:
highest-priority `set` wins (none at all means 0 W), then every `limit` whose
priority is >= that set's applies, most restrictive first; contradictory
limits command 0 W and are flagged as the bug they are.
The second clause is the whole point. "Money outranks maintenance" used to be a
hand-written exception inside a Jinja template; it is now a consequence of the
priorities - the charge-only limit binds the loop but cannot bind a
higher-priority peak claim.
Also: maintenance shaping moved out of control.py, which is a controller again
and not a policy engine; the loop now tracks the arbiter's actual output rather
than its own last wish, so it does not jump when it regains control; and every
decision explains itself ("loop -> 0 W, limited by maintenance(charge-only)")
in the UI and the log.
19 new assertions in test_arbiter.py, each one a precedence question someone
will eventually ask in the field. Deployed to the reference site as 0.2.0 and
holding grid within a few watts of zero.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016NckgXecasQb2eSsPYNSW6
|
||
|
|
36cc837446 |
Point the manifests at the real repository URL
Also documents the two things that stop an install dead: the repository must be public for Home Assistant to clone it anonymously (a private repo shows nothing in the store and gives no useful error), and config.yaml's `version` must be bumped for every change or Supervisor reuses the previously built image. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016NckgXecasQb2eSsPYNSW6 |
||
|
|
594f8f9fc9 |
Six silent failures found by installing this on a live system
Migrated the reference site off the YAML packages and onto the add-on. Every bug below presented identically: the add-on starts, logs "started", serves its UI, and cannot do its job. - run.sh needs #!/usr/bin/with-contenv sh. s6-overlay sanitises the environment for services, so a plain shebang means SUPERVISOR_TOKEN is absent and every Core API call is 401 - while homeassistant_api: true makes permissions look granted. Startup now prints the token length and probes the API. - Supervisor keys the image by config.yaml `version`, so rebuilding without a bump reuses the old image. Two fixes appeared not to work because of it. - Alpine is musl and has no aiohttp wheel on PyPI; deps now come from apk so nothing compiles on a client's Pi. - Alpine ships paho-mqtt 1.x, which has no CallbackAPIVersion. That raised at construction and took the control loop down with it - so MQTT setup is now wrapped too. Observability must never be able to stop the controller. - MQTT discovery is published from on_connect: paho silently drops QoS-0 publishes issued before the CONNACK, so the previous code announced nothing while logging "MQTT connected". - Repeated failures now log once a minute. Six warnings a second rolled the log buffer and destroyed the startup diagnostics needed to find the 401. - auto_start could never fire, because the store's defaults always supplied auto: False for the fallback to find. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016NckgXecasQb2eSsPYNSW6 |
||
|
|
0a1e61dbc9 |
Packaged as a Home Assistant add-on, with a field guide
Turns the reference RS485 controller into something a technician can install at a client site: a typed config form instead of YAML, an ingress UI that names misconfiguration in words, and persistent state that cannot be broken by a timezone. Why an add-on rather than YAML packages or blueprints: - Blueprints cannot create helpers, and the maintenance cycle is a state machine whose phase and completion date must survive restarts. - YAML packages need filesystem access, a configuration.yaml edit and a restart - none of which belong in a client install. - Add-ons authenticate with SUPERVISOR_TOKEN, so there is no long-lived token to generate, store or leak on someone else's machine. - Requires HA OS/Supervised. Container and Core installs cannot run add-ons at all, which is a market decision, not an oversight. The control law and the maintenance machine are pure functions with no Home Assistant imports, and both ship with runnable checks (22 and 22 assertions). Every assertion corresponds to a rule whose absence caused an observed failure on hardware - the saturation duration term, the clamp-before-slew ordering, the deadband, the sign convention. One behaviour deliberately differs from the implementation it replaces: when its inputs go missing this commands 0 W rather than replaying the last setpoint. The reference version kept replaying, which the hardware watchdog cannot catch - from the ESP32's side, Home Assistant is still talking to it. Includes the ESPHome firmware (now parameterised: node name, inverter rating, watchdog timeout) and the optional RS485 e-stop. FIELD-GUIDE.md carries the commissioning gates, all judged on the wire rather than on how Home Assistant looks, plus the written statement a site without an e-stop needs signed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016NckgXecasQb2eSsPYNSW6 |