Create the AC Charge Start/End Battery SOC entities on grid-tied families - #488
Create the AC Charge Start/End Battery SOC entities on grid-tied families#488notexpected wants to merge 3 commits into
Conversation
The reg-160/161 AC-charge SOC window (added for EG4_OFFGRID in joyfulhouse#331) is live on grid-tied families too. FlexBOSS21 hardware evidence (EG4_HYBRID, fw FAAB-2727, local dongle Modbus): reg 160 initiates AC charging whenever battery SOC is below its value, in or out of the AC-charge time windows and regardless of the reg-120 ACChargeType selector -- at the factory 90 it grid-charged outside every window and overrode a running forced discharge; at 10 it blocked the scheduled in-window charge at SOC 19. The portal exposes the field for the family as "Start AC Charge SOC(%)", so with no entity the value silently defeats any ToU schedule. Create both entities in the grid-tied/fail-open setup branch: Start enabled (reg-160 local read+write verified on the FlexBOSS21), End disabled by default pending a verified grid-tied write (PR joyfulhouse#332 review observed reg-161 writes rejected on grid-tied hardware). EG4_OFFGRID behavior unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JwHbYjtEufAi3gk2cncLXG
Code reviewNot ready to merge. Adversarial review (gpt-5.6-sol at ultra) found six issues; I verified the two blocking ones directly against the source. The register evidence in the PR body is solid and the premise is right — I independently confirmed on a second FlexBOSS21 through the cloud path that both parameters exist and read ( 1. On the very setup that produced the evidence, the new entity can never show a value. Local polling only widens the 158 read to cover 160/161 on eg4_web_monitor/custom_components/eg4_web_monitor/coordinator_local.py Lines 605 to 616 in 92c22c9 Both entities read exclusively from the parameter cache with no inverter-property fallback, so a pure-LOCAL grid-tied FlexBOSS21 — the dongle-connected unit in the PR body — gets an enabled Start entity stuck at 2. Using the eg4_web_monitor/custom_components/eg4_web_monitor/utils.py Lines 267 to 287 in 92c22c9
Non-blocking, but worth addressing: 3. Start's range disagrees with the library. The entity permits 0–100%, while pylxpweb's register-160 definition and its hybrid setter cap Start at 90% ( 4. Reg 161 as a grid-tied "End" isn't what the library models. pylxpweb models the grid-tied SOC window as Start = 160 and End = reg 67, with a paired setter ( 5. An accepted no-op still reports success. Loud failures are handled (pylxpweb raises on HTTP errors and 6. Factual wording. The PR and changelog say reg-161 grid-tied writes were "observed rejected", citing the #332 note — but that note records them as read-only, and this codebase deliberately distinguishes the two. |
…ites Review response (all six items): 1. Widen the local 158-block parameter read to EG4_HYBRID so a pure-LOCAL grid-tied unit's Start entity reads from the cache it is served from, instead of sitting at unknown forever. 2. Gate grid-tied creation on is_hybrid_family() -- the fails-closed convention for capabilities verified on hybrid hardware -- instead of the else-branch, which lumped LXP and unidentified hardware in on one FlexBOSS21's evidence. 3. Cap Start writes at 90% (pylxpweb's reg-160 definition and its hybrid setter bound); reads keep the tolerant 0-100 window. 4. Drop the reg-161 End entity from the grid-tied branch entirely -- pylxpweb models the grid-tied stop as reg 67 (set_ac_charge_soc_limits pairs 160 with 67) -- and add the 160<->67 cross-refresh to the related-entity sets. End stays EG4_OFFGRID-only. 5. Equality-checked readback on the named cloud write: success:true with the register still at its old value now raises instead of reporting success; an unreadable readback only logs. 6. Wording: the joyfulhouse#332 note records grid-tied reg-161 as READ-ONLY, not write-rejected -- corrected everywhere, and the CLAUDE.md register table row now reflects the Start/End family split. Tests updated to pin the new gates (the fail-open suppression test once again asserts NO window entities without positive family detection) plus new coverage: EG4_HYBRID setup shape, the 90 write cap, all three readback outcomes (match, mismatch, unreadable), the End entity's reg-161 readback arming, and the LXP exclusion. Adversarial follow-up (independent second review of this fix): corrected the changelog's false "EG4_OFFGRID behavior is unchanged" claim -- the 90 cap and the readback apply to that family too, and an off-grid Start value above 90 written through an earlier version becomes one-way (still displays, can only be re-written at <= 90); documented the hybrid Start entity in docs/CONFIGURATION.md; noted the single-model evidence and NAK blast radius on the widened (158, 4) hybrid read with the split-read escape hatch; and tightened the readback docstring (an absent key skips verification silently, only a raising read logs). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JwHbYjtEufAi3gk2cncLXG
|
Thank you for the thorough review — all six items are addressed in ff15f54. Per item:
Adversarial follow-up — mirroring your process, an independent second review was run against the fix itself before pushing. It verified all six items against pylxpweb 0.9.39b4 source (including that the readback's key naming, cache freshness via Full suite (2262), CI-pinned ruff, and strict mypy are clean. Same disclosure as the PR body: this response and the fix commits were written by Claude Fable 5 (model |
Code review — fix roundFive of the six findings are fully resolved. Adversarial review (gpt-5.6-sol at xhigh) confirmed each against the code rather than the commit message:
Finding 5 is partially resolved, plus two smaller items. 1. The readback can report a successful write as failed. The write is followed by an immediate read with no settle delay and no retry, and the first stale value raises. pylxpweb's own write-and-verify guidance waits ~2s for propagation, and its dongle verifier treats mismatches as diagnostic precisely because delayed clamping and concurrent propagation legitimately differ. So a write the portal accepted and applied can surface to the user as a failure if the immediate read is still stale. 2. An accepted no-op can still report success — finding 5 isn't fully closed. A definite numeric mismatch now fails, but read exceptions are swallowed, and an absent key or non-numeric value skips verification entirely. On the Hybrid fallback path the requested value is then seeded into the local cache as if confirmed. So 3. Some of the new tests don't kill the mutation they're meant to. None of these three block on their own; 1 is the one I'd fix first, since a false failure on a write that did apply is worse for a user than the no-op case it's guarding against — they'll retry a write that already succeeded. |
The reg-120 selector decides what arms grid (AC) charging -- the AC
Charge schedule windows ("Time"), the battery thresholds ("Volt"), or
both ("Time+Volt"), the vendor app's three options. It was invisible to
the integration, so a misconfiguration silently disabled every AC-charge
schedule/threshold control the integration does expose: a live
FlexBOSS21 sat in Volt-only mode for days with its charge windows
configured and inert.
Value space is pinned by a simultaneous local-raw/cloud lockstep on that
FlexBOSS21 (raw 0x0054 alongside cloud BIT_AC_CHARGE_TYPE = 4, plus a
live bitParamControl write of 4 verified by re-read): the app labels
Time/Volt/Time+Volt map to 0/2/4 = the raw bits under mask 0x0E. That
same lockstep disproves pylxpweb's model of this register on every leg,
so the integration bypasses its helpers throughout (all pinned by the
new _MISMODELED_NAMED_PARAMS carve-out in the register contract
harness, which breaks loudly the moment pylxpweb's tables change):
- REGISTER_TO_PARAM_KEYS[120] models the key as single bit 3, so a
transport-served read_named_parameters() returns garbage -- and the
garbage is a BOOL whose False would parse as the legitimate "Time".
Both consumers treat bools as unparseable (render unknown / gate
nothing), LOCAL mode reads the register raw (family-gated, decoded
integration-side, a failure carrying forward the last non-bool value
for this key only rather than pushing the cycle into the joyfulhouse#282
incomplete/early-retry loop), and a coordinator overlay re-reads the
register raw over the transport after any locally-served parameter
refresh, since the HYBRID full-range fetch publishes the mis-decoded
value. (Raw, not pylxpweb's correct-but-hybrid-only
get_ac_charge_type(): the integration's device objects are
GenericInverter -- live-verified AttributeError on first deployment,
caught because the fail-open netting rendered unknown instead of a
wrong label.) When the overlay's re-read fails it drops only
bool-shaped values: on a degraded transport the failures are
correlated and the cached int is the carried-forward corrected
value, which must survive.
- The cloud get/set helpers speak a shifted 0/1/2 enum while the wire
wants 0/2/4. Cloud writes use control_bit_param directly with the
live-verified values.
- Local writes are a verified read-modify-write: foreign reg-120 bits
preserved, post-write readback must match (holding-register writes on
this platform are not trusted blind -- reg 229 silently reverts
rejected writes), and no blind write happens when the pre-read fails.
The related controls now mirror the app: whichever side the firmware
ignores goes unavailable -- the AC Charge schedule times in Volt mode;
the AC Charge SOC Limit and the AC Charge Start/End Voltage pair in
Time mode -- failing open whenever the mode is missing or unrecognized
so a gate never takes working controls away on absent data, and gating
only positively-identified EG4_HYBRID (the one family where the field
is pinned; select creation fails closed the same way per the joyfulhouse#488
review convention). Unavailability rather than the SOC-vs-Voltage
regime's registry-disable treatment is deliberate vendor-app parity:
the app hides the ignored side outright, and unlike the regime split
the mode makes the other side genuinely inert; the trade-off (no
pre-staging while gated) is documented. A register state outside the
app's three options renders as an unknown selection and gates nothing.
An independent adversarial review ran before pushing; its findings are
folded in: the bool-shape fail-open guards and the overlay's
keep-carried-value failure semantics above, the voltage-pair gating
(the threshold side is regs 158-161, not just the SOC limit), an
end-to-end test pinning that the overlay is actually wired into
_refresh_device_parameters (deleting the call now fails a test), and
an honest comment on what the RMW readback can and cannot catch.
Validation: 2378 tests pass (65 new: setup gating, decode incl. the
bool rows, both write routes, RMW verify/refusal semantics, LOCAL read
carry-forward incl. the never-carry-a-bool pin, overlay trust matrix
incl. correlated-failure keep, the end-to-end refresh wiring pin, gate
truth table, and entity gating across times/SOC limit/voltage pair
with non-target controls pinned unaffected); ruff 0.15.5 check+format
and strict mypy clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The reg-120 selector decides what arms grid (AC) charging -- the AC
Charge schedule windows ("Time"), the battery thresholds ("SOC/Volt"), or
both ("Time+SOC/Volt"), the vendor app's three options. It was invisible to
the integration, so a misconfiguration silently disabled every AC-charge
schedule/threshold control the integration does expose: a live
FlexBOSS21 sat in SOC/Volt-only mode for days with its charge windows
configured and inert.
Value space is pinned by a simultaneous local-raw/cloud lockstep on that
FlexBOSS21 (raw 0x0054 alongside cloud BIT_AC_CHARGE_TYPE = 4, plus a
live bitParamControl write of 4 verified by re-read): the app labels
Time, SOC/Volt and Time+SOC/Volt map to 0/2/4 = the raw bits under mask 0x0E. That
same lockstep disproves pylxpweb's model of this register on every leg,
so the integration bypasses its helpers throughout (all pinned by the
new _MISMODELED_NAMED_PARAMS carve-out in the register contract
harness, which breaks loudly the moment pylxpweb's tables change):
- REGISTER_TO_PARAM_KEYS[120] models the key as single bit 3, so a
transport-served read_named_parameters() returns garbage -- and the
garbage is a BOOL whose False would parse as the legitimate "Time".
Both consumers treat bools as unparseable (render unknown / gate
nothing), LOCAL mode reads the register raw (family-gated, decoded
integration-side, a failure carrying forward the last non-bool value
for this key only rather than pushing the cycle into the joyfulhouse#282
incomplete/early-retry loop), and a coordinator overlay re-reads the
register raw over the transport after any locally-served parameter
refresh, since the HYBRID full-range fetch publishes the mis-decoded
value. (Raw, not pylxpweb's correct-but-hybrid-only
get_ac_charge_type(): the integration's device objects are
GenericInverter -- live-verified AttributeError on first deployment,
caught because the fail-open netting rendered unknown instead of a
wrong label.) When the overlay's re-read fails it drops only
bool-shaped values: on a degraded transport the failures are
correlated and the cached int is the carried-forward corrected
value, which must survive.
- The cloud get/set helpers speak a shifted 0/1/2 enum while the wire
wants 0/2/4. Cloud writes use control_bit_param directly with the
live-verified values.
- Local writes are a verified read-modify-write: foreign reg-120 bits
preserved, post-write readback must match (holding-register writes on
this platform are not trusted blind -- reg 229 silently reverts
rejected writes), and no blind write happens when the pre-read fails.
The related controls now mirror the app: whichever side the firmware
ignores goes unavailable -- the AC Charge schedule times in SOC/Volt mode;
the AC Charge SOC Limit and the AC Charge Start/End Voltage pair in
Time mode -- failing open whenever the mode is missing or unrecognized
so a gate never takes working controls away on absent data, and gating
only positively-identified EG4_HYBRID (the one family where the field
is pinned; select creation fails closed the same way per the joyfulhouse#488
review convention). Unavailability rather than the SOC-vs-Voltage
regime's registry-disable treatment is deliberate vendor-app parity:
the app hides the ignored side outright, and unlike the regime split
the mode makes the other side genuinely inert; the trade-off (no
pre-staging while gated) is documented. A register state outside the
app's three options renders as an unknown selection and gates nothing.
An independent adversarial review ran before pushing; its findings are
folded in: the bool-shape fail-open guards and the overlay's
keep-carried-value failure semantics above, the voltage-pair gating
(the threshold side is regs 158-161, not just the SOC limit), an
end-to-end test pinning that the overlay is actually wired into
_refresh_device_parameters (deleting the call now fails a test), and
an honest comment on what the RMW readback can and cannot catch.
Validation: 2378 tests pass (65 new: setup gating, decode incl. the
bool rows, both write routes, RMW verify/refusal semantics, LOCAL read
carry-forward incl. the never-carry-a-bool pin, overlay trust matrix
incl. correlated-failure keep, the end-to-end refresh wiring pin, gate
truth table, and entity gating across times/SOC limit/voltage pair
with non-target controls pinned unaffected); ruff 0.15.5 check+format
and strict mypy clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fix-round review of the reg-160/67 Start-SOC work. Three items, none
blocking on their own; item 1 fixed first since a false failure on a
write that DID apply is worse than the no-op it guards -- the user
retries a write that already landed.
1. The readback no longer fails a write that merely applied a beat late.
The portal can ACK a holdParam write seconds before the register
reflects it (firmware-side delayed apply / concurrent propagation,
matching pylxpweb's own ~2 s write-and-verify guidance), so the first
read is taken with no delay (fast path for a write that landed at
once) and only a value that STAYS wrong across a settle re-read
(_READBACK_SETTLE_SECONDS) fails the write.
2. The changelog no longer overstates the guarantee. The readback
catches a definite, persistent numeric mismatch and nothing more: a
readback that cannot testify -- a read that raises, an absent key, or
a non-numeric value -- is trusted rather than failed (a flaky read
must not fail a write that in all likelihood landed). So a
success:true whose readback times out or omits the key still reports
success; the wording now says the readback NARROWS the
acknowledged-but-unapplied no-op class without eliminating it, and the
docstring matches.
3. The readback tests now bite the mutations they cover:
- the match and the flaky-read tests assert read_parameters was
actually awaited (a "verification removed" mutation now fails them);
- a new test pins the settle/retry -- a stale-then-correct readback
succeeds without a false failure -- and another pins the absent-key
trust path;
- the 90 write cap is pinned by an explicit boundary test (90 accepted,
91 rejected), independent of the shared coerce-int parametrization;
- a new parametrized read test pins the promised 91-100 legacy read
behaviour: a register value above the write cap still DISPLAYS
(reads keep the tolerant 0-100 window), rather than blanking.
Full suite 2268 passed, ruff and strict mypy clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Thanks — all three are addressed in 1. The readback no longer fails a slow-to-apply write. The first read is still taken immediately — the fast path for a write that lands at once — but a mismatch there is no longer decisive. Only a value that STAYS wrong across a settle re-read ( 2. The claim is narrowed to what the code guarantees. You're right that a definite numeric mismatch is all that fails: a readback that cannot testify — a read that raises, an absent key, or a non-numeric value — is trusted rather than failed, and the Hybrid path then seeds the optimistic value as before. I kept that fail-safe direction (it's the counterweight to item 1) and instead narrowed the CHANGELOG: it now says the readback narrows the acknowledged-but-unapplied no-op class without eliminating it — a 3. The tests now bite.
Full suite 2268, ruff and strict mypy clean. Same disclosure as before: this response and the fix commit were written by Claude Fable 5 (model |
Summary
The reg-160/161 AC Charge SOC window entities (
ACChargeStartBatterySOCNumber/ACChargeEndBatterySOCNumber, added for EG4_OFFGRID in #331) are also live —and currently invisible — on grid-tied families. This PR creates them in the
grid-tied setup branch too: Start enabled by default, End created
disabled by default pending a verified grid-tied write (the PR #332 review
noted reg-161 writes observed rejected on grid-tied hardware).
Hardware evidence (FlexBOSS21, EG4_HYBRID, fw FAAB-2727, local WiFi dongle)
Register 160 initiates AC charging whenever battery SOC is below its value,
regardless of the reg-120 ACChargeType selector and of the AC-charge time
windows. Observed over three consecutive nights with
ACChargeType = "according to time"(reg 120 low bits0x52) and AC Charge enabled (reg 21bit 7):
outside every configured window (battery oscillated 90↔100 overnight), and
an in-progress forced discharge was overridden at SOC 90. Net effect: the
battery was pinned high around the clock and every ToU schedule silently
defeated.
(01:00–03:00) with the battery at SOC 19 — 19 > 10, so the trigger never
fired. The official 18KPV Modbus protocol says regs 160/161 are "valid after
selecting ACChg according to SOC"; this firmware applies the start-SOC gate
in time mode as well.
read-back); reg 161 reads correctly (100), write untested here.
The EG4 portal/app exposes the same field for this family as "Start AC
Charge SOC(%)", so users can set it (or ship with the factory 90) while
Home Assistant has no way to see why their charge schedule isn't honored.
That makes the entity arguably more important on grid-tied units than on
EG4_OFFGRID, where it's at least the family's primary, documented control.
Changes
number.py: grid-tied/fail-open setup branch also createsACChargeStartBatterySOCNumber(enabled) andACChargeEndBatterySOCNumber(
entity_registry_enabled_default = Falseon this branch); class docstringsupdated from "EG4_OFFGRID only" to both families with the evidence above.
EG4_OFFGRID behavior unchanged.
tests/test_number_entities.py: grid-tied setup test now expects 25entities with the pair present (Start enabled-default, End disabled-default
asserted); fail-open test updated likewise (23 entities).
CHANGELOG.md: Unreleased entry.Testing
pytest tests/test_number_entities.pypasses.ruff check/ruff format --checkclean on the touched files (no newfindings vs main).
unit that produced the register evidence.
Claude AI
Note, this code was generated by Claude Code and I manually reviewed the cod prior to sending PR.