Skip to content

Commit 9e651a5

Browse files
committed
Improve AlphaESS schedule readback and clarify write pacing
1 parent 512f8d6 commit 9e651a5

7 files changed

Lines changed: 123 additions & 9 deletions

File tree

‎apps/predbat/alphaess.py‎

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@
8080
ALPHAESS_HOLD_SOC,
8181
ALPHAESS_HOLD_POWER,
8282
ALPHAESS_SETTLE_POLLS,
83+
ALPHAESS_CONFIG_SETTLE_SECONDS,
8384
ALPHAESS_WRITE_SETTLE_SECONDS,
8485
ALPHAESS_WRITE_BURST_MAX,
8586
ALPHAESS_STORAGE_MODULE,
@@ -89,6 +90,7 @@
8990
ALPHAESS_CACHE_CONTROL,
9091
ALPHAESS_TTL_STATIC,
9192
ALPHAESS_TTL_CONFIG,
93+
ALPHAESS_TTL_CONTROL_CONFIG,
9294
ALPHAESS_TTL_ENERGY,
9395
)
9496

@@ -1435,7 +1437,7 @@ def _owned_fields(direction):
14351437
if direction == "charge":
14361438
return ("gridCharge", "timeChaf1", "timeChae1", "timeChaf2", "timeChae2", "batHighCap")
14371439
if direction == "periodic":
1438-
return ("gridChargeCycle", "ctrDisCycle", "chargeTimeList", "dischargeTimeList")
1440+
return ("executeCycleType", "gridChargeCycle", "ctrDisCycle", "chargeTimeList", "dischargeTimeList")
14391441
return ("ctrDis", "timeDisf1", "timeDise1", "timeDisf2", "timeDise2", "batUseCap")
14401442

14411443
# chargeTimeList/dischargeTimeList entries carry fields Predbat never writes (the API
@@ -1503,10 +1505,12 @@ def _field_differs(self, direction, field, observed_value, applied_value):
15031505
def note_external_change(self, sn, direction, observed):
15041506
"""Report when a Predbat-owned field no longer matches what Predbat last wrote.
15051507
1506-
Suppressed for the first ALPHAESS_SETTLE_POLLS reads after a write, because
1508+
Suppressed for ALPHAESS_CONFIG_SETTLE_SECONDS after a write, because
15071509
settings only reach the inverter on its next cloud poll - typically one to five
15081510
minutes later - so an immediate read-back legitimately shows the old values.
1509-
Without that window every single write would raise a false alarm.
1511+
Count-based grace is only a fallback for old cached intent with no write timestamp.
1512+
Config reads can be thirty minutes apart, so counting those polls for a timestamped
1513+
write would hide a real overwrite for up to two hours, including after a restart.
15101514
15111515
Does nothing before Predbat has written anything for this serial and direction:
15121516
with no recorded intent there is nothing to have been overwritten, and what the
@@ -1518,7 +1522,11 @@ def note_external_change(self, sn, direction, observed):
15181522
key = (sn, direction)
15191523
polls = self.settle_count.get(key, 0) + 1
15201524
self.settle_count[key] = polls
1521-
if polls <= ALPHAESS_SETTLE_POLLS:
1525+
last_write = self.last_write_time.get(key)
1526+
if last_write is not None:
1527+
if (time.time() - last_write) < ALPHAESS_CONFIG_SETTLE_SECONDS:
1528+
return
1529+
elif polls <= ALPHAESS_SETTLE_POLLS:
15221530
return
15231531
differing = [field for field in self._owned_fields(direction) if field in observed and self._field_differs(direction, field, observed.get(field), applied.get(field))]
15241532
if not differing:
@@ -2145,7 +2153,10 @@ async def run(self, seconds, first):
21452153
if not self.device_list:
21462154
self.log("Warn: AlphaESS found no battery systems on this account")
21472155
return False
2148-
if self.tier_expired("config", ALPHAESS_TTL_CONFIG):
2156+
config_ttl = ALPHAESS_TTL_CONFIG
2157+
if self.control_active.intersection(self.device_list) and self.control_enable and not self._is_read_only():
2158+
config_ttl = ALPHAESS_TTL_CONTROL_CONFIG
2159+
if self.tier_expired("config", config_ttl):
21492160
await self.refresh_config()
21502161
live_ok = True
21512162
if self.tier_expired("power", self.power_tier_ttl()):

‎apps/predbat/alphaess_const.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@
7474
# Tier TTLs in minutes.
7575
ALPHAESS_TTL_STATIC = 8 * 60
7676
ALPHAESS_TTL_CONFIG = 30
77+
# Check the real schedule more often while Predbat is actively controlling a system.
78+
ALPHAESS_TTL_CONTROL_CONFIG = 5
7779
ALPHAESS_TTL_POWER = 1
7880
ALPHAESS_TTL_ENERGY = 5
7981
# Power tier interval once a serial has been demoted to the history path - matches the
@@ -92,6 +94,8 @@
9294
# Polls a written payload is considered pending for before an unexpected read-back counts
9395
# as external interference (the phone app, or another Predbat instance).
9496
ALPHAESS_SETTLE_POLLS = 3
97+
# Inverter cloud propagation takes minutes, not three 30-minute config-tier polls.
98+
ALPHAESS_CONFIG_SETTLE_SECONDS = 5 * 60
9599

96100
# How long after a SUCCESSFUL write a further difference still counts as the rest of the
97101
# SAME schedule update, and is therefore let through alphaess_min_write_interval.

‎apps/predbat/tests/test_alphaess_control.py‎

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2022,10 +2022,105 @@ def test_alphaess_shutdown_force_mode_is_accepted():
20222022
assert client.shutdown_mode == "self_consumption_force"
20232023

20242024

2025+
def test_alphaess_readback_grace_expires_by_time_on_the_first_config_poll():
2026+
"""A first read half an hour after a write must detect a disabled schedule immediately."""
2027+
for direction in ("charge", "discharge", "periodic"):
2028+
client = _writable()
2029+
field = {"charge": "gridCharge", "discharge": "ctrDis", "periodic": "gridChargeCycle"}[direction]
2030+
client.applied_payload["AL70"] = {direction: {field: 1}}
2031+
client.last_write_time[("AL70", direction)] = 1000.0
2032+
with patch("alphaess.time.time", return_value=2800.0):
2033+
client.note_external_change("AL70", direction, {field: 0})
2034+
assert direction not in client.applied_payload["AL70"], "A stale schedule was still cached as applied after 30 minutes"
2035+
assert any("no longer match" in message for message in client.log_messages)
2036+
2037+
2038+
def test_alphaess_readback_grace_protects_the_full_five_minutes():
2039+
"""Frequent early reads cannot spend the settling grace before the inverter catches up."""
2040+
client = _writable()
2041+
client.applied_payload["AL70"] = {"charge": {"gridCharge": 1}}
2042+
client.last_write_time[("AL70", "charge")] = 1000.0
2043+
for elapsed in (1, 60, 120, 180, 299):
2044+
with patch("alphaess.time.time", return_value=1000.0 + elapsed):
2045+
client.note_external_change("AL70", "charge", {"gridCharge": 0})
2046+
assert "charge" in client.applied_payload["AL70"], "A pending write was treated as interference before five minutes"
2047+
with patch("alphaess.time.time", return_value=1300.0):
2048+
client.note_external_change("AL70", "charge", {"gridCharge": 0})
2049+
assert "charge" not in client.applied_payload["AL70"]
2050+
2051+
2052+
def test_alphaess_periodic_cycle_type_change_is_detected():
2053+
"""Switching a daily schedule to weekly changes its meaning even when its times match."""
2054+
client = _writable()
2055+
payload = client.build_periodic_payload("AL70", _schedule())
2056+
client.applied_payload["AL70"] = {"periodic": payload}
2057+
client.last_write_time[("AL70", "periodic")] = 1000.0
2058+
with patch("alphaess.time.time", return_value=2800.0):
2059+
client.note_external_change("AL70", "periodic", dict(payload, executeCycleType=1))
2060+
assert "periodic" not in client.applied_payload["AL70"], "A daily schedule changed to weekly without being detected"
2061+
2062+
2063+
def test_alphaess_control_readback_uses_five_minutes_only_while_driving():
2064+
"""Active control checks settings sooner; monitoring and read-only retain the normal TTL."""
2065+
for active, enabled, read_only, interval in ((True, True, False, 300), (False, True, False, 1800), (True, False, False, 1800), (True, True, True, 1800)):
2066+
client = _client()
2067+
client.control_active = {"AL70"} if active else set()
2068+
client.control_enable = enabled
2069+
client.state["switch.predbat_set_read_only"] = "on" if read_only else "off"
2070+
client._tier_refreshed = {tier: 1000.0 for tier in ("static", "config", "power", "energy")}
2071+
with (
2072+
patch.object(client, "refresh_config", new_callable=AsyncMock, return_value=True) as refresh,
2073+
patch.object(client, "refresh_power", new_callable=AsyncMock, return_value=True),
2074+
patch.object(client, "refresh_energy", new_callable=AsyncMock, return_value=True),
2075+
patch.object(client, "_reconcile_control", new_callable=AsyncMock),
2076+
patch.object(client, "refresh_discovery"),
2077+
):
2078+
with patch("alphaess.time.time", return_value=1000.0 + interval - 1):
2079+
assert run_async_local(client.run(0, False))
2080+
assert refresh.await_count == 0, "Config read ran before its TTL expired"
2081+
with patch("alphaess.time.time", return_value=1000.0 + interval):
2082+
assert run_async_local(client.run(0, False))
2083+
assert refresh.await_count == 1, "Config read did not run when its TTL expired"
2084+
2085+
2086+
def test_alphaess_reverted_schedule_after_restart_is_reapplied_after_startup_delay():
2087+
"""An old persisted write cannot hide a reversion, and recovery still obeys startup delay."""
2088+
from tests.test_alphaess_storage import FakeStorage, StoredAlphaESS
2089+
2090+
store = FakeStorage()
2091+
client = StoredAlphaESS(store=store)
2092+
client._periodic_ok["AL70"] = True
2093+
client.control_active.add("AL70")
2094+
client.local_schedule["AL70"] = _schedule(charge={"enable": True, "soc": 90, "power": 3000, "start": "01:00:00", "end": "05:00:00"})
2095+
payload = client.build_periodic_payload("AL70", client.local_schedule["AL70"])
2096+
client.applied_payload["AL70"] = {"periodic": payload}
2097+
client.last_write_time[("AL70", "periodic")] = 1000.0
2098+
run_async_local(client.save_config())
2099+
run_async_local(client.save_control())
2100+
2101+
restored = StoredAlphaESS(store=store, startup_write_delay=300)
2102+
run_async_local(restored.restore_state())
2103+
restored._post = AsyncMock(return_value=(200, None))
2104+
with patch("alphaess.time.time", return_value=2800.0):
2105+
restored.note_external_change("AL70", "periodic", dict(payload, gridChargeCycle=0, executeCycleType=1))
2106+
assert "periodic" not in restored.applied_payload["AL70"], "Restart granted a fresh multi-poll grace to an old write"
2107+
run_async_local(restored._reconcile_control("AL70"))
2108+
restored._post.assert_not_awaited()
2109+
restored.startup_write_ready_at = 0
2110+
run_async_local(restored._reconcile_control("AL70"))
2111+
restored._post.assert_awaited_once_with("set_time_charge", body=payload)
2112+
assert restored.applied_payload["AL70"]["periodic"] == payload
2113+
2114+
20252115
def run_alphaess_control_tests(my_predbat):
20262116
"""Run all AlphaESS control-logic tests."""
20272117
failed = False
20282118
for name, fn in [
2119+
("readback_elapsed_grace", test_alphaess_readback_grace_expires_by_time_on_the_first_config_poll),
2120+
("readback_full_grace", test_alphaess_readback_grace_protects_the_full_five_minutes),
2121+
("periodic_cycle_type_change", test_alphaess_periodic_cycle_type_change_is_detected),
2122+
("active_control_readback_ttl", test_alphaess_control_readback_uses_five_minutes_only_while_driving),
2123+
("restart_reversion_startup_delay", test_alphaess_reverted_schedule_after_restart_is_reapplied_after_startup_delay),
20292124
("entities_round_trip", test_alphaess_control_entities_round_trip),
20302125
("reserve_unclamped", test_alphaess_reserve_entity_is_published_unclamped),
20312126
("entity_routing", test_alphaess_entity_routing_does_not_confuse_prefixed_serials),

‎docs/apps-yaml.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1028,7 +1028,7 @@ Keep `alphaess_app_id` and `alphaess_app_secret` in `secrets.yaml` rather than i
10281028
- `alphaess_control_enable` - Allow Predbat to write charge/export schedules to the inverter. Defaults to **`true`** - an AlphaESS component that is configured but not writing to the inverter is not what most users expect, so set this to `false` explicitly if you only want monitoring
10291029
- `alphaess_battery_rate_max` - Optional override, in Watts, for the battery's maximum charge/discharge rate - see below
10301030
- `alphaess_api_delay` - Optional pacing between API calls, in seconds (default: `2`). AlphaESS advise a minimum 10-second polling interval between calls to the same endpoint
1031-
- `alphaess_min_write_interval` - Optional minimum spacing between writes to the same inverter, in seconds (default: `300`). Both write endpoints are documented as writable once per 24 hours, so this is the first line of defence against exhausting that budget. Predbat applies a schedule in stages (window, then enable, then target SoC), so for up to 60 seconds after a write it will still send a small number of corrections to the schedule it just sent - otherwise a half-applied schedule, such as a charge window carrying the previous cycle's target SoC, would sit on the inverter for the whole interval
1031+
- `alphaess_min_write_interval` - Optional local pacing between writes to the same inverter, in seconds (default: `300`). The periodic schedule API (`setTimeChargeBySn`) supports repeated updates and is not limited to one write per day; the once-per-day wording in the legacy endpoint documentation does not apply to it. This setting avoids unnecessary requests and is not an API-imposed daily limit. Predbat applies a schedule in stages (window, then enable, then target SoC), so for up to 60 seconds after a write it will still send a small number of corrections to the schedule it just sent - otherwise a half-applied schedule, such as a charge window carrying the previous cycle's target SoC, would sit on the inverter for the whole interval
10321032
- `alphaess_startup_write_delay` - How long Predbat waits after starting or restarting before it sends charge and discharge schedules (default: `300` seconds, or five minutes). Predbat still reads your inverter during the wait. It sends the latest schedule when the wait ends and another update is due. Set to `0` to remove the wait
10331033
- `alphaess_hold_power` - The charging power Predbat requests when it needs to stop the battery discharging (default: `100` W). It sets a 10% target, so a battery below 10% may charge up to that level. Increase this value if your inverter rejects 100 W. This setting only works if your AlphaESS system lets Predbat set charging power through the cloud
10341034
- `alphaess_shutdown_mode` - What Predbat does when it stops normally (default: `none`). This only applies when inverter control is enabled and Predbat is not read-only:

‎docs/components.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1524,7 +1524,7 @@ Integrates with AlphaESS SMILE/Storion hybrid inverters via the AlphaESS Open AP
15241524

15251525
- **EXPERIMENTAL:** nobody on the Predbat project has AlphaESS hardware, so behaviour is inferred from AlphaESS's published Open API documentation and the Home Assistant AlphaESS integration rather than confirmed against real inverters. Every request and response is traced to the log (`api_debug`, on by default) with credentials redacted, so a tester can capture evidence for an issue report. Run the [diagnostics CLI](inverter-setup.md#alphaess-cloud) against your own system before trusting Predbat with control
15261526
- **Control is on by default**, the same as `sunsynk_control_enable`. Set `alphaess_control_enable: false` for monitoring only. `switch.predbat_set_read_only` additionally holds back Predbat's own automatic writes, including its periodic re-apply
1527-
- **Both write endpoints are documented as writable once per 24 hours.** Predbat therefore only writes when the payload actually changes, gates charge and discharge independently so one does not consume the other's budget, and paces writes with `alphaess_min_write_interval`
1527+
- **The periodic schedule API (`setTimeChargeBySn`) supports repeated updates and is not limited to one write per day.** Once-per-day wording appears in the legacy `updateChargeConfigInfo` and `updateDisChargeConfigInfo` documentation; it must not be applied to the periodic API. Predbat only writes when the payload actually changes and uses `alphaess_min_write_interval` as local pacing to avoid unnecessary requests. Legacy charge and discharge writes are paced independently
15281528
A schedule is committed in stages (window, then enable, then target SoC), so a small, capped number of corrections is allowed through within 60 seconds of a successful write - without it the inverter would run a schedule Predbat had already superseded until the interval expired
15291529
- **Export cannot be controlled at all.** The Open API has no forced-export, working-mode or dispatch endpoint, and AlphaESS document the discharge window as a *permission* window - during it the system runs in self-consumption, outside it the battery may only charge - so Force Export exports nothing beyond genuine solar surplus. Freeze Export is equally undeliverable, because nothing in the API stops the battery charging from solar. Set `select.predbat_mode` to `Control charge` on these systems, and see [AlphaESS cannot be used to control export](apps-yaml.md#alphaess-cannot-be-used-to-control-export)
15301530
- The battery percentage in a timed discharge schedule is Predbat's requested reserve, or its export target if it has planned an export window. AlphaESS has a separate minimum battery level for powering your home; set that in AlphaESS. A disabled discharge schedule may still show a battery percentage because AlphaESS requires one, but it does not set that separate level. Predbat uses a **zero** charge or discharge rate when it needs to hold the battery, because AlphaESS has no pause control
@@ -1535,6 +1535,7 @@ A schedule is committed in stages (window, then enable, then target SoC), so a s
15351535
- Systems reporting no battery capacity (plug-in solar, such as the VT1000 family) are skipped by design - there is nothing for Predbat to control
15361536
- A system that does not serve live power data automatically falls back to five-minute history data and re-probes itself back to live if it recovers
15371537
- Using the AlphaESS phone app while Predbat is running can overwrite Predbat's settings, and vice versa - the charge and discharge endpoints are whole-object replacements, so the last writer wins. Predbat logs when a field it owns has changed since its last write
1538+
- While Predbat actively controls a system, it reads schedules back every five minutes; monitoring-only and read-only installations use a thirty-minute interval. A write gets five minutes to reach the inverter before a mismatch triggers re-application, and a restart preserves that deadline. Periodic readback also checks whether the daily schedule has been changed to weekly
15381539
- A write reaching the cloud does not mean the inverter has applied it: it collects new settings on its next poll, typically one to five minutes later
15391540

15401541
#### Configuration Options (alphaess)

‎templates/alphaess_cloud.yaml‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,9 @@ pred_bat:
4949
# inverter's nominal power (poinv). Set this if you know your pack's real limit.
5050
#alphaess_battery_rate_max: 3600
5151

52-
# Optional pacing. AlphaESS advise a minimum 10-second polling interval, and both write
53-
# endpoints are documented as writable once per 24 hours.
52+
# Optional pacing. AlphaESS advise a minimum 10-second polling interval.
53+
# The periodic schedule API supports repeated writes, not a once-per-day limit.
54+
# alphaess_min_write_interval is Predbat's local pacing, not an API-imposed daily limit.
5455
#alphaess_api_delay: 2
5556
#alphaess_min_write_interval: 300
5657
# Wait 5 minutes after Predbat starts before sending schedules. Monitoring continues.

0 commit comments

Comments
 (0)