Skip to content

Commit 142b016

Browse files
committed
Keep schedule intent separate from inverter mode
1 parent 8c86924 commit 142b016

16 files changed

Lines changed: 863 additions & 758 deletions

README.md

Lines changed: 57 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -230,10 +230,8 @@ What this setup depends on:
230230
AlphaESS app. An unused slot is stored as start equal to end and reads as
231231
`unknown`, which Predbat cannot parse as a window, and no one-sided write can
232232
create both sides at once.
233-
- **Leave Scheduled Discharging on.** Predbat turns the charge enable switch off
234-
while it moves a window, and Home Assistant refuses to author the
235-
both-timers-off state because it is indistinguishable from a self-consumption
236-
mode.
233+
- **Leave Scheduled Discharging on** if you do not want Predbat's temporary
234+
charge-disable step to leave both schedules disabled while it moves a window.
237235
- **Times are rounded to the nearest 15 minutes.** Predbat reads an entity back
238236
after writing it and warns when the value differs, so keep
239237
`inverter_clock_skew_start` and `inverter_clock_skew_end` at 0 and expect that
@@ -251,9 +249,7 @@ What this setup depends on:
251249
- **Set both enable switches once before letting Predbat write.** The periodic
252250
API cannot report whether scheduled charging and discharging are on, so until
253251
Scheduled Charging and Scheduled Discharging have an answer every write is
254-
refused — see *The enable flags are write-only* below. Recording both as off
255-
additionally locks the time-based controls, since that is how the API is told
256-
to run self-consumption.
252+
refused — see *The enable flags are write-only* below.
257253

258254
If you drive the services from Predbat instead, note two things about its
259255
service templates: falsy values are dropped before the call, so `enabled: false`
@@ -275,55 +271,58 @@ was based. If the remote schedule changes before Apply, the write is rejected
275271
as a conflict instead of overwriting the newer schedule. Discard the draft,
276272
allow fresh values to load if necessary, review them, and stage the change
277273
again. Immediate actions and services have no long-lived draft, but still start
278-
from a fresh read.
279-
280-
The binary sensor **Time Based Control Active** reports whether any timed
281-
charge/discharge control is enabled in whichever store governs the system
282-
(either enable flag set). It turns off when the inverter runs a non-timed
283-
mode such as **Self Consumption (Plus)** — live probing showed those modes
284-
flip both enable flags to `0` while keeping the configured windows stored —
285-
and is unavailable while no schedule store is readable.
274+
from a fresh read. If one succeeds while a draft is open, untouched draft fields
275+
are rebased onto the new remote schedule while explicitly staged fields remain
276+
pending. An immediate action uses only its own arguments plus the last confirmed
277+
enable pair; it never consumes staged switch values before Apply.
278+
279+
The diagnostic binary sensor **Recorded Schedule Flags Enabled** reports whether
280+
either flag in Home Assistant's last confirmed pair is on. Its unique ID remains
281+
compatible with the former **Time Based Control Active** entity so an existing
282+
registry entry is renamed in place. It is deliberately not a working-mode
283+
sensor: AlphaESS OpenAPI exposes no authoritative way to tell **Time Based
284+
Control** from **Self Consumption (Plus)**.
286285

287286
### The enable flags are write-only
288287

289-
`gridChargeCycle` and `ctrDisCycle` decide whether scheduled charging and
290-
discharging run. On the periodic store they are **write-only**: probing on
291-
issue #267 showed `getTimeChargeBySn` answers `0` for both however the inverter
292-
is actually set, while `setTimeChargeBySn` acts on what it is sent.
288+
`gridChargeCycle` and `ctrDisCycle` are the scheduled charge/discharge intent
289+
sent with a periodic replacement. They are effectively **write-only**: probing
290+
on issue #267 showed `getTimeChargeBySn` can answer `0` for both regardless of
291+
the inverter's app-selected working mode, while `setTimeChargeBySn` accepts the
292+
explicit pair it is sent.
293293

294-
| sent | effect on the inverter |
294+
| sent | recorded schedule intent |
295295
| --- | --- |
296-
| `1 / 0` | schedule active, scheduled discharging off |
297-
| `1 / 1` | both schedules enabled |
298-
| `0 / 1` | scheduled discharging only |
299-
| `0 / 0` | **switches the inverter to self-consumption** |
296+
| `1 / 0` | scheduled charging on, scheduled discharging off |
297+
| `1 / 1` | both schedules on |
298+
| `0 / 1` | scheduled charging off, scheduled discharging on |
299+
| `0 / 0` | both schedules off |
300+
301+
That pair does not reveal or reliably change the app's overall working mode.
302+
In Self Consumption modes the cloud may still accept and retain schedule edits,
303+
but the inverter may not physically act on them until its mode is changed in the
304+
AlphaESS app.
300305

301306
Two consequences follow, and the integration is built around them.
302307

303-
**The read is never echoed back.** A replacement built from the read would post
304-
`0/0` on every write and quietly switch the inverter out of timed control.
305-
Snapshots therefore drop both flags, and every write sends a value that came
306-
from the user instead.
308+
**The read is never echoed back.** A replacement built from the read could post
309+
an invented `0/0` pair on every write. Snapshots therefore drop both flags, and
310+
every write sends only values that came from the user or the last confirmed pair.
307311

