fix(deye): correct the TOU slot payload found by the Sunsynk comparison - #4586
Merged
Conversation
Four corrections to the control payload, all surfaced by diffing this component against the Sunsynk port built from it and against a real inverter's live settings. **A zero-length window became a multi-hour full-power grid charge (#4560).** build_tou_slots() added an action segment at the window start and then called setdefault() for the return-to-self-use segment at the window end - a no-op when the two times are equal, so the action slot ran on until the next boundary, up to four hours. The schedule default is start = end = "00:00:00" and the control entities are written one at a time, so an enable event landing before the times do produced exactly this: a grid charge to the window's target at full power while _window_active() - which has always guarded start == end - reported the window inactive. The same guard now runs in build_tou_slots(), on the normalised times so "02:00:00" against "02:00" is caught too. **Solar Sell was switched off outside export windows (#4580).** solarSellAction governs whether surplus PV reaches the grid at all, not what the battery does, and it was derived from the window active right now - on inside an export window, off everywhere else. A user with an ordinary overnight charge window therefore had spare solar curtailed all through the following day, invisibly: nothing in Predbat's model represents PV curtailment, so the lost export revenue never showed up. It is now written on unconditionally. Fixed in build_dynamic_payload() rather than derive_control_state(), whose solar_sell field also classifies action-vs-self-use slots. **Non-export states now use ZERO_EXPORT_TO_CT (#4580).** The CT variant measures at the grid CT, so the battery serves the whole house without exporting; ZERO_EXPORT_TO_LOAD measures at the inverter's own output and on a CT-clamp install would stop the battery serving anything not wired to it, with the shortfall drawn from the grid. Confirmed on Sunsynk hardware, which sits behind the same registers. **Zero slot power is a freeze, and it was on the wrong slots (#4581).** TimeUseSettingItem.power is the slot's charge/export power and zero is how the inverter is told to hold. Self-use slots - every interval Predbat is not actively charging or exporting - carried zero, freezing the battery as its default state and putting the house on the grid, while the two freeze states carried the requested power, the opposite of a freeze. Self-use slots now carry the inverter's rated power, falling back to the battery's own maximum charge rate for a model whose device/latest has no RatedPower, and failing closed if neither is known: no payload is built and nothing is written, because a zero there cannot be justified. That warning is throttled to once per serial, since _reconcile_control() rebuilds the payload every cycle. freeze_charge and freeze_export now carry zero and still classify as action slots, keeping grid_charge and solar_sell respectively. hold_charge is deliberately unchanged: the battery is already at target, so the slot keeps Predbat's chosen charge rate and only the grid-charge flag differs. Zero there would mean freeze, which is a different state. Closes #4560, #4580, #4581 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
This PR updates the DEYE cloud inverter integration’s dynamic-control payload generation to prevent unintended charging/export behaviour and to align slot semantics (especially slot power and solar sell) with observed inverter behaviour, backed by new regression tests.
Changes:
- Fix TOU slot construction to ignore enabled zero-length windows and prevent unterminated action slots.
- Correct control payload semantics: always leave
solarSellActionon; write non-zero power for self-use slots; use zero power for freeze states; and fail closed (write nothing) if a safe self-use power cannot be determined. - Extend/adjust the DEYE test harness and add targeted regression tests covering these behaviours.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| docs/superpowers/specs/2026-07-19-deye-cloud-inverter-integration-design.md | Updates the DEYE intent→payload mapping table and documents the cross-cutting payload rules (zero-power freeze, solar sell always on, CT mode for non-export). |
| docs/components.md | Updates DEYE component documentation to reflect the new work mode selection, solar sell behaviour, and self-use slot power semantics. |
| apps/predbat/deye.py | Implements the payload fixes (zero-length window guard, self-use power derivation/fail-closed, freeze power semantics, solar sell always on, non-export work mode selection). |
| apps/predbat/tests/test_deye_publish.py | Updates publish-related tests to provide an inverter rating where payload building now depends on it. |
| apps/predbat/tests/test_deye_control.py | Adds six new regression tests for the corrected DEYE control semantics and updates existing tests for the new expectations. |
| apps/predbat/tests/test_deye_api.py | Adds MOCK_RATED_POWER and a MockDeye.with_rating() helper to support payload-building tests. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Comment on lines
541
to
+545
| charge_soc = int(charge.get("soc", 0)) | ||
| if charge_soc > current_soc and charge_soc > reserve: | ||
| return {"behaviour": "charge", "work_mode": DEYE_WORKMODE["zero_export_load"], "grid_charge": True, "solar_sell": False, "slot_soc": charge_soc, "power": int(charge.get("power", 0))} | ||
| return {"behaviour": "charge", "work_mode": DEYE_WORKMODE["zero_export_ct"], "grid_charge": True, "solar_sell": False, "slot_soc": charge_soc, "power": int(charge.get("power", 0))} | ||
| if charge_soc == reserve: | ||
| return {"behaviour": "freeze_charge", "work_mode": DEYE_WORKMODE["zero_export_load"], "grid_charge": True, "solar_sell": False, "slot_soc": reserve, "power": int(charge.get("power", 0))} | ||
| return {"behaviour": "hold_charge", "work_mode": DEYE_WORKMODE["zero_export_load"], "grid_charge": False, "solar_sell": False, "slot_soc": reserve, "power": int(charge.get("power", 0))} | ||
| # Zero power IS the freeze: the slot is enabled for grid charge but given no |
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes three DEYE control-payload bugs, all surfaced by diffing this component against the Sunsynk port built from it and against a real inverter's live settings. They are one PR because they are one payload and one comparison.
A zero-length window became a multi-hour full-power grid charge — #4560
build_tou_slots()added an action segment at the window start, then calledsetdefault()for the return-to-self-use segment at the window end — a no-op when the two times are equal, so the action slot ran on until the next boundary, up to four hours.The schedule default is
start = end = "00:00:00"and the control entities are written one at a time, so anenableevent landing before the times do produced exactly this: a grid charge to the window's target at full power, while_window_active()— which has always guardedstart == end— reported the window inactive. The same guard now runs inbuild_tou_slots(), on the normalised times so"02:00:00"against"02:00"is caught too.Solar Sell was switched off outside export windows — #4580
solarSellActiongoverns whether surplus PV reaches the grid at all, not what the battery does, and it was derived from the window active right now: on inside an export window, off everywhere else. A user with an ordinary overnight charge window had spare solar curtailed all through the following day — invisibly, because nothing in Predbat's model represents PV curtailment, so the lost export revenue never showed up anywhere.It is now written
onunconditionally, inbuild_dynamic_payload()rather thanderive_control_state()— thesolar_sellfield there also classifies action-vs-self-use slots, so forcing it at the derivation would turn every slot into an action slot.Non-export states now use
ZERO_EXPORT_TO_CT— the open question on #4580The CT variant measures at the grid CT, so the battery serves the whole house without exporting.
ZERO_EXPORT_TO_LOADmeasures at the inverter's own output, so on a CT-clamp install it would stop the battery serving anything not wired to it and the shortfall would come from the grid.This is the part of #4580 raised as a question rather than a claim: it is confirmed on Sunsynk hardware, which sits behind the same registers, not against a DEYE inverter. Worth a second opinion from someone with DEYE hardware.
Zero slot power is a freeze, and it was on the wrong slots — #4581
TimeUseSettingItem.poweris the slot's charge/export power and zero is how the inverter is told to hold. It was the wrong way round in both places:Self-use slots now carry the inverter's rated power, falling back to the battery's own maximum charge rate on a model whose
device/latestreports noRatedPower, and failing closed if neither is known: no payload is built and nothing is written, because a zero there cannot be justified. That warning is throttled to once per serial, since_reconcile_control()rebuilds the payload every cycle.freeze_chargeandfreeze_exportnow carry zero and still classify as action slots, keepinggrid_chargeandsolar_sellrespectively.hold_chargeis deliberately unchanged: the battery is already at target, so the slot keeps Predbat's chosen charge rate and only the grid-charge flag differs. Zero there would mean freeze, which is a different state.Tests
Six new tests in
test_deye_control.py, each written to fail againstmainfirst:zero_length_window_window_activesolar_sell_always_onsolarSellAction=onacross idle, charging, charge-set-but-idle and exportingnon_export_uses_ctZERO_EXPORT_TO_CT, export still sells firstself_use_slot_powerfreeze_zero_powerhold_chargekeeps its rateno_self_use_power_fails_closedMockDeyegains awith_rating()helper, since a payload now needs its serials to have one. Existing tests that encoded the old behaviour are updated. Full quick suite andrun_pre_commitpass.Closes #4560
Closes #4580
Closes #4581
🤖 Generated with Claude Code