fix(deye): deliver charge/export windows to the inverter, and stop re-sending unchanged payloads - #4371
Merged
Merged
Conversation
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>
Contributor
There was a problem hiding this comment.
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 fromforce=Truetoforce=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>
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.
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
DeyeClouddeclaredcharge_time_format: "HH:MM".inverter.pyreplacescharge/dischargestart_timeandend_timewith its own dummysensor.predbat_<type>_<id>_*entities for any format other than"HH:MM:SS", discarding whateverautomatic_configmapped: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_timewhile every other arg points at a realpredbat_deye_<serial>_*entity.The consequence, from a two-hour live log — every control payload:
Those are exactly
TOU_FILLER_TIMES—build_tou_slotspadding 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_DEFdeclaring"HH:MM", a value handled nowhere else ininverter.py: it appears in neitheris_hm_formatnor any of theH M/H:M-H:M/Sbranches. Its only effect was tripping that dummy-creation test.Fix: the entities now carry
HH:MM:SSto match the declared format, and_to_slot_time()drops the seconds at the single point a schedule time becomes a DEYE slot time (timeUseSettingItemstakeHH:MM).HH:MMinput still resolves, so a stale entity value cannot break the payload._hm_to_minutesalready ignored trailing seconds, so window matching needed no change.The
INVERTER_DEFvalue is now pinned bytest_deyecloud_inverter_defwith 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_eventcalledapply_schedule(sn, force=True), andforcebypasses 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_schedulenow defaults to unforced. Nothing loses the ability to re-assert: when the cache must be distrusted (an order unconfirmed afterDEYE_ORDER_MAX_POLLS) that path already pops the entry, which makes the next apply write naturally.Testing
Four new tests. A
HH:MM:SSwindow producesHH:MMslot 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_timenormalisation 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; restoringforce=Truegivesan unchanged schedule should be written once, got 5 writes.Full quick suite passes;
pre-commitclean.🤖 Generated with Claude Code