Skip to content

Move scheduling onto the periodic API, with staged edits and Apply - #274

Merged
CharlesGillanders merged 5 commits into
CharlesGillanders:mainfrom
Poshy163:fix/periodic-charge-discharge
Aug 21, 2026
Merged

Move scheduling onto the periodic API, with staged edits and Apply#274
CharlesGillanders merged 5 commits into
CharlesGillanders:mainfrom
Poshy163:fix/periodic-charge-discharge

Conversation

@Poshy163

@Poshy163 Poshy163 commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator

Takes on the first half of #267: getTimeChargeBySn/setTimeChargeBySn becomes the store the integration schedules on. It stops short of the second half — the legacy two-slot endpoints are not deleted, they are demoted to a backup for accounts AlphaESS has not granted the timed charge/discharge permission to. Until that permission is handed out on request, it is the only control path those accounts have.

This supersedes the description this PR carried before, which described the first two commits only and one behaviour that has since been reversed (see Nothing is invented below).

One store per system

A definitive 6017 on the periodic read moves that inverter to the legacy backup. From then on polling, entity state, writes and the duration buttons all follow the store that mode names. The two stores are never read together, merged, or mirrored — #269 showed what mixing them costs: values the app never shows, phantom overlaps, and writes the server accepts without acting on.

A system whose periodic read works but whose write is refused fails closed rather than falling back. The app-facing periodic store still governs that inverter, and editing a store it ignores would only mislead.

Two backup-mode caveats, both from live probing: the inverter may not act on the legacy discharge store even though the write is accepted, and per-period power exists only in the periodic store, so the Power entities are unavailable there.

Edits are staged, Apply sends them

Times, cutoff SOCs, per-period power and the enable switches now stage locally. Apply Charge/Discharge Schedule sends the whole draft as one write; Discard Schedule Changes puts the remote values back. Both buttons exist only while a draft does.

That makes a plan change one API write instead of one per field, and means a half-finished edit is never sent. Polling keeps a draft visible instead of resetting an end time while the start is still being edited. A failed or conflicted Apply keeps the draft so it can be retried or discarded.

The duration buttons and the two services stay immediate, and deliberately do not consume a draft.

Safe writes

Every write reads its store fresh inside the transaction, patches only the fields it was asked for, and serialises per inverter. Periods, weekday selections and powers the two-slot entity UI cannot show all survive a write. A draft records the schedule it was based on and is refused as a conflict if the remote schedule moved underneath it.

In periodic mode there is no partial-write case — one store, one POST. Backup mode writes two stores, so a two-sided change there can half-land, and that is reported as a partial write naming what already applied. A timeout after a POST is reported as an explicitly unknown outcome, not as a failure.

Nothing is invented

A period with no valid power or cutoff is reported by name rather than filled in. In particular the poinv fallback added in 9a8d61d is reversed: the inverter's nominal rating is not a verified battery charge rate, and sending it as one is exactly the class of write that looks accepted and does something unintended. Empty period lists are refused before they reach the API, which answers 6001 for them anyway. Invalid remote data is named and blocks the write instead of being clamped.

The overlap guard stays gone. 6008 is reported with the API's own explanation and the periods that were sent.

Self-consumption locks the controls

Self Consumption and SCP are indistinguishable over the OpenAPI: both clear the enable flags and keep the windows stored. Field testing showed that writing an enable flag does not leave those modes, so while one is running every time-based control — times, cutoffs, powers, switches, duration buttons, Reset, Apply/Discard and both services — is unavailable or refused with a message naming the remedy, and the new Time Based Control Active binary sensor says why.

For the same reason Home Assistant will not author the both-flags-off state itself: afterwards it could not tell that state apart from self-consumption. Disabling the last timer belongs in the app. Polling unlocks everything automatically once a timed mode is active again, and a draft stranded by an app-side mode change is kept until then.

Automations

dragon2611 asked in #267 for a way to set the parameters and then apply them. That is what the entities plus button.press already do, so the README now documents it: the entity IDs, the choice between the immediate services and the staged surface, and the rule that an inverter should use one or the other — a service write replaces the remote schedule, so a draft staged before it will fail its next Apply as a conflict.

Predbat gets a worked example, because it already drives inverters that stage settings and then press a button: a custom inverter definition with time_button_press, the entity mapping, and the things that setup depends on — including that Predbat cannot see whether an Apply was accepted, since Home Assistant timestamps a button press before the write is attempted.

Also

  • Periodic Schedule Read diagnostic sensor reports which store governs the system, and stays unknown rather than guessing while no read has succeeded.
  • The services accept optional per-slot power, required when a call creates a new periodic slot, and normalise times to zero-padded quarter hours (the API answers 6001 for 9:00).
  • Reset Charge/Discharge now reports unavailable on periodic systems: a periodic schedule cannot be cleared through the API, so the button could only ever fail there.
  • Duration buttons retarget slot 1 only and leave everything else alone; they refuse a weekly period that does not run today rather than silently rescheduling its other weekdays.
  • All calls use the fast API lane, so an app-side mode change is noticed in about a minute.

Two things from testing on CharlesGillanders#269.

dragon2611 saw the discharge entry appear in the AlphaESS portal and do
nothing, because it had no rate to run at. setTimeChargeBySn accepts a period
with no chargePower and then ignores it. We only sent one when a previous
periodic read had given us a value to carry over, which for a system with no
existing periodic schedule is never. Fall back to the inverter's own rated
power from getEssList.

benbrown249 hit "charge period 13:30-02:45 overlaps discharge" on a system
whose app schedule was empty. That window comes from the legacy store, which
on a migrated system is a separate store the app doesn't show, and because it
wraps midnight it spans thirteen hours and collides with anything. So every
write was skipped.

The overlap guard was written from the documentation, before the library could
report return codes. It can now, so drop the guess and send the schedule: if
the API objects with 6008 we say so, and name the periods we sent. That also
removes a guard that could never have been right, since we don't know whether
the API applies the overlap rule when a cycle is disabled.
Swept the rest of the endpoints for the two shapes behind the CharlesGillanders#269 reports: an
optional field whose absence silently does nothing, and us deciding a call will
fail instead of making it.

chargePower was the only optional field with that behaviour, and it is now
sent, so that one is closed.

The EV control pre-check is the same mistake as the overlap guard, and worse.
It refused to send start/stop unless the cached charger status matched our own
idea of which states allow which command. That status is only as fresh as the
last poll, which is configurable up to an hour, and if the status endpoint had
failed there was no status at all, so every command was blocked indefinitely.
Absorbing API errors on reads made that more likely. The command now always
goes out and we report what the charger says. can_control_ev stays for the
Can Start/Stop Charging sensors, where a hint is all it ever was.

The two services also passed times through untouched. The API wants zero
padded HH:mm on the quarter hour, so "9:00" comes back 6001 and an off-grid
minute is accepted and then unusable. They are normalised now, the same way
the time entities already did it.
@Poshy163
Poshy163 force-pushed the fix/periodic-charge-discharge branch from ba6acd1 to d6d9c1a Compare August 21, 2026 13:49
@Poshy163 Poshy163 changed the title Send a power setpoint, and stop pre-judging overlap Move scheduling onto the periodic API, with staged edits and Apply Aug 21, 2026
@Poshy163
Poshy163 force-pushed the fix/periodic-charge-discharge branch from d6d9c1a to ee0db64 Compare August 21, 2026 13:52
CharlesGillanders#267 asks for the six-period endpoints to become the scheduling API and for the
old two-slot endpoints to be retired. This does the first half and stops short
of the second: getTimeChargeBySn/setTimeChargeBySn is now the store the
integration runs on, and the legacy endpoints stay only as a backup for accounts
AlphaESS has not granted the timed charge/discharge permission to.

A system is in exactly one mode. A definitive 6017 on the periodic read moves
that inverter to the backup, and from then on polling, entity state, writes and
the duration buttons all follow the store that mode names. CharlesGillanders#269 showed what
mixing them costs: values the app never shows, phantom overlaps, and writes the
server accepts without acting on. The two stores are never read together,
merged, or mirrored. A system whose periodic read works but whose write is
refused fails closed instead of falling back, because the periodic store is
still the one governing that inverter.

Entity edits are drafts now. Times, cutoff SOCs, per-period power and the enable
switches stage locally and go out together when Apply Charge/Discharge Schedule
is pressed, so a plan change is one write rather than one per field and a
half-finished edit is never sent. Discard puts the remote values back. Both
buttons exist only while a draft does. The duration buttons and the two services
stay immediate and deliberately do not consume a draft.

Every write reads the store fresh inside the transaction and patches only the
fields it was asked for, so periods, weekdays and powers the entity UI cannot
show survive it. A draft remembers the schedule it was based on and is refused
as a conflict if the remote schedule moved underneath it.

Nothing is invented to fill a gap. A period with no valid power or cutoff is
reported by name instead of being filled in from the inverter's nominal rating,
which is not a battery rate: the fallback added in 9a8d61d is reversed here.
Empty period lists are refused before they reach the API, which answers 6001 for
them anyway.

Self Consumption and SCP are indistinguishable over the OpenAPI, since both
clear the enable flags and keep the windows. Field testing showed that writing
an enable flag does not leave those modes, so while one runs every time-based
control locks, the new Time Based Control Active sensor says why, and Home
Assistant refuses to author the both-flags-off state itself, having no way to
tell it apart from self-consumption afterwards. Polling unlocks everything once
the app returns to a timed mode.

Periodic Schedule Read reports which store governs the system, and stays unknown
rather than guessing while no read has succeeded.
The README described a single charge/discharge surface that no longer exists.
It now says which store governs a system and why, what a draft is, what Apply
and Discard do, and which limits are the API's rather than ours: both period
lists are required in a replacement, a periodic schedule cannot be cleared
through the API, per-period power exists only in the periodic store, and 6008
is only ever reported as a generic failure.

There is also a section on driving schedules from an automation, prompted by
dragon2611 asking in CharlesGillanders#267 for a way to set the parameters and then apply them.
That already exists — the entities stage, button.press applies — so it is
written down, with the entity IDs and the two rules that matter: pick one
surface per inverter, and expect the immediate services to rewrite both slots
of their side.

Predbat gets its own worked example, since it already knows how to drive an
inverter that stages settings and then presses a button: a custom inverter
definition with time_button_press, the entity mapping, and the five things that
setup depends on, including that Predbat cannot see whether an Apply was
accepted.

A staged time also stops disappearing when it briefly matches the other half of
its slot. Equal start and end is how AlphaESS stores an unused slot, so that
pair reads as unset, but an automation writing one half and reading it back
before writing the other would see its own edit blanked.
@Poshy163
Poshy163 force-pushed the fix/periodic-charge-discharge branch from ee0db64 to 3e0fd17 Compare August 21, 2026 13:55
The 100% gate had never run over this work, because none of it was committed.
It was at 96.87%, and everything missing was an error path: the legacy backup
write in all of its outcomes (a side that cannot be read, one missing fields,
one that already matches, a store that moved under a draft, a half-landed
two-sided write, and the same again when a lost response means the outcome is
unknown rather than failed), the Reset button's rate limit and its three
failure endings, and the guards that only fire in narrow states — staging a
power in backup mode, refusing to disable the last enabled timer from a
service, a quick button with no cutoff to copy, an alt-mode poll caching the
backup snapshot, and cancellation passing through a write untouched.

Those are exactly the paths worth pinning: each one exists because a field
report showed what happens without it.
@Poshy163
Poshy163 marked this pull request as ready for review August 21, 2026 14:10
@Poshy163

Copy link
Copy Markdown
Collaborator Author

@CharlesGillanders would be good if you can approve this, going to push it as a beta 2 with reworks most of how the integration works for the new server interface/back-end

@CharlesGillanders
CharlesGillanders merged commit 665ccdb into CharlesGillanders:main Aug 21, 2026
3 checks passed
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