Commit Graph
9 Commits
Author SHA1 Message Date
glenn schrooyenandClaude Opus 5.5 f39e52efd3 MAINT-14: maintenance with no SoC reading: charge times out, drain holds at 0 W
checks / unit (pull_request) Successful in 35m13s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 20:37:01 +02:00
glenn schrooyenandClaude Sonnet 5.5 6f05b2e4be MAINT-13 round 2: gate open when nothing holds the charge back; safety93 scenario restored
checks / unit (pull_request) Failing after 34m45s
infra-pin / fresh (pull_request) Successful in 2s
- gate_open is true with no price gate set up, in a cheap window, or while the
  pack is taking charge; the stall rule and timeout now work on a no-tariff site.
- reset the save timer on a phase change; exact 5 min gap-cap test.
- test_safety93: original timeout scenario kept (B finished) plus a case where a
  due B takes the slot after A's failure.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-08 10:29:46 +02:00
glenn schrooyenandClaude Opus 5.5 28eff568b5 MAINT-13: a full pack that stops at 98-99 % is not a failed charge
checks / unit (pull_request) Successful in 34m21s
infra-pin / fresh (pull_request) Successful in 1s
A charge is complete at SoC >= target, or at >= target - 3 once the SoC has
made no new high for 60 min of allowed charging, or when the charging time
runs out at >= target - 3. The 36 h timeout counts only the minutes the price
gate let the pack charge (plus a 7 day wall-clock backstop). A cycle that
fails (stuck drain, charge below target - 3) backs off 7 days, tells the
operator once (EN + NL), and no cycle starts on that pack in that time unless
the operator forces one. State survives a restart; old state files load.

