Skip to content

fix(deye): correct the TOU slot payload found by the Sunsynk comparison - #4586

Merged
springfall2008 merged 3 commits into
mainfrom
fix/deye-tou-slot-corrections
Aug 19, 2026
Merged

fix(deye): correct the TOU slot payload found by the Sunsynk comparison#4586
springfall2008 merged 3 commits into
mainfrom
fix/deye-tou-slot-corrections

Conversation

@springfall2008

Copy link
Copy Markdown
Owner

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 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 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 on unconditionally, in build_dynamic_payload() rather than derive_control_state() — the solar_sell field 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 #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, 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.power is 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 carried zero — and they cover every interval Predbat is not actively charging or exporting, so the battery was frozen as its default state and the house drew from the grid.
  • The 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 on a model whose device/latest reports 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.

Tests

Six new tests in test_deye_control.py, each written to fail against main first:

Test Covers
zero_length_window an enabled zero-length charge/export window leaves the day in self-use, and the payload agrees with _window_active
solar_sell_always_on solarSellAction=on across idle, charging, charge-set-but-idle and exporting
non_export_uses_ct charge / freeze-charge / hold-charge / idle map to ZERO_EXPORT_TO_CT, export still sells first
self_use_slot_power no slot is written with zero power; self-use carries the rating, the charge window keeps Predbat's rate
freeze_zero_power both freeze states are zero power and still classify as action slots; hold_charge keeps its rate
no_self_use_power_fails_closed no rating and no battery config writes nothing at all, warns once per serial, and the battery rate backs the rating up

MockDeye gains a with_rating() helper, since a payload now needs its serials to have one. Existing tests that encoded the old behaviour are updated. Full quick suite and run_pre_commit pass.

Closes #4560
Closes #4580
Closes #4581

🤖 Generated with Claude Code

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>
Copilot AI lite review requested due to automatic review settings August 19, 2026 07:18

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 solarSellAction on; 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 thread apps/predbat/deye.py
Comment thread apps/predbat/deye.py Outdated
Comment thread apps/predbat/deye.py
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
springfall2008 and others added 2 commits August 19, 2026 08:29
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>
@springfall2008
springfall2008 merged commit e409e61 into main Aug 19, 2026
2 checks passed
@springfall2008
springfall2008 deleted the fix/deye-tou-slot-corrections branch August 19, 2026 07:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants