Skip to content

fix(deye,sunsynk): share the TOU builder, pad correctly and split at midnight - #5159

Merged
springfall2008 merged 4 commits into
mainfrom
fix/deye-tou-filler-truncates-window-5156
Sep 19, 2026
Merged

springfall2008 merged 4 commits into
mainfrom
fix/deye-tou-filler-truncates-window-5156

Conversation

@springfall2008

@springfall2008 springfall2008 commented Sep 19, 2026 •

Copy link
Copy Markdown
Owner

This is an automated draft PR generated from issue #5156 — a maintainer should review it before merging.

Fixes #5156

Summary

Two defects in the 6-slot Time-of-Use programme, both present identically in deye.py and sunsynk.py.

Padding placed at fixed clock hours (the reported bug). The programme was padded out from a fixed list — 04:00, 08:00, 12:00 — with each padding slot carrying the idle baseline. Every slot is always in force, so a padding slot landing inside a window is a boundary the inverter obeys: it silently ended the window there while Predbat's plan still showed it running. Any window strictly crossing one of those hours was affected, including the commonest shape of all, an overnight charge crossing 04:00.

Padding now follows the last real slot at 5-minute steps and carries that slot's own state, so it is inert wherever it lands — the state does not change at its boundary. When the last slot is too close to midnight to fit (a window ending 23:50), it falls back to the widest gap, which is always wide enough: with at most 5 slots the widest gap is at least 1440/5 minutes against the 25 minutes five padding slots need.

Wrapping windows were never split. No slot may span midnight, and the 00:00 slot was seeded idle unconditionally, so a 22:00–02:00 charge emitted boundaries at 22:00 and 02:00 and simply dropped its post-midnight half. It is now written as two slots — 22:00–00:00 and 00:00–02:00 — and charges unbroken across midnight. Note the failure mode was not the inverter rejecting or resetting the programme: slot 1 at 00:00 always terminated slot 6, so the charge was just silently lost.

Sharing. These were two copies of the same builder, which is why the same bug existed twice. The programme logic now lives in TouScheduleMixin (tou_schedule.py) and each component keeps only its own slot encoding — DEYE a JSON list, Sunsynk positional sellTime1..6 fields. Net −321 lines across the two components. TOU_FILLER_TIMES is gone from both const modules; the CONFIRMED Sunsynk timer documentation that lived beside it moved into the shared module, where it now documents the rules the builder implements.

The one behavioural difference between the two copies was resolved in favour of Sunsynk's: _window_active normalises via _to_slot_time before comparing, which is equivalent on valid input and safer on malformed input.

Testing

coverage/run_pre_commit passes — all hooks, full quick suite. All 15 DEYE and Sunsynk test modules pass.

New test_tou_schedule.py (registered in TEST_REGISTRY) tests the shared builder directly against a minimal fake component, so it holds whatever either vendor does with the slots: programme shape across 7 schedule shapes, minute-by-minute window continuity, the midnight split, padding placement and its fallback, the idle 24-hour case, and zero-length window rejection. The vendor-level tests in test_deye_control.py cover the same properties through the DEYE encoding.

Fails without the fix, passes with it. A pure refactor cannot be proved by stashing (the imports break), so the red run reverts the behaviour inside the mixin — fixed-hour padding, no midnight split — while keeping the structure:

ERROR: reported day 2 09:45-15:30 loses 210 minutes from 12:00: ['00:00', '04:00', '08:00', '09:45', '12:00', '15:30']
ERROR: reported day 1 11:15-15:30 loses 210 minutes from 12:00: ['00:00', '04:00', '08:00', '11:15', '12:00', '15:30']
ERROR: overnight crossing 04:00 00:30-07:30 loses 210 minutes from 04:00: ['00:00', '00:30', '04:00', '07:30', '08:00', '12:00']
ERROR: export crossing 12:00 11:00-13:00 loses 60 minutes from 12:00: ['00:00', '04:00', '08:00', '11:00', '12:00', '13:00']
ERROR: wrapping charge 22:00-02:00 loses 120 minutes from 00:00
ERROR: wrapping charge 23:30-05:30 loses 330 minutes from 00:00
ERROR: split 22:00-02:00 expected [('00:00', '02:00'), ('22:00', '00:00')] got [('22:00', '02:00')]
ERROR: expected padding at ['05:05', '05:10', '05:15'], got ['05:00', '08:00', '12:00']

The day-2 line reproduces the reporter's captured timeUseSettingItems payload exactly.

The resulting programmes:

charge 09:45-15:30 (reported)          charge 23:30-05:30 + export 16:00-19:00
  00:00  self-use                        00:00  CHARGE     <- post-midnight half
  09:45  CHARGE                          05:30  self-use
  15:30  self-use                        16:00  EXPORT
  15:35  self-use   <- padding           19:00  self-use
  15:40  self-use   <- padding           23:30  CHARGE     <- pre-midnight half
  15:45  self-use   <- padding           23:35  CHARGE     <- padding

CONFIRMED ON HARDWARE (Sunsynk 2211093089, 2026-09-19) with the new sunsynk.py --tou-test, which writes a padded programme, reads it back and restores the original:

Current programme: ['00:00', '04:00', '08:00', '12:00', '16:00', '20:00']
Writing charge 19:30-22:30 (now 15:30, deliberately outside the window)
VERIFIED: the inverter stored ['00:00', '19:30', '22:30', '22:35', '22:40', '22:45']
Restored: ['00:00', '04:00', '08:00', '12:00', '16:00', '20:00']

The padding slots at 22:35/22:40/22:45 carry self-use — the state they follow — so the window runs unbroken to 22:30 and the programme is unchanged by their presence. The inverter that ran this was itself sitting on 00:00/04:00/08:00/12:00/16:00/20:00, the factory default and exactly the fixed-hour list the old padding drew from.

The DEYE side remains mock-verified only; it is the same builder behind a different cloud.

impact() before editing: build_tou_slots and _active_state are both LOW on each side, contained within their own component, no execution flows affected. detect_changes({scope: "compare", base_ref: "main"}) after: low, 6 changed symbols, 0 affected, no affected processes.

Notes

  • Padding slots are real 5-minute intervals, not zero-length ones. Two slots sharing a start time is not a chronological programme, and the firmware's response to it is not consistent enough to rely on. Five minutes is below Predbat's own planning resolution, so a padding slot can never split a window it was not already inside.
  • The degenerate 24-hour case needs no special handling. A schedule holding one state all day yields a single boundary at 00:00, and the padding simply follows it at 00:05–00:25 carrying the same state, so the day is never artificially split. Covered by test_idle_schedule_holds_one_state_all_day.
  • voltN/currentN in the Sunsynk settings object alias the TOU registers. The live round trip revealed that volt1-volt4 are sellTime3-sellTime6 as minutes/50, volt5-volt10 are sellTime1Pac-sellTime6Pac/10, and volt11, volt12, current1-current4 are sellTime1Volt-sellTime6Voltx10 — one contiguous 16-value window, matching 16/16 in both the before and after reads. That is independent proof the write reached the real registers rather than a cloud-side echo, and it means SUNSYNK_SYSTEM_MODE_FIELDS excluding those keys is load-bearing: anything that wrote them would corrupt the charge programme. Recorded in tools/debug-journal.md; no code change needed, since Predbat already never sends them.
  • An earlier revision of this PR fixed only the symptom — it made padding inside a window inherit that window's state rather than not placing it there. That produced a correct programme for a simple window but an incoherent charge/gap/charge one for a window crossing midnight, and it has been replaced by the construction above.

DEYE's 6-slot TOU programme is padded out with fillers at fixed clock hours,
and each filler carried the idle baseline whatever time it landed on. DEYE
reads a slot as running until the next slot's start, so a filler inside a
window silently ended it there while Predbat's plan still showed it running.

Fillers now derive their state at their own time, so one inside a window
carries that window's action and one outside it still derives the baseline.

Fixes #5156

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@springfall2008 springfall2008 self-assigned this Sep 19, 2026
…midnight

The 6-slot TOU programme was padded out at fixed clock hours (04:00, 08:00,
12:00) carrying the idle baseline. Every slot is always in force, so padding
that landed inside a window silently ended it there while Predbat's plan still
showed it running - any window crossing one of those hours, including an
overnight charge crossing 04:00.

Padding now follows the last real slot at 5-minute steps carrying that slot's
own state, so it is inert wherever it lands. It falls back to the widest gap
when the day has no room left.

Separately, no slot may span midnight, and a wrapping window was never split -
its post-midnight half was dropped because the 00:00 slot was always seeded
idle. A wrapping window is now written as a pre-midnight and a post-midnight
slot, so it charges unbroken across midnight.

Both defects existed identically in deye.py and sunsynk.py, which carried
copies of the same builder. The programme logic now lives in TouScheduleMixin
and each component keeps only its own slot encoding.

Fixes #5156

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@springfall2008 springfall2008 changed the title fix(deye): stop padding slots truncating a charge or export window fix(deye,sunsynk): share the TOU builder, pad correctly and split at midnight Sep 19, 2026
@springfall2008
springfall2008 marked this pull request as ready for review September 19, 2026 14:03
Copilot AI lite review requested due to automatic review settings September 19, 2026 14:03

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.

Copilot review overview

🔵 Needs a closer look

Overlapping charge and export windows can produce an incorrect idle interval in the generated schedule.

Review effort: Lite
Findings: None

What changed in this PR

Refactors DEYE/Sunsynk TOU scheduling into a shared builder, fixing padding boundaries and midnight-spanning windows.

Changes:

  • Adds shared TOU construction with state-preserving padding and midnight splitting.
  • Updates both integrations to use vendor-specific encoders.
  • Adds regression tests and removes obsolete filler constants.
  • The shared builder still needs correction for overlapping charge and export windows.
File Summary
apps/​predbat/​unit_test.py Registers the shared TOU test suite.
apps/​predbat/​tou_schedule.py Implements shared TOU construction; overlapping windows have an unresolved correctness issue.
apps/​predbat/​tests/​test_tou_schedule.py Adds shared builder regression coverage.
apps/​predbat/​tests/​test_sunsynk_const.py Updates tests for removed filler constants.
apps/​predbat/​tests/​test_deye_control.py Adds DEYE regression coverage.
apps/​predbat/​sunsynk.py Uses the shared builder with Sunsynk encoding.
apps/​predbat/​sunsynk_const.py Removes obsolete filler constants.
apps/​predbat/​deye.py Uses the shared builder with DEYE encoding.
apps/​predbat/​deye_const.py Removes obsolete filler constants.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

CI and others added 2 commits September 19, 2026 15:31
The mocked tests can say what build_tou_slots produces; nothing said the
inverter stores it. --tou-test writes a padded programme to a real inverter,
reads it back to check every slot time and flag landed, then restores the
original.

Safety: the original System Mode group is written to a JSON file before
anything is sent, the restore runs from a finally and is itself verified by
reading back, and the test window is placed hours from the current time so the
inverter never acts on the programme written here.

--apps-yaml reads credentials from a real apps.yaml, which is also the only way
this CLI can drive an oauth-token account - --username/--password cannot
express one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ters

Found while confirming the GH#5156 fix on real hardware. The settings object
exposes the TOU register block a second time under unrelated names, so keys
that read like independent settings are the same memory at a different scale.

Useful twice over: it independently proves a TOU write reached the real
registers rather than a cloud-side echo, and it makes SUNSYNK_SYSTEM_MODE_FIELDS
excluding those keys load-bearing rather than incidental.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@springfall2008
springfall2008 merged commit d3ca077 into main Sep 19, 2026
2 checks passed
@springfall2008
springfall2008 deleted the fix/deye-tou-filler-truncates-window-5156 branch September 19, 2026 14:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

DeyeCloud: stale TOU boundary slot silently truncates charge windows (reproduced twice, exact mechanism identified)

2 participants