No option added. DECISIONS/MAINT-13-full-pack-is-complete.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 09:36:06 +02:00
goodwe-agent 02c0a3dd7b MAINT-10: maintenance start hour in local time, default 19:00
The start hour was compared with the UTC hour, so the shipped 10 started at
12:00 in a Belgian summer. It is now read in Home Assistant's time zone
(fallback: the container's TZ, then UTC, one log line says which), and the
default moves to 19. DST: a missing hour starts at the first hour after the
jump; a repeated hour starts only in its first pass. Stored options are not
rewritten: a saved 10 now means 10:00 local (DOCS, CHANGELOG).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 22:52:05 +02:00
glenn schrooyenandClaude Opus 5.5 23a3a3f5b5 UX-10: the last plain-language leftovers: log lines, two table rows, 'Why: Other', red at 0 s
checks / unit (pull_request) Successful in 9m3s
infra-pin / fresh (pull_request) Successful in 2s
- test_messages.py part 6: every dynamic part of a WARNING+ line is classified
  as data or as a built text, and every built text is produced by the real
  code and checked. This is the gap the 2d scan had: it counted `%s` as one
  word, so reasons built by loop_clamp_note() and friends were never seen.
- Quoted reasons rewritten to STYLE.md (red-alarm shape, codes kept):
  OUTPUT_CLAMP, METER_FROZEN, CIRCULATING_POWER, STALE_READING, the peak
  guard (codes now PEAK_GUARD*), the migration report, price feed, price
  history and gate reasons. Plurals via messages.times()/count().
- Top table: Price history "96 prices over 24 hours for 26 Sep", Tariff
  "one every 15 minutes or every hour", "not final yet" dropped.
- Levels and noise: per-cycle shadow line DEBUG, shadow on/off INFO,
  aiohttp access log only at log level debug, settings warnings once per
  start, one plain line per Home Assistant outage (hass.Outage).
- INFO jargon and error texts: no "§8.3", ISO times, "banked", "keys ARE",
  "NOT_OBSERVED", "did NOT land", "AND".
- Display bugs: WHY_WINNER covers every winner and safety reason
  (main.why_key(), part 7 finds them in the code); the meter row is amber
  "waiting for the first reading" inside its limit after a start; the
  missing-reading age is counted when the page asks.
- STYLE.md and DECISIONS/UX-08-short-notifications.md (UX-10 section).

No change to safety behaviour, alarm levels, notification ids, triggers,
cadence or the banner cascade. One row colour changes (meter at start),
justified in DECISIONS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 10:58:43 +02:00
adminandClaude Opus 5 1aef19e995 MAINT-05: one recalibration per pack, and only one pack moves power at a time
checks / unit (pull_request) Successful in 4m48s
§12's monthly cycle drains a pack to empty and refills it to true its coulomb
counter. There was exactly one `Maintenance` and one un-namespaced history, so
at two packs only the first was ever recalibrated - and nothing prevented the
cycle draining pack A while the loop charged pack B, which is two power-moving
phases on one grid connection pulling against each other.

* ONE `Maintenance` PER ROSTER ENTRY, each with its own schedule, phase and
  history under `unit/<name>/...`. `store.migrate_maint_keys()` moves an
  install's flat history into the first pack's namespace under
  `migrate_unit_keys()`'s three rules - shared in code via `_move_flat_keys()`
  and `_verify_moves()`, not restated - on its own marker, because unlike
  DEV-02's it runs only where the roster has more than one pack. The flat keys
  are kept LIVE as the rollback view.
* ONE MAINTENANCE SLOT PER SITE (`maintenance.arbitrate()`). At most one pack
  in a power-moving phase - drain, charge AND hold. The holder is DERIVED from
  the packs' own stored phases, never persisted, so a restart mid-drain
  re-derives it. Several packs due in the same hour: the slot goes to the one
  that has gone longest without a completed cycle; roster order breaks a
  numeric tie and nothing else. Nothing preempts a cycle in flight.
* THE DEFERRED PACK IS NOT STARVED AND IS NOT A FAULT. A deferral does not
  stamp `last_start_attempt` and does not move the phase, so neither the 12 h
  drain nor the 36 h charge timeout is reachable by waiting.
* The claim is filed on the pack whose state machine produced it, and stays
  SCOPE_UNIT - ARCH-01's rule.
* `validate.py` rule 6 grades `maintenance_charge_w` against the WEAKEST cap on
  the roster now, as its own comment said it must once `maint_unit` stopped
  being `inverters[0]`.
* At a roster of one nothing changes, and it is asserted: no new store key, the
  migration does not run, the four cycle events are the shipped strings, the
  banner is the bare phase.
* RECORDED NOT FIXED: a drain pins its pack while the loop's target is a site
  claim, so below the pin the allocator refuses the split and the site parks at
  0 W. Pre-existing at 2d025ee. Pinned as a running check.

Verified: 19/19 suites green in the CI image; 17 mutations run through
tools/mutation_run.sh (bytecode purged), every one observed red.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-04 13:38:29 +02:00
glenn schrooyenandClaude Opus 5 d5b1ef19b3 CFG-03: give maintenance option defaults one source of truth
checks / unit (pull_request) Successful in 3m44s
Option defaults for the maintenance cycle were declared in three places that
could disagree, and MAINT-04 proved they already did. config.yaml shipped
`maintenance_charge_w: 2000`; main.py carried TWO `opts.get(..., 2500)`
fallbacks; MaintConfig's dataclass carried a third `2500.0`. So an options dict
with the key ABSENT was ACCEPTED by validate.py's rule 6 - which read 2000 -
and then built by main.py at 2500, against a 2000 W ceiling:

    options dict with maintenance_charge_w ABSENT, max_w=2000
      validate_startup_config  ->  []  (accepted, no problems)
      MaintConfig().charge_w   ->  2500.0
      arbiter.resolve()        ->  0.0 W, contradiction=True

That is the bug rule 6 exists to refuse, passing rule 6. It is unreachable on a
Supervisor install, which always supplies every key from config.yaml, and fully
reachable from sim/inject.py and from the test suite - which is where anyone
debugging maintenance actually works.

MAINT-04 shipped a TRIPWIRE and said so: test_validate.py pinned all three
literals and asserted the consequence through the real arbiter, so the
divergence could not silently widen or be half-closed. Its PASSING CONDITION
WAS THE BUG. Both it and validate.py's comment said to delete each other when
the divergence was closed. It is closed, so both are deleted.

THE DIVERGENCE WAS WIDER THAN ONE KEY. `maintenance_discharge_w` had the
identical three-way split (config.yaml 2000, MaintConfig 2500) and the card
named only the charge side. Closing one and leaving its twin would have
satisfied the numbers and left three sources of truth one line down, which is
the state that produced the bug. Both are closed, and so is the whole family:
all eight `maintenance_*` keys go through the same mechanism.

WHAT THE SINGLE SOURCE IS. config.yaml's `options:` block - it is what the
Supervisor reads and what the operator edits, so it wins by definition. There
is now exactly one in-code copy, `maintenance.MAINT_OPTION_DEFAULTS`, which is
TARIFF-02's `PRICE_OPTION_DEFAULTS` and SAFETY-24's `PEAK_OPTION_DEFAULTS`
pattern applied to the family that named the card - validate.py's rule 8 comment
already called that "CFG-03 read as a rule rather than as a complaint", so this
makes the rule hold where it was first broken rather than inventing a fourth
mechanism for one option family.

A MIRROR RATHER THAN A RUNTIME READ, and the reason is mechanical: the
Dockerfile copies `app/` and `run.sh` into the image and NOT config.yaml. The
running add-on cannot open the file it is configured by, so there is nothing to
read at 03:00 on a client's Pi, and a YAML-subset parser that could fail at
import is a new way for the add-on not to start. What makes the copy safe is
that test_maintenance.py pins every entry by READING the shipped file - drift is
a CI failure, not a comment.

EVERY OTHER SITE NOW DERIVES FROM IT OR HAS CEASED TO EXIST:

  * MaintConfig's eight dataclass defaults are indexed out of the mirror. No
    numeric literal survives in the class that held the third opinion.
  * `MaintConfig.from_options()` is the ONLY place in the add-on that reads a
    `maintenance_*` option. main.py's eight inline `opts.get(key, <literal>)`
    calls are gone: moving the read next to the mirror means the fallback cannot
    be anything but the mirror, because there is no literal in sight of it to
    disagree with. It uses `key in opts` rather than truthiness, so a shipped
    `false` or `0` is honoured instead of being quietly replaced by the default
    - this card's own bug in a different costume.
  * main.py's SECOND fallback site - the cheap-window charge floor at the
    P_MAINT_SHAPE claim - reads `self.maint.cfg.charge_w` instead of re-opening
    the options dict with its own literal. The Controller had already parsed
    this once in __init__; asking the object that owns it cannot drift from it,
    and the rate the shaping asks for can no longer be a different number from
    the rate the state machine drains at on the same install.
  * validate.py's rule 6 and its `maintenance_discharge_w` warning take their
    fallbacks from the mirror.

WHERE THE RANGE LIVES, STATED AND CHECKED. config.yaml's `schema:` block carries
`maintenance_charge_w: int(500,5000)` - a fourth declaration site of a different
kind. It is a BOUND, not a default, so it is not this bug; but leaving the
question open is how a fifth site arrives. The decision recorded in config.yaml
and in maintenance.py is: bounds live in the schema block ONLY, because the
Supervisor is what enforces them and the Supervisor reads only that file, so a
Python copy of `int(500,5000)` would be a place to disagree and nothing else.
test_maintenance.py checks both halves of that: every mirrored key must have a
schema entry - without one the Supervisor drops the option and the add-on
silently runs on the mirror instead of the operator's value - and every shipped
default must lie INSIDE its own range, since a default the schema rejects is a
config that cannot be re-entered once it is edited. What the schema grammar
cannot express is a relation BETWEEN keys (`maintenance_charge_w` against
`max_w`), which is exactly why rule 6 is written in Python and why it does not
belong in the mirror either.

THE REPLACEMENT CHECKS, AND HOW THEY WERE VERIFIED. test_validate.py's new
section exercises the same four consumers the defect ran through, the same way:
the validator ACCEPTS a dict with every maintenance key missing; a REAL
Controller built from that same dict holds 2000 W (through main.py, not through
a regex over its source); `MaintConfig()` answers 2000 W with no dict at all;
and the real arbiter emits `-2000 W, limited by supervised(max_w)` instead of
`0 W, contradiction=True`. test_maintenance.py pins the mirror to config.yaml in
both directions - equal values, and full coverage of every `maintenance_*` key
the file ships, so a ninth key cannot arrive unmirrored - and asserts the
consumers move as a RELATION rather than against numbers, reading MaintConfig's
defaults back out of `dataclasses.fields()`.

Each new check was verified by BREAKING the thing it watches and observing red,
this project's rule:

  1. config.yaml -> 1500, mirror left at 2000:  test_maintenance 1 FAILED,
     test_validate 4 FAILED.
  2. config.yaml AND the mirror -> 1500: green again, and every consumer moved
     together - MaintConfig() 1500 W, the Controller 1500 W, and the arbiter
     `-1500 W, limited by maintenance(cheap-window charge)` where it had said
     `-2000 W, limited by supervised(max_w)`. That is AC4: change the single
     source and watch the consumers follow.
  3. `charge_w: float = 2500.0` retyped into the dataclass: 2 FAILED in each of
     the two files.
  4. `opts.get("maintenance_charge_w", 2500)` restored in main.py: 1 FAILED,
     naming the file and the literal it found.
  5. `maintenance_hold_min` deleted from config.yaml's options block: 3 FAILED.

Full suite 17/17 in goodwe-addon-ci, twice, once under parallel load.

Stale prose that asserted the now-closed divergence as current is corrected in
app/watchdog.py, test_watchdog.py and test_peakguard.py - watchdog.py's example
still uses 1500 W, but because the point of the example is a charge strictly
inside the ceiling, not because 2500 is what ships.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 06:16:54 +02:00
glenn schrooyenandClaude Opus 5 ffb8ec6373 MAINT-04 review: pin the default divergence with a check, not a comment
validate.py's comment said "if config.yaml's value moves, move this and
main.py's together" and nothing enforced it - a comment where a check
belonged, in a PR that elsewhere proves it knows the difference.

test_validate.py now reads main.py and maintenance.py directly and asserts
the divergence EXACTLY AS IT STANDS: config.yaml/validate.py at 2000,
main.py's two fallbacks and MaintConfig's dataclass default at 2500. It
records the mismatch rather than demanding it be closed, so it passes today
and goes red if either side moves - including when somebody closes it in
main.py, at which point the check and validate.py's block are deleted
together. Nothing in a file another ticket owns is touched.

The consequence is asserted through the real code as well as the literals:
with the key absent the validator accepts, MaintConfig builds 2500 W against
a 2000 W ceiling, and the arbiter resolves it to 0 W as a contradiction -
the bug rule 6 exists to catch, passing rule 6. Production-unreachable (the
Supervisor always supplies every key) but fully reachable from sim/inject.py
and the tests, which is where anyone debugging maintenance works.

Also: a CHANGELOG section for the upgrade path, covering both the case where
the Supervisor re-derives the default and the case where it persisted the
old one, since which applies was not verifiable without probing the live
install. And maintenance.py's timeout comment said 13.5 kWh where everything
else says 13.8 (300 Ah x 51.2 V, DoD 90); SAFETY-18 does not touch that file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
2026-08-30 02:51:40 +02:00
adminandClaude Opus 5 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
2026-08-23 01:15:25 +02:00