Skip to content

fix(deye): deliver charge/export windows to the inverter, and stop re-sending unchanged payloads - #4371

Merged
springfall2008 merged 2 commits into
mainfrom
worktree-deye-write-button-dedup
Jul 29, 2026
Merged

fix(deye): deliver charge/export windows to the inverter, and stop re-sending unchanged payloads#4371
springfall2008 merged 2 commits into
mainfrom
worktree-deye-write-button-dedup

Conversation

@springfall2008

@springfall2008 springfall2008 commented Jul 29, 2026

Copy link
Copy Markdown
Owner

Two control bugs found from live logs. The second is the serious one: the charge and export windows have never reached the inverter.

1. Window times went to dummy entities, so no window was ever sent

DeyeCloud declared charge_time_format: "HH:MM". inverter.py replaces charge/discharge start_time and end_time with its own dummy sensor.predbat_<type>_<id>_* entities for any format other than "HH:MM:SS", discarding whatever automatic_config mapped:

if self.inv_charge_time_format != "HH:MM:SS":
    for x in ["charge", "discharge"]:
        for y in ["start", "end"]:
            self.base.args[f"{x}_{y}_time"][id] = self.create_entity(...)

So Predbat wrote the window times to dummy entities while this component kept reading its own selects, which nothing ever wrote. This is visible in the config UI, where the time args point at sensor.predbat_DeyeCloud_0_charge_start_time while every other arg points at a real predbat_deye_<serial>_* entity.

The consequence, from a two-hour live log — every control payload:

times     = ('00:00', '04:00', '08:00', '12:00', '16:00', '20:00')
gridCharge= (False, False, False, False, False, False)
workMode  = ZERO_EXPORT_TO_LOAD   gridAction = off   sellAction = off

Those are exactly TOU_FILLER_TIMESbuild_tou_slots padding with no charge and no export window at all — while Predbat reported "Freeze exporting" for the entire period. Reserve, charge limit and the enable switches were mapped correctly, which is why the component looked healthy.

DEYE was the only inverter in INVERTER_DEF declaring "HH:MM", a value handled nowhere else in inverter.py: it appears in neither is_hm_format nor any of the H M / H:M-H:M / S branches. Its only effect was tripping that dummy-creation test.

Fix: the entities now carry HH:MM:SS to match the declared format, and _to_slot_time() drops the seconds at the single point a schedule time becomes a DEYE slot time (timeUseSettingItems take HH:MM). HH:MM input still resolves, so a stale entity value cannot break the payload. _hm_to_minutes already ignored trailing seconds, so window matching needed no change.

The INVERTER_DEF value is now pinned by test_deyecloud_inverter_def with the reason recorded, because getting it wrong is silent — everything still runs, it just never controls anything.

2. The write button re-sent an unchanged payload every cycle

switch_event called apply_schedule(sn, force=True), and force bypasses the applied-payload change detection. Predbat presses that button on every cycle as its normal apply action, so the bypass fired every time: 40 presses produced 36 byte-identical orders over two hours (every consecutive pair diffed to zero changed keys), plus 4 deferred by the in-flight guard.

The cache was working — _reconcile_control, which is unforced, correctly suppressed 119 writes over the same period. Only the button ignored it.

apply_schedule now defaults to unforced. Nothing loses the ability to re-assert: when the cache must be distrusted (an order unconfirmed after DEYE_ORDER_MAX_POLLS) that path already pops the entry, which makes the next apply write naturally.

Testing

Four new tests. A HH:MM:SS window produces HH:MM slot times, actually yields a grid-charge slot, and explicitly asserts the payload is not all-filler — the exact signature of the live bug. _to_slot_time normalisation including malformed input. Five presses of an unchanged schedule produce one write, with the in-flight guard cleared between presses so suppression is proven to come from change detection. A genuine change still writes.

Both fixes verified against the pre-fix code: reverting the format gives DeyeCloud[charge_time_format] expected HH:MM:SS got HH:MM; restoring force=True gives an unchanged schedule should be written once, got 5 writes.

Full quick suite passes; pre-commit clean.

🤖 Generated with Claude Code