308312
**The switches are the record.** *Scheduled Charging* and *Scheduled
309-
Discharging* are the only place that answer can live, so their published state
310-
is restored across restarts and handed back to the coordinator. Until both have
311-
an answer they read **unknown**, and a schedule write is refused with a message
312-
naming them rather than guessing which working mode you wanted. Editing and
313-
staging still work; only the write needs the answer.
314-
315-
Once both are recorded, a request to set them to `0/0` is a real one — it is how
316-
the API is told to run self-consumption — so it locks the time-based controls:
317-
time entities, cutoff SOC and power numbers, duration buttons, Reset,
318-
Apply/Discard, and any service call carrying a window, cutoff or power. The two
319-
switches stay available so the inverter can be brought back to a timed mode from
320-
Home Assistant, and turning one on there is sent immediately rather than staged,
321-
since Apply is unavailable in that state. Home Assistant still refuses to author
322-
`0/0` by turning off the *last* enabled timer from a service.
323-
324-
In legacy backup mode `gridCharge` and `ctrDis` are ordinary read-write fields
325-
and none of the above applies: they are read from the store, reported as-is, and
326-
a mode change made in the AlphaESS app is picked up on the next poll.
313+
Discharging* are the only place that answer can live, so a separate
314+
last-confirmed attribute is restored across restarts and handed back to the
315+
coordinator. The displayed switch can show a newer local draft without that
316+
draft becoming confirmed after a restart. Until both confirmed values are
317+
known, unrelated immediate writes are refused rather than borrowing a pending
318+
draft or guessing. Stage both switch choices and press Apply once to establish
319+
the pair.
320+
321+
Schedule controls are gated only by whether a complete governing store can be
322+
read and safely replaced. A recorded `0/0` pair does not lock them, because the
323+
same flags and schedule contents cannot tell Home Assistant which working mode
324+
the AlphaESS app selected. In legacy backup mode the flags are read-write fields,
325+
but they still are not treated as an overall working-mode endpoint.
327326

328327
The diagnostic sensor `Periodic Schedule Read` reports:
329328

@@ -344,20 +343,24 @@ erase weekly settings, extra periods, or power values that it cannot see.
344343

345344
**Settings → Devices & Services → AlphaESS → ⋮ → Download diagnostics** answers
346345
most of what a report needs, without anyone having to sign API requests by hand.
347-
Alongside the usual entry and entity data, the `schedule` section carries, per
348-
inverter:
346+
Alongside the usual entry and entity data, the `schedule` section carries one
347+
atomic completed-poll snapshot per inverter:
349348

350349
- `governing_store` and `periodic_read` — which surface the system is on;
351-
- `capabilities`the exact flags behind every unavailable control, so a locked
352-
UI explains itself;
350+
- `capabilities`schedule read/write capability plus the explicit fact that
351+
working mode is unavailable through OpenAPI;
353352
- `enable_intent` / `enable_last_sent` — the write-only pair, which nothing else
354353
can report;
355354
- `periodic_snapshot` and `legacy_snapshot` — what each store actually held,
356355
including an empty period list or a stale window the app no longer shows;
357-
- `draft` — what is staged, which fields are dirty, and whether an Apply is in
358-
flight;
356+
- `draft` — what was staged when that poll completed;
359357
- cooldowns and consecutive poll errors.
360358

359+
`schedule_live` separately reports the current draft, dirty fields, and
360+
in-flight Apply state. Keeping it separate prevents a diagnostics download
361+
during a sequential multi-inverter poll from pretending that live transaction
362+
state and completed-poll data came from the same tick.
363+
361364
The `coordinator.api` section reports the current call spacing and whether the
362365
fast lane is still active, which is what to check when something else is sharing
363366
the same API account. Serials are replaced with `inverter_1`, `inverter_2` and so
@@ -376,7 +379,7 @@ logger:
376379
377380
Every OpenAPI request and **its full response** is then written out, along with
378381
each staged field, each write and the enable pair sent with it, and a line
379-
whenever the schedule surface locks or unlocks and what decided it. That is the
382+
whenever schedule capability changes and what decided it. That is the
380383
same information a hand-signed API request would give you, without signing one.
381384
Very large responses are capped rather than dropped. Requests carry no
382385
credentials — those live in the headers, which are not logged — but the log does
@@ -471,8 +474,7 @@ In practice the integration runs calls at 1-second spacing: live probing of
471474
the current server showed reads accepted at sub-second spacing without
472475
`6053`, and at the documented 10 seconds a two-inverter full poll took
473476
minutes — blocking Home Assistant startup and delaying app-side changes
474-
(such as a work-mode switch flipping `Time Based Control Active`) by up to
475-
five minutes. The documented pace remains the fallback: if the server ever
477+
by up to five minutes. The documented pace remains the fallback: if the server ever
476478
answers `6053`, that call is retried once and the session drops back to
477479
10-second spacing permanently.
478480

custom_components/alphaess/binary_sensor.py

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -104,14 +104,17 @@ async def async_setup_entry(hass, entry, async_add_entities) -> None:
104104

105105

106106
class AlphaScheduleControlBinarySensor(CoordinatorEntity, BinarySensorEntity):
107-
"""Whether AlphaESS time-based charge/discharge control is active.
107+
"""Whether either recorded schedule-enable flag is on.
108108
109-
On when either enable flag is set in whichever schedule store governs
110-
the system (periodic cycles or the legacy backup flags). Off means a
111-
non-timed mode such as Self Consumption Plus is running. Unavailable
112-
while no schedule store is readable.
109+
The OpenAPI has no inverter working-mode endpoint. This diagnostic reports
110+
only the last pair Home Assistant successfully sent/restored (or the legacy
111+
flags), and must never be interpreted as Time Based Control mode.
113112
"""
114113

114+
# Keep the original unique ID so an existing diagnostic entity is renamed
115+
# in place instead of leaving an orphan after this semantic correction.
116+
_LEGACY_UNIQUE_ID_NAME = "Time Based Control Active"
117+
115118
def __init__(self, coordinator, serial, config, description, device_info=None):
116119
super().__init__(coordinator)
117120
self._coordinator = coordinator
@@ -128,7 +131,7 @@ def __init__(self, coordinator, serial, config, description, device_info=None):
128131
@property
129132
def is_on(self) -> bool | None:
130133
value = self._coordinator.data.get(self._serial, {}).get(
131-
AlphaESSNames.TimeBasedControl
134+
AlphaESSNames.ScheduleFlagsEnabled
132135
)
133136
return None if value is None else bool(value)
134137

@@ -139,14 +142,16 @@ def available(self) -> bool:
139142
serial_data = self._coordinator.data.get(self._serial)
140143
if serial_data is None:
141144
return False
142-
return (
143-
self._coordinator.cloud_available
144-
and AlphaESSNames.TimeBasedControl in serial_data
145-
)
145+
return self._coordinator.cloud_available and serial_data.get(
146+
AlphaESSNames.ScheduleFlagsEnabled
147+
) is not None
146148

147149
@property
148150
def unique_id(self):
149-
return f"{self._config.entry_id}_{self._serial} - {self._name}"
151+
return (
152+
f"{self._config.entry_id}_{self._serial} - "
153+
f"{self._LEGACY_UNIQUE_ID_NAME}"
154+
)
150155

151156
@property
152157
def name(self):

custom_components/alphaess/button.py

Lines changed: 11 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,13 @@ async def handle_time_restriction(last_update_dict, update_fn, update_key, movem
456456
@property
457457
def available(self) -> bool:
458458
"""Buttons require cloud API to function."""
459+
if self._key == AlphaESSNames.ButtonDiscardSchedule:
460+
# Discard is entirely local. Keep the escape hatch available even
461+
# if the cloud is down while edits are pending.
462+
return (
463+
self._coordinator.has_schedule_draft(self._serial)
464+
and not self._coordinator.is_schedule_apply_in_progress(self._serial)
465+
)
459466
if not self.coordinator.last_update_success:
460467
return False
461468
serial_data = self._coordinator.data.get(self._serial)
@@ -465,35 +472,23 @@ def available(self) -> bool:
465472
AlphaESSNames.evchargersn
466473
) != self._ev_serial:
467474
return False
468-
if self._key in (
469-
AlphaESSNames.ButtonApplySchedule,
470-
AlphaESSNames.ButtonDiscardSchedule,
471-
):
472-
# In a self-consumption working mode nothing time-based can be
473-
# written, so a stranded draft cannot be applied (or discarded)
474-
# until the app returns the inverter to a time-based mode.
475+
if self._key == AlphaESSNames.ButtonApplySchedule:
475476
return (
476477
self._coordinator.cloud_available
477478
and self._coordinator.has_schedule_draft(self._serial)
478479
and not self._coordinator.is_schedule_apply_in_progress(self._serial)
479-
and self._coordinator.is_time_based_control_active(self._serial)
480-
is not False
481480
)
482481
if self._key == AlphaESSNames.ButtonRechargeConfig:
483482
# The reset clears only the legacy backup stores; on a system the
484483
# periodic schedule governs it could only fail, so report it as
485-
# unavailable there instead. In a self-consumption mode it would
486-
# silently re-enable time-based control, so it locks there too.
484+
# unavailable there instead.
487485
return (
488486
self._coordinator.cloud_available
489487
and self._coordinator.can_reset_schedule(self._serial)
490-
and self._coordinator.is_time_based_control_active(self._serial)
491-
is not False
492488
)
493489
if getattr(self, "_movement_state", None) in ("Charge", "Discharge"):
494-
# Duration buttons need a usable schedule store and active
495-
# time-based control; in a self-consumption mode a press would
496-
# silently re-enable timed control.
490+
# Duration buttons need a usable schedule store. The OpenAPI has
491+
# no authoritative working-mode read with which to gate them.
497492
return (
498493
self._coordinator.cloud_available
499494
and self._coordinator.can_modify_time_controls(self._serial)

0 commit comments

Comments
 (0)