Skip to content

fix(hanchu): event-loop write fix, #5672 review fixes, model ratings, no Freeze Export, hold backstop, docs - #5684

Merged
springfall2008 merged 9 commits into
mainfrom
fix/hanchu-review-and-followups
Oct 22, 2026
Merged

springfall2008 merged 9 commits into
mainfrom
fix/hanchu-review-and-followups

Conversation

@springfall2008

@springfall2008 springfall2008 commented Oct 21, 2026 •

Copy link
Copy Markdown
Owner

One PR for the Hanchu ESS cloud component's open fixes. It supersedes #5682, #5674 and #5680, which are closed in favour of it. Each original is one or two commits here, in merge order, so the history still reads PR by PR:

Commits From What
0cf78975, 4692095c #5682 Plan-thread writes failed and landed a minute late (event-loop bug) + debug-journal entry
268827b2 #5674 Fixes from the code review in #5672
f39a5a06, 0ac65fd2 #5680 upton68's follow-up tickets + docs
66edcb61 review of #5680 Never write a power limit above what the device is known to accept
d6256b0a hardware feedback Cap the writable power limit at a known model's battery limit
47806948 final review Handle the component stopping under a handed-over write; reword the timeout's comment
c5b4dad9 maintainer request API trace on by default for customer debugging

Closes #5664, closes #5665, closes #5667, closes #5668. Addresses #5672 findings 1, 2, 3, 7, 8, 9 and 10 (4 is answered by hardware evidence; 5 and 6 are still open, see below).

