Repository navigation
fix(hanchu): ratings from devModel, no Freeze Export, hold grid-charge backstop - #5680
springfall2008 wants to merge 2 commits into
Conversation
…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>
springfall2008
left a comment
There was a problem hiding this comment.
Automated comment from the triage bot.
Reviewed 8290f81 (fix(hanchu): ratings from devModel, no Freeze Export, hold grid-charge backstop): 1 finding inline, 0 in the body. One low-severity log-accuracy issue in ranges_for(); everything else checked clean.
What I verified: the support_discharge_freeze: False gate is real end to end (execute.py:1418 forces set_export_freeze/set_export_freeze_only off for an inverter whose INVERTER_DEF row disables it, plan.py:1336/3028 then never plans freeze options, and plan.py:5147 drops manual freeze-export times to demand); HANCHU_CAPABILITIES, the INVERTER_DEF row and test_hanchu_inverter_def_complete all agree. The hold grid-charge cap (hanchu.py:1599) holds up across its scenarios: cap at SoC, keep-branch while the written value is within [low, cap] (so rising solar SoC sends nothing, falling SoC brings the limit down), the 20% device floor, restore-on-hold-end via the normal branch, and in-window holds unchanged — and the keep-branch migrates safely over a pre-PR applied_payload that still carries 100. If the SoC is unknown the backstop is skipped as before, which is no worse than pre-PR behaviour. The model-rating path is consistent end to end: ranges_for() raises the CHG/DSCHG clamp ceilings without ever lowering one (held/menuraise still wins above the table), battery_rate_max()/inverter_limit() prefer the table with the user override still on top, and the ratings flow through the published battery_rate_max/inverter_limit sensors into set_arg_auto. The ratings table values are transcriptions from the #5664 report and are not checkable from code; the lookup itself (including the battery-None codes 28/29 and unknown-code fallback to the menu/held derivation) behaves as described. Tests cover each new behaviour at the point where reverting the change would fail them, and all suites are registered. Template notes match the code (three charge + three discharge slots written, unused ones 00:00-00:00; tracing opt-in via hanchu_api_debug, which is indeed further down the template).
| for key in (HANCHU_KEY_CHARGE_POWER, HANCHU_KEY_DISCHARGE_POWER): | ||
| held = max(self._as_float(settings.get(key), 0.0), self._as_float(ceiling.get(key), 0.0)) | ||
| low, high = merged[key] | ||
| if battery_limit > high: |
There was a problem hiding this comment.
Automated comment from the triage bot.
After this raise, the once-per-device "held above" log (hanchu.py:849) can quote the ratings table's value as the menu's maximum.
The raise sets high = battery_limit before the held > high check, and the log text still says "above the menu's {} W maximum (a range the menu shares across models)". When the device holds a value above even the model's battery limit, the message prints the table's limit where the menu's bound was intended: e.g. a HESS-HY-T-06K (devModel 7, battery 6000 W) with menu bounds 0-5000 holding DSCHG_PWR_LMT 7000 logs "above the menu's 6000 W maximum (a range the menu shares across models)" - 6000 is the table's figure, the menu said 5000, and "a range the menu shares across models" no longer describes the source. Someone diagnosing why a 7000 W hold is accepted gets pointed at the menu rather than the ratings table.
Verified by reading ranges_for() (hanchu.py:838-849): the mislabel needs battery_limit > menu-high AND held > battery_limit, so it is rare, but the new raise makes it reachable on exactly the models the table now covers. A word like "menu's" replaced with the actual source (or the log mentioning the table when it raised the ceiling) would keep the diagnostic honest.
springfall2008
left a comment
There was a problem hiding this comment.
Automated comment from the triage bot.
Reviewed head 8290f81: 4 findings, 3 inline and 1 in this body. One plausible correctness risk: the clamp is raised above the menu maximum on the table's word alone. The rest are low severity: two inaccurate claims and a docs gap. No code was run, only read. Checked: every hunk's enclosing function; the write path, including partial-accept handling; the poll-tier order in run(); the static-cache save timing; the INVERTER_DEF freeze path through inverter.py/execute.py/plan.py (no kernel change is needed, because it is a planner capability flag); and that every new test is registered and would fail with its change reverted.
docs/inverter-setup.md:628
The user-facing changes here are documented only in templates/hanchu.yaml. They are that Freeze Export is no longer planned on Hanchu, that the ratings now come from the model code (and the log line saying so), and that the app's slots are cleared at the first write. docs/inverter-setup.md has no section for the native component. Its "Hanchu iESS" section (line 628 onwards) still opens with "The Hanchu iESS has no native Predbat integration", which has been wrong since #5305. A user who checks the docs to find out why Freeze Export vanished from their plan finds nothing. The GE 3-phase precedent puts exactly this statement in the docs (docs/inverter-setup.md:174-180). The gap is mostly pre-existing, but this PR adds behaviour that the docs, rather than the template, are where users look for. A short "Hanchu ESS (native cloud)" section would cover it: what is controlled, no Freeze Export, slot clearing and the model-based ratings, with a pointer to templates/hanchu.yaml.
| for key in (HANCHU_KEY_CHARGE_POWER, HANCHU_KEY_DISCHARGE_POWER): | ||
| held = max(self._as_float(settings.get(key), 0.0), self._as_float(ceiling.get(key), 0.0)) | ||
| low, high = merged[key] | ||
| if battery_limit > high: |
There was a problem hiding this comment.
Automated comment from the triage bot.
Plausible, not confirmed on hardware: raising the clamp ceiling to the table's battery limit can make every write a partial accept.
Before this PR the CHG/DSCHG ceiling was raised only to a value the device had been seen holding, which is evidence that it accepts that value. Now a S-7.0K/8.0K/10.0K (or any 3-phase unit above 5 kW) whose menu says 0-5000 gets a ceiling of 7000-10000 from HANCHU_MODELS alone, and the planner asks for that, because battery_rate_max now reports it. Nothing yet shows the cloud accepts a CHG_PWR_LMT above the menu's maxVal on those models. upton68's #5664 comment only says the manual rates them higher.
Failure scenario: a 10.0K holding 5000 W, with a charge window at 10000 W. If the device refuses the key, rejected_keys() names CHG_PWR_LMT, and _write_payload_locked (hanchu.py:1817-1823) takes the partial-accept branch on every paced cycle. The payload is never cached as applied, so it is resent each cycle and HANCHU_WRITE_FAILURE_NOTIFY fires after three attempts. Because applied_payload stays empty, #5674's drift check and this PR's hold-cap "keep" rule never engage. The charge power stays at the old value while the plan models 10 kW.
Before the change the same plan was clamped to 5000 and accepted, with the plan over-optimistic but the writes clean. The tests cover only ranges_for()'s output, not a write.
Two safer shapes:
- Let the table set
battery_rate_max/inverter_limitbut keep the clamp at max(menu, highest held). The plan and device can then disagree, but only by a figure that is logged once. - Or raise the clamp only for models seen holding above the menu, and note in the PR body that 7-10 kW single-phase units need a hardware check before relying on it.
| low = ranges.get(HANCHU_KEY_GRID_CHARGE_SOC, HANCHU_RANGES[HANCHU_KEY_GRID_CHARGE_SOC])[0] | ||
| cap = min(self._as_float(target, 0), max(int(self._as_float(soc, 0)), low)) | ||
| written = (self.applied_payload.get(sn) or {}).get(HANCHU_KEY_GRID_CHARGE_SOC) | ||
| if written is not None and low <= self._as_float(written, -1) <= cap: |
There was a problem hiding this comment.
Automated comment from the triage bot.
Low: the "keep the written value" rule also applies to the in-window target-met hold, so that shape is not "unchanged" as the PR body and the new test say.
_hold_grid_target runs for every signal == "hold", including the ("hold", None) returned for a running charge window whose target is met. There, cap = min(target, SoC) is the window target, and any lower value left in applied_payload is kept instead. For example, it might be 20, written before the window opened when no charge window existed (target 0, clamped to 20).
Scenario: a window 19:00-21:00 with target 50, SoC 60, and a previously applied DTU_AC_CHG_SOC_LMT of 20. The payload keeps 20, not 50. That is functionally harmless: the target is met, the limit only governs grid charging, and once SoC falls below 50 _rate_signal returns no hold, so the normal branch writes 50 again on the next cycle.
The last block of test_hanchu_hold_caps_the_grid_charge_limit_at_the_soc asserts 50 "as it is" only because the test popped applied_payload a few lines earlier. Either restrict the keep rule to the added-slot hold (extra_direction set, where it was meant to stop per-percent writes), or reword the claim and add the sticky case to the test.
| def _apply_model(self, sn, code): | ||
| """Record a device's devModel and its name, saying once what the ratings table makes of it. | ||
|
|
||
| Kept in device_detail, which the static cache saves, so a restart has the ratings before the |
There was a problem hiding this comment.
Automated comment from the triage bot.
Low: the docstring overclaims the cache. "Kept in device_detail, which the static cache saves, so a restart has the ratings before the first status poll" holds only once a static refresh has run after a status poll.
save_static() is called only from refresh_static() (hanchu.py:569), which runs before refresh_config/refresh_power in a cycle (hanchu.py:2028 onwards) and then not again for HANCHU_TTL_STATIC (8 h). So model_code first reaches the cache up to 8 h after the first status poll that reported it. A restart inside that window comes up without it.
The consequence is small, because _warn_if_default_power's deferral handles a missing model correctly and the status poll fills it in within the same cycle. But the comment describes a guarantee the code does not make, and no test covers the restore. Either call save_static() when _apply_model records a new or changed code, or drop the restart claim.
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>
|
I've checked the |
Follow-ups to the Hanchu component from upton68's tickets. Stacked on #5674. The base is
fix/hanchu-review-5672, so this diff shows only the new commit. GitHub retargets it tomainonce #5674 merges.Closes #5664, closes #5665, closes #5667, closes #5668.
Changes
battery_rate_max/inverter_limitcame from the menu's shared 0–5000 W item plus whatever limits the device held, so they were wrong for most modelsHANCHU_MODELStable, keyed ongetDeviceStatusdevModel. Names come from the console'spcsEnMapand ratings from the manuals, both as posted on #5664. Battery limit →battery_rate_max, AC rating →inverter_limit. The table's battery limit also raises the CHG/DSCHG clamp ceiling, so a 10 kW unit can be asked for 10 kW. Nothing lowers a ceiling: a value the device accepts is never refused. Unknown codes, and 28/29 (no stated battery limit), fall back to the menu/held-value derivation from #5674. User overrides still win. The model is logged once (Hanchu … is a HESS-HY-S-5.0K (devModel 4): battery limit 5000 W, AC rating 5000 W), kept indevice_detail(static cache) and reported asinfo.modelin the discovery record.CHG_PWR_LMTonly limits grid charging, so solar kept filling the batterysupport_discharge_freeze: Falsefor HanchuCloud (INVERTER_DEFandHANCHU_CAPABILITIES). Predbat stops planning Freeze Export. The ≥50%CHG_BAT_SOC_LMThold is not done: it needs a guaranteed restore of the user's value, which the component doesn't have.DTU_AC_CHG_SOC_LMTcarried the next window's target (up to 100), so a 0 W write that didn't land could grid-chargemax(int(SoC), 20), in the sameiotSet. A limit already written at or below the SoC is kept, so solar raising the SoC during a hold doesn't send a slow cloud write every percent (the 300004 concern). When the hold ends, the normal branch writes the real target. The in-window "target met" hold normally still writes that window's target, which is already ≤ SoC. If a lower limit is still inapplied_payload, though, the keep rule leaves that lower value in place. That is harmless: the limit only governs grid charging, and once SoC falls below the target the window is charging again and writes its target (see the review comment on_hold_grid_target).templates/hanchu.yamlnow says Predbat writes all six slots at its first write, zeroing unused ones, and why (User-defined runs every enabled slot). It also says Freeze Export is unavailable. Also updated: the override comments (model lookup) and the stale "every request and response is traced" header line (tracing is opt-in since #5674).docs/inverter-setup.mdhad no section for the native component, and its Hanchu iESS section said there was no native integrationdevModelratings and the diagnostics CLI. The Service API section now points to it. Also corrected_apply_model's docstring: the model reaches the static cache only at the next static refresh.Decisions for review
--live-window freeze-exportrun yet. The same fact (CHG_PWR_LMT doesn't stop PV) is what clears hanchu code review findings #5672 finding 4 (holds don't block solar). If the DC run shows a zero charge rate does stop PV there, this can be relaxed per unit via thegrid_charge_onlyattribute instead.Not in this PR
From #5672: finding 5 (the export floor is applied only once the window is active; needs a behaviour decision) and finding 6 (a restart can write a disabled schedule). upton68 has now confirmed finding 6 on a standalone Docker instance, but the same pattern exists in alphaess/fox/deye/sunsynk, so it belongs in a cross-component fix.
Tests
New tests cover the model ratings (four models, including the fallbacks), unknown and missing
devModel, the default-power deferral, the discoveryinfo.model, the hold grid-charge cap (cap, keep, lower, 20% floor without a clamp warning, restore, in-window unchanged) and theINVERTER_DEFfreeze flag. Each one was run against #5674's head and failed for the reason it targets. Two existing default-warning tests now simulate an answered status poll first, because the warning legitimately waits for one. All six Hanchu suites and--quickpass (4 slow tests skipped), and pre-commit is clean on the changed files.GitNexus impact analysis couldn't resolve the Hanchu symbols: the index predates
hanchu.py. Grep shows every changed method is used only byhanchu.pyand its tests. The same-namedbattery_rate_max/inverter_limitin alphaess/deye/sunsynk belong to their own classes.detect_changesreports LOW risk, withINVERTER_DEFthe only indexed symbol touched.🤖 Generated with Claude Code