Predbat presses the schedule write button on every cycle as its normal
"apply the schedule" action, and switch_event passed force=True, which
bypasses the applied-payload change detection. The result on a live site
was 40 button presses producing 36 byte-identical control orders over two
hours with nothing actually changing — every consecutive pair of payloads
diffed to zero changed keys. It reads as a repeated freeze-export because
the same freeze-export payload was re-sent every few minutes.

The cache was working: over the same period _reconcile_control, which is
unforced, correctly suppressed 119 writes. Only the button ignored it.

apply_schedule now defaults to unforced and the button calls it that way,
so the applied-payload cache is the single source of truth for whether a
write is needed. Nothing loses the ability to re-assert: when the cache
must be distrusted, because an order stayed unconfirmed for
DEYE_ORDER_MAX_POLLS cycles, that path already pops the entry, which makes
the next apply write naturally.

Each redundant order also cost an API call and risked the 2104004
concurrent-order rejection, so this removes most of the remaining control
traffic.

One existing assertion changed: the write-button test expected a forced
apply, which encoded the bug.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 29, 2026 16:36

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 fixes repeated DEYE control writes caused by the “schedule write” button forcing apply_schedule() even when the computed dynamic_control payload is unchanged. By making the button path unforced, it reuses the existing applied-payload change detection to suppress no-op writes, reducing API traffic and avoiding DEYE concurrent-order rejections.

Changes:

  • Change Deye.apply_schedule() default from force=True to force=False, and update the write-button handler to call it unforced.
  • Update an existing publish test to reflect that the write button should not force applies.
  • Add new control tests to verify repeated button presses don’t resend identical payloads, while genuine schedule changes still trigger a write.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.

File Description
apps/predbat/deye.py Makes schedule applies unforced by default and updates the write-button path to rely on applied-payload change detection.
apps/predbat/tests/test_deye_publish.py Updates expectation to ensure write-button apply is unforced.
apps/predbat/tests/test_deye_control.py Adds regression tests ensuring unchanged schedules don’t generate repeated control POSTs, while changes still do.

DeyeCloud declared charge_time_format "HH:MM". inverter.py replaces
charge/discharge start_time and end_time with its own dummy
sensor.predbat_<type>_<id>_* entities for ANY format other than
"HH:MM:SS", discarding whatever automatic_config mapped:

    if self.inv_charge_time_format != "HH:MM:SS":
        for x in ["charge", "discharge"]:
            for y in ["start", "end"]:
                self.base.args[f"{x}_{y}_time"][id] = self.create_entity(...)

So Predbat wrote the window times to dummy entities while this component
kept reading its own selects, which nothing ever wrote. The windows never
arrived. Every control payload in a two-hour live log carried only the
filler times 00:00/04:00/08:00/12:00/16:00/20:00 with grid charge off in
every slot and workMode ZERO_EXPORT_TO_LOAD — build_tou_slots seeing no
charge and no export window at all — while Predbat reported "Freeze
exporting" throughout. Reserve, charge limit and the enable switches were
mapped correctly, so the component looked like it was working.

DEYE was the only inverter in INVERTER_DEF declaring "HH:MM", a value
handled nowhere else in inverter.py: it appears in neither is_hm_format
nor any of the H M / H:M-H:M / S branches. Its only effect was to trip
that dummy-creation test.

The entities now carry HH:MM:SS to match, and _to_slot_time() drops the
seconds at the single point a schedule time becomes a DEYE slot time,
since timeUseSettingItems take HH:MM. HH:MM input still resolves, so a
stale entity value cannot break the payload. _hm_to_minutes already
ignored trailing seconds, so window matching needed no change.

The INVERTER_DEF assertion is now pinned by test_deyecloud_inverter_def
with the reason recorded, since the wrong value is silent — everything
still runs, it just never controls anything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@springfall2008 springfall2008 changed the title fix(deye): stop the write button re-sending an unchanged control payload fix(deye): deliver charge/export windows to the inverter, and stop re-sending unchanged payloads Jul 29, 2026
@springfall2008
springfall2008 merged commit 3e1de75 into main Jul 29, 2026
2 checks passed
@springfall2008
springfall2008 deleted the worktree-deye-write-button-dedup branch July 29, 2026 16:48
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.

2 participants