1. Plan-thread writes failed and arrived a minute late (#5682) - a bug in v9.4.3

When Predbat's plan thread pressed the schedule-write switch, every write failed with Hanchu /gateway/app/ha/iotSet transport failure: Timeout context manager should be used inside a task. The component's own tick wrote the payload about a minute later, so every plan change reached the inverter roughly a minute late. The doomed retries also blocked the plan thread for about 12 s per press.

Cause: the press arrives through ha.py's loopback call_service → run_async(trigger_callback(...)), which runs it on a throwaway event loop. _handle_control_event then wrote from there, using the persistent aiohttp session and the asyncio write lock, both of which belong to the loop run() ticks on.

Fix:

  • run() records its loop.
  • The write-button branch awaits apply_schedule through _on_component_loop(), which hands it over with run_coroutine_threadsafe, the same hand-over gateway.py's _publish_raw() makes.
  • The wait is shielded and capped at HANCHU_LOOP_HANDOVER_TIMEOUT (120 s). A stuck loop cannot hold the plan thread, and giving up the wait never cancels the write.
  • On the component's own loop, or with that loop not running, the write runs in place as before. If the component stops or restarts under a waiting caller (47806948), a write its loop cancels is logged rather than escaping as a CancelledError, and a write the stopped loop will never run is given up within HANCHU_LOOP_HANDOVER_POLL (1 s) rather than after the full timeout.

End-to-end check (scratch, not committed): a real ClientSession on an owner loop, a local aiohttp server standing in for the cloud, and the press driven through the real run_async.

  • Before: 0 writes, the exact error, plan thread blocked 12.0 s.
  • After: 1 write, 0.0 s.

The trap is recorded in tools/debug-journal.md.

2. Code review findings from #5672 (#5674)

# Finding Change
1 The 6 kW power-ceiling raise undid itself ranges_for() raises the menu's ceiling to the highest value ever seen per device and key (power_ceiling), saved with the config cache (seeded from cached device_settings for an older cache).
2 Changes made on the device were never corrected refresh_config() compares each iotGet read-back with applied_payload and, on a mismatch, logs the keys and forgets the applied payload, so the next reconcile resends it. Keys missing from the read-back are ignored. A read within HANCHU_READBACK_SETTLE (60 s) of a write, or one that started before it, is not judged.
3 An HTTP 401 with an empty or non-JSON body never re-authenticated The 401 check runs before the body-shape check.
7 "Magnitude < 10 means kW" applied to every power field Only fields with no unit sibling (loadPwr) use it.
8 CHG_PWR_LMT=0 was written when no hold slot could be placed The charge power is decided after the extra-slot decision.
9 A 24:00:00 end gave TCT_END_1=86400 Clamped to 23:59, as hhmmss_to_seconds() does.
10 api_debug = True was hard-coded New hanchu_api_debug option. Since c5b4dad9 it defaults on, at the maintainer's request, so a customer's log carries the trace for debugging, matching Sunsynk/AlphaESS/DEYE. hanchu_api_debug: False quietens it. The CLI harness traces unless --no-trace.

3. Follow-ups from upton68's tickets (#5680)

Ticket Change
#5664 Ratings from the model. A HANCHU_MODELS table keyed on getDeviceStatus devModel (names from the console's pcsEnMap, ratings from the manuals, both as posted on #5664). Battery limit → battery_rate_max, AC rating → inverter_limit, which is what Predbat plans with. The table never raises what is written: CHG/DSCHG power limits stay within the menu's maximum or a higher limit the device has been seen holding (66edcb61). Where a rating is above that, the gap is logged once per key. The writable limit is also capped at a known model's battery limit (d6256b0a): a HESS-HY-S-6.0K holding DSCHG_PWR_LMT=6000 still exports at its 5000 W battery limit, so a held setting above the battery limit moves no more power, and the 6.0K's export power control no longer advertises 6000 W. Unknown codes, and 28/29 (no stated battery limit), fall back to the menu/held derivation, and user overrides still win. The model is logged once and reported as info.model in the discovery record. The default-power warning waits for the first status poll and is not given for a model the table covers.
#5665 No Freeze Export. support_discharge_freeze: False, because CHG_PWR_LMT only limits grid charging, so a zero charge rate cannot stop solar charging the battery. The ≥50% "Maximum Charge SOC" alternative is not done: it needs a guaranteed restore.
#5667 Hold backstop. During a hold, DTU_AC_CHG_SOC_LMT is capped at max(int(SoC), 20) in the same iotSet, so a 0 W write that did not land cannot grid-charge to a later window's target. A limit already written at or below the SoC is kept, so solar raising the SoC does not send a write every percent.
#5668 + docs templates/hanchu.yaml says Predbat clears the app's six slots at its first write, and why, and that Freeze Export is unavailable. docs/inverter-setup.md gains a Hanchu ESS Cloud section and table row (setup, control behaviour, the devModel ratings, the diagnostics CLI), and its Hanchu iESS section no longer says there is no native integration.

Review points from #5680

  1. Fixed in 66edcb61. The table's battery limit used to raise the write clamp above the menu's 0-5000 W, and a 7-10 kW unit that refused e.g. CHG_PWR_LMT=10000 would have made every write a partial accept. Now the table only sets the ratings, writes stay within proven-accepted limits, and test_hanchu_model_rating_never_raises_what_is_written covers the payload. The trade-off: on such a unit, forced charge and export windows run at the writable limit while the plan models the rating, until the device is seen holding more.
  2. Low, open. The hold cap's "keep the written value" rule also applies to the in-window target-met hold, where a lower old value can stand in for the window's target. That is harmless (the limit only governs grid charging), but it could be restricted to holds added outside a window.
  3. Resolved by 1. The "holds … above the menu's maximum" log can no longer quote a table figure, because the table no longer raises the ceiling.

Decisions for review

  • Freeze Export is off unconditionally. The evidence comes from an AC-coupled HESS-HY-S-5.0K only. A sunshine --live-window freeze-export run on a DC-coupled unit decides whether it can be relaxed per unit, via the grid_charge_only attribute.
  • The hold backstop is not hardware-tested through this component. upton68 runs the same SoC cap daily via their BLE automation.
  • The ratings table is transcribed from the Hanchu: battery_rate_max / inverter_limit depend on held power limits, not the unit's rating #5664 comment, not checked against the manuals.

Still open, not in this PR

#5672 finding 5 (the export floor is applied only once the window is active, which needs a behaviour decision) and finding 6 (a restart can write an all-disabled schedule). upton68 confirmed finding 6 on a standalone Docker instance, but the same pattern exists in alphaess, fox, deye and sunsynk, so it belongs in a cross-component fix.

Tests

  • Every new test was checked red against the code before its change.
  • The only conflict between the three branches was add/add in test_hanchu_control.py's test functions and run_hanchu_control_tests registration list. Both sides were kept: 38 tests defined, 38 registered, no duplicates.
  • The combined branch's Hanchu delta over fix(hanchu): ratings from devModel, no Freeze Export, hold grid-charge backstop #5680's head is exactly fix(hanchu): write a plan-thread schedule press on the component's own loop #5682's diff.
  • All six Hanchu suites and ./run_all --quick pass on the combined head (4 slow tests skipped), and pre-commit is clean on every changed file.
  • GitNexus's index predates hanchu.py. Grep shows the changed symbols are used only by hanchu.py, hanchu_const.py and their tests, plus the HanchuCloud INVERTER_DEF row and the hanchu_api_debug registry/schema entries.

🤖 Generated with Claude Code

CI and others added 5 commits October 21, 2026 22:25
…n loop

Predbat presses the Hanchu write switch from its plan thread through
ha.py's run_async(), which runs the event on a throwaway event loop. The
component wrote from there with its persistent aiohttp session and
asyncio write_lock, both of which belong to the loop run() ticks on, so
every press failed with "Timeout context manager should be used inside a
task". The retries also blocked the plan thread for about 12 s. The change
then reached the device only on the component's next tick, about a
minute later.

run() now records its loop, and _handle_control_event hands the write
over to it with run_coroutine_threadsafe - the same hand-over
gateway.py's _publish_raw() makes. The wait is shielded and capped at
HANCHU_LOOP_HANDOVER_TIMEOUT, so a stuck loop cannot hold the plan thread
and giving up the wait never cancels the write. On the component's own
loop, or with that loop not running, the write runs in place as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Power ceiling: raise the menu's 0-5000 W range to the highest value the
  device has ever been seen holding (persisted with the config cache),
  not the value held now - Predbat's own write overwrote it, so the 6 kW
  raise undid itself after the first write (finding 1).
- Drift: compare each control read-back with the applied payload and
  forget it on a mismatch, so a change made in the Hanchu app, a device
  reset or a lost write is re-sent (finding 2).
- HTTP 401: re-authenticate on the status before the body's shape is
  checked, so an empty or HTML 401 body no longer skips re-auth (finding 3).
- Units: the below-10-means-kW heuristic applies only to fields with no
  unit sibling (loadPwr), not to grid and battery power (finding 7).
- Hold: decide the zero charge rate after the hold slot is placed, so a
  hold with no room leaves the plan's charge rate alone (finding 8).
- Midnight: clamp a 24:00:00 end to 23:59 so TCT_END is never 86400
  (finding 9).
- Logging: the API trace is now hanchu_api_debug (default off), and
  "settings unchanged" is only logged at debug level with the trace on
  (finding 10).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e backstop

- battery_rate_max and inverter_limit come from a devModel -> rating table
  (HANCHU_MODELS) built from the console's pcsEnMap and the manuals, with the
  menu/held-value derivation kept for unknown codes and the user overrides still
  winning. The table's battery limit also raises the power clamp ceiling, so a
  10 kW unit can be asked for 10 kW. The model name is logged once and reported
  in the discovery record. The default-power warning waits for the first status
  poll, and is not given for a model the table covers (#5664).
- support_discharge_freeze is False for HanchuCloud: CHG_PWR_LMT only limits
  charging from the grid, so a zero charge rate cannot stop solar filling the
  battery (#5665).
- During a hold the grid-charge limit (DTU_AC_CHG_SOC_LMT) is capped at the
  current SoC, so a 0 W charge slot whose write did not land cannot grid charge
  to a later window's target. A limit already written at or below the SoC is
  kept, so solar raising the SoC does not send a write every percent (#5667).
- templates/hanchu.yaml: say that Predbat clears the Hanchu app's time slots at
  its first write and why, that Freeze Export is unavailable, and how the
  ratings are found; drop the stale "every request is traced" line (#5668).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
docs/inverter-setup.md had no section for the native component, and its
Hanchu iESS section still said there was no native integration. Add a
"Hanchu ESS Cloud" section and table row covering setup, control
behaviour (app slots cleared, no Freeze Export, hold grid-charge cap, SoC
limits, write pacing), the devModel power ratings and the diagnostics CLI,
and point the Service API section at it.

Also correct _apply_model's docstring: device_detail reaches the static
cache only at the next static refresh, not straight away.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@springfall2008 springfall2008 left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated comment from the triage bot.

Review of head 0ac65fd2 (the combined branch superseding #5682, #5674 and #5680): 1 finding inline (low; it is the body's own open point 3, confirmed still present in this head), and nothing new beyond it. The prior PR-by-PR findings carry over cleanly into the combined history.

Checked against the head itself (read of the checkout, not main):

  • HANCHU_MODELS (apps/predbat/hanchu_const.py) matches #5664's table line for line — all 32 codes and the battery/AC figures, including the battery entries marked "not stated" for 28 (HESS-HY-S-6K-S) and 29 (HESS-HY-S-12K-S).
  • _note_external_change()'s slot-drift coverage is real in production, not just in the test's mock: the iotGet read-back keys argument is HANCHU_CONTROL_KEYS (hanchu_const.py:347), which includes all twelve TCT_/TDT_ slot keys as well as the six scalars, so an app-edited slot time actually is compared. (I went in suspecting the mock was generous here — it is not.)
  • The 401 fix covers both body shapes: a genuinely empty 401 body makes response.json() raise ValueError, which the new inner except catches into payload=None — the same path the test's unparseable_response(401, ValueError(...)) models for an HTML body — so both re-authenticate.
  • inverter_record() (apps/predbat/coordinator.py:1148) takes info= natively and drops an empty dict, so "info" in record being False for an unknown model matches the builder's own convention.
  • The 38/38 registration claim on test_hanchu_control.py holds: 38 def test_hanchu_* definitions, 38 registered tuples, no duplicate names and no duplicate registrations.
  • split_window_seconds()'s clamp now agrees with hhmmss_to_seconds() for a 24:00:00 end; HANCHU_TTL_CONFIG = 30 matches the new doc's "(every 30 minutes)"; the CLI keeps the trace on by default (hanchu.py:2491, api_debug = not args.no_trace).
  • ha.py run_async() (apps/predbat/ha.py:50) confirmed as the throwaway-loop press path the handover fixes, with gateway.py:2687 as the precedent.

On the two decision points the body asks about:

  1. The table raising the write clamp above the menu (open point 1) — I would keep the current shape. The refusal path is loud where it matters: a partial accept is not cached as applied (hanchu.py:1846), so it is rebuilt and resent each paced cycle, a per-key refusal from the ack map is named in the log, and the user is notified after three consecutive failures. The "safer" shape has the opposite cost: a fresh 10 kW unit whose settings still hold 5000 W would be planned at 10000 and then have every window's write clamped back to 5000 behind a single one-time clamp warning — quiet, systematic derating that nobody is told about. If a larger unit does refuse, the failure is diagnosable from the log and reversible in one line; the derating of the alternative is neither.
  2. The in-window hold keeping the written grid limit (open point 2) — agree it is harmless, and it is actually correct as written: at a target-met hold, SoC >= target, so the cap is the target itself and any kept lower written value is below the SoC — grid charging cannot reach the limit even when the zero-power write did not land, so the fallback charges no more than the plan asked for. A written value above the cap (SoC having fallen below it) is re-derived, as the test's third case shows. I see nothing that needs restricting.

The body's still-open items (#5672 findings 5 and 6, cross-component restart schedule write) are as agreed — nothing to add from this review.

Comment thread apps/predbat/hanchu.py Outdated
merged[key] = (low, int(held))
if (sn, "held", key) not in self._clamp_logged:
self._clamp_logged.add((sn, "held", key))
self.log("Info: Hanchu {} holds {} {:.0f} W, above the menu's {} W maximum (a range the menu shares across models), so {:.0f} W is used as its limit".format(sn, key, held, high, held))

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated comment from the triage bot.

Confirmed in this head, and it is open point 3 in the body — this line can now quote a figure that came from HANCHU_MODELS, not the menu. After the table raise above (battery_limit > high), high here is the table's battery limit wherever the menu said nothing or said 5000: the T-06K-and-above models are not in the menu's power item at all (#5664's menu dump), so device_ranges holds no bound, merged is the built-in (0, 5000), the table raises high to e.g. 6000 for a HESS-HY-T-06K, and any held value above 6000 logs "above the menu's 6000 W maximum" — where the menu said nothing and 6000 came from the table. Cosmetic and once per (device, key) via _clamp_logged, but the provenance it claims is now wrong. Simplest fix is to reword this one line to what the figure actually is, e.g. "above the {} W power ceiling (menu or model)".

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 66edcb61 and d6256b0a. The model table no longer raises the ceiling at all: ranges_for() computes min(max(menu, highest held), battery limit). The "held above the menu" line now fires only when the result is actually above the menu's bound, and it quotes the menu's own figure and the capped limit used. Covered by test_hanchu_model_battery_limit_caps_a_higher_held_setting.

@springfall2008 springfall2008 removed the BOT_REVIEW Trigger an autotriage label Oct 21, 2026
CI and others added 2 commits October 21, 2026 22:41
… to accept

The devModel table raised the CHG/DSCHG write clamp to the model's battery
limit, above the settings menu's 0-5000 W, with nothing showing the cloud
accepts that. A 7-10 kW unit that refused e.g. CHG_PWR_LMT=10000 would turn
every write into a partial accept, resent each cycle with failure
notifications and no applied_payload.

The table now sets only battery_rate_max and inverter_limit. What is
written stays within the menu's maximum or a higher limit the device has
been seen holding. Where the rating is above that, the gap is logged once
per key after a settings read. This also removes the case where the
"holds ... above the menu's maximum" log quoted the table's figure as the
menu's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…limit

A HESS-HY-S-6.0K holds DSCHG_PWR_LMT 6000 against the menu's 0-5000 W but
still exports at its 5000 W battery limit, so a held setting above the
battery limit moves no more power. ranges_for() now caps the ceiling at
the model's battery limit where HANCHU_MODELS knows it, and the 6.0K's
export power control no longer advertises 6000 W. A larger unit seen
holding above the menu keeps that, up to its own battery limit, and an
unknown model is unchanged.

Also corrects the rationale repeated in ranges_for(), inverter_limit() and
a test docstring, that taking the menu's word "would cap every discharge
below the inverter's rating" on the 6.0K.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@springfall2008 springfall2008 left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated comment from the triage bot.

Final review of head d6256b0: 2 findings inline, both low, 0 in the body. No correctness issue blocks merging. No code was run, only read; CI's pre-commit job (which runs the quick suite) passed at this head.

What I re-checked across the combined change, beyond each part's own earlier review:

  • Event-loop handover vs #5672 drift detection. A handed-over write now runs on the same loop as refresh_config(). _note_external_change() skips any read that began before a write's last_write_time, or within HANCHU_READBACK_SETTLE of it, so an event write cannot be judged as drift by a read already in flight.
  • Model table vs power_ceiling. ranges_for() is now min(max(menu, highest held), battery limit) for a known model, and unchanged for an unknown one. The 6.0K (held DSCHG 6000) gives 5000/5000 with no raise or gap log. A 10 kW unit gets min(held, 10000), and the "held above the menu" log quotes the capped figure. The published power controls' max and the write clamp both read this, so they agree. The daemon's earlier point (the log quoting a table figure as the menu's) is gone, because the table no longer raises a ceiling.
  • Hold backstop vs drift and partial accepts. _hold_grid_target()'s keep rule reads applied_payload. That is popped on drift and never set on a partial accept, so a stale value is never kept. Open point 2 in the PR body (the keep rule also applies to the in-window target-met hold) is still present and still harmless; I have not re-posted it.
  • support_discharge_freeze: False. INVERTER_DEF, HANCHU_CAPABILITIES and test_hanchu_inverter_def_complete agree. execute.py forces freeze export off fleet-wide, and the docs quote the log line it writes.
  • Tests and docs. Every new test is registered (the control and API runners, with no duplicates after the cherry-pick merge). Each change's test asserts the behaviour it changes. The docs in docs/inverter-setup.md and templates/hanchu.yaml match the code at this head, including the battery-limit cap and the 30-minute drift re-read.

Comment thread apps/predbat/hanchu.py Outdated
Comment thread apps/predbat/hanchu_const.py
CI and others added 2 commits October 22, 2026 08:14
From the final review of #5684. _on_component_loop() only caught
TimeoutError, so two shutdown/restart paths misbehaved:

- asyncio.run() ending on the component's loop cancels the write in
  flight, and the CancelledError escaped the hand-over, killing
  run_async()'s thread with a traceback. It is now logged and returns
  False, while a cancellation of the caller itself is still re-raised.
- The loop could stop between the is_running() check and the queued
  write starting, which then never ran, and the caller waited the full
  120 s. The wait is now taken in HANCHU_LOOP_HANDOVER_POLL steps and
  abandoned as soon as the loop is no longer running.

Also reword HANCHU_LOOP_HANDOVER_TIMEOUT's comment: 120 s is not above a
write's worst case, and it does not need to be, because giving up only
stops the waiting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r debugging

hanchu_api_debug now defaults to True. The Hanchu API is undocumented,
so a customer's log needs the request/response trace when something goes
wrong, and asking them to turn it on and wait for the problem to recur
loses the evidence. This matches the Sunsynk and AlphaESS components,
which also trace by default. hanchu_api_debug: False quietens the log once
things work. The template and docs say so.

MockHanchu passes api_debug=False explicitly, so the other tests' log
assertions don't wade through traces.
test_hanchu_api_trace_is_on_unless_turned_off pins the real default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@springfall2008
springfall2008 merged commit 4e6882d into main Oct 22, 2026
2 checks passed
@springfall2008
springfall2008 deleted the fix/hanchu-review-and-followups branch October 22, 2026 07:48
springfall2008 added a commit that referenced this pull request Oct 23, 2026
…tes); mark #5656/#5657 and Hanchu #5684 on main (#5700)

Co-authored-by: CI <ci@example.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment