Skip to content

Commit 72b9408

Browse files
committed
Put the answers in the diagnostics download
Every question asked on #267 needed something the download did not carry, so it took three rounds of signed API requests written by hand to establish what the integration already had in memory. The download now includes, per inverter: which store governs and why, the capability flags behind each unavailable control, the write-only enable pair that nothing else can report, the periodic and legacy snapshots as they were read, any open draft with its dirty fields and in-flight state, the duration cooldowns, and the consecutive poll error count. Alongside it, how the client is pacing calls and whether the fast lane is still on, which is what to look at when something else shares the API account. That one file would have answered the whole report: an empty dischargeTimeList, a 0/0 enable pair with nothing recorded, and the capability flags explaining the locked UI. Serials stay anonymised and credentials stay redacted.
1 parent f32eb87 commit 72b9408

4 files changed

Lines changed: 210 additions & 0 deletions

File tree

README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,29 @@ until a full periodic read succeeds. The integration never builds a
340340
replacement schedule from anything but a fresh read, because doing so could
341341
erase weekly settings, extra periods, or power values that it cannot see.
342342

343+
### Reporting a schedule problem
344+
345+
**Settings → Devices & Services → AlphaESS → ⋮ → Download diagnostics** answers
346+
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:
349+
350+
- `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;
353+
- `enable_intent` / `enable_last_sent` — the write-only pair, which nothing else
354+
can report;
355+
- `periodic_snapshot` and `legacy_snapshot` — what each store actually held,
356+
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;
359+
- cooldowns and consecutive poll errors.
360+
361+
The `coordinator.api` section reports the current call spacing and whether the
362+
fast lane is still active, which is what to check when something else is sharing
363+
the same API account. Serials are replaced with `inverter_1`, `inverter_2` and so
364+
on, and credentials, keys and addresses are redacted.
365+
343366
### AlphaESS API limitations
344367

345368
- The periodic API requires at least one complete charge period **and** one

custom_components/alphaess/coordinator.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2593,6 +2593,71 @@ def _update_diagnostics(self) -> None:
25932593
self.get_periodic_read_state(serial)
25942594
)
25952595

2596+
def schedule_diagnostics(self, serial: str) -> dict[str, Any]:
2597+
"""Return everything needed to explain a schedule state from a report.
2598+
2599+
Every field here answers a question that has cost a round trip with a
2600+
tester: which store governs, why the controls are locked, what the two
2601+
write-only enable flags were last set to, and what the stores actually
2602+
held at the time. It must never raise - a diagnostics download is often
2603+
the only thing a user can still produce.
2604+
"""
2605+
draft = self._schedule_drafts.get(serial)
2606+
dirty = self._schedule_draft_dirty.get(serial) or {}
2607+
now = time_mod.monotonic()
2608+
2609+
def _cooldown(store: dict[str, float]) -> float | None:
2610+
started = store.get(serial)
2611+
return None if started is None else round(now - started, 1)
2612+
2613+
if self.is_periodic_schedule_readable(serial):
2614+
governing_store = "periodic"
2615+
elif self.is_legacy_backup_active(serial):
2616+
governing_store = "legacy-backup"
2617+
else:
2618+
governing_store = "none"
2619+
2620+
return {
2621+
"governing_store": governing_store,
2622+
"periodic_read": self.get_periodic_read_state(serial),
2623+
"periodic_write_denied": serial in self._periodic_write_denied,
2624+
"capabilities": {
2625+
"time_based_control_active": self.is_time_based_control_active(serial),
2626+
"can_stage_schedule": self.can_stage_schedule(serial),
2627+
"can_modify_time_controls": self.can_modify_time_controls(serial),
2628+
"can_unlock_time_controls": self.can_unlock_time_controls(serial),
2629+
"can_reset_schedule": self.can_reset_schedule(serial),
2630+
},
2631+
# Write-only on the periodic API: the read reports 0 for both
2632+
# whatever the inverter is set to, so this record is the only
2633+
# answer there is, and an absent one blocks every write.
2634+
"enable_intent": self._periodic_enable_intent.get(serial),
2635+
"enable_last_sent": self._periodic_enable_sent.get(serial),
2636+
"periodic_snapshot": self._periodic_schedules.get(serial),
2637+
"legacy_snapshot": self._legacy_schedules.get(serial),
2638+
"draft": None if draft is None else {
2639+
"dirty": {side: sorted(fields) for side, fields in dirty.items()},
2640+
"revision": self._schedule_draft_revisions.get(serial, 0),
2641+
"apply_in_progress": serial in self._schedule_apply_in_progress,
2642+
"staged": draft,
2643+
},
2644+
"number_settings": self.number_settings.get(serial),
2645+
"seconds_since_last_charge_write": _cooldown(self.last_charge_update),
2646+
"seconds_since_last_discharge_write": _cooldown(self.last_discharge_update),
2647+
"consecutive_poll_errors": self._inverter_error_count.get(serial, 0),
2648+
}
2649+
2650+
def api_diagnostics(self) -> dict[str, Any]:
2651+
"""Return how the client is currently pacing and answering."""
2652+
return {
2653+
"fast_api_lane": self._fast_api_lane,
2654+
"call_interval_seconds": self._call_interval(),
2655+
"throttle_multiplier": self.throttle_multiplier,
2656+
"last_poll_type": self._last_poll_type,
2657+
"last_full_poll": self._last_full_poll_utc or "never",
2658+
"poll_tick_count": self._poll_tick_count,
2659+
}
2660+
25962661
def get_periodic_read_state(self, serial: str) -> str:
25972662
"""Return whether the periodic schedule can be read on this account.
25982663

custom_components/alphaess/diagnostics.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,15 @@ async def async_get_config_entry_diagnostics(
4545
for idx, serial in enumerate(coordinator.data or {})
4646
}
4747

48+
# The state behind the schedule surface: which store governs, why the
49+
# controls are in the state they are, and what the stores actually hold.
50+
# None of it identifies anyone, and it is what a report needs to be
51+
# answerable without a round trip.
52+
schedule = {
53+
f"inverter_{idx + 1}": coordinator.schedule_diagnostics(serial)
54+
for idx, serial in enumerate(coordinator.data or {})
55+
}
56+
4857
return {
4958
"entry": {
5059
"data": async_redact_data(dict(entry.data), TO_REDACT_CONFIG),
@@ -62,6 +71,8 @@ async def async_get_config_entry_diagnostics(
6271
"model_list": coordinator.model_list,
6372
"has_throttle": coordinator.has_throttle,
6473
"periodic_schedule_read": periodic_schedule_read,
74+
"api": coordinator.api_diagnostics(),
6575
},
76+
"schedule": schedule,
6677
"data": inverters,
6778
}

tests/test_device_diagnostics.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
"""Tests for device info builders and diagnostics."""
2+
import time as time_mod
3+
24
from custom_components.alphaess.device import (
35
build_ev_charger_device_info,
46
build_inverter_device_info,
@@ -97,3 +99,112 @@ async def test_diagnostics_with_empty_data(self, mock_hass, make_coordinator):
9799

98100
result = await async_get_config_entry_diagnostics(mock_hass, entry)
99101
assert result["data"] == {}
102+
103+
104+
class TestScheduleDiagnostics:
105+
"""Every field here exists because answering a report needed it and the
106+
download did not have it."""
107+
108+
def _periodic(self):
109+
return {
110+
"executeCycleType": 1,
111+
"chargeTimeList": [
112+
{
113+
"beginTime": "05:00",
114+
"endTime": "06:00",
115+
"weeks": [1, 2, 3, 4, 5, 6, 7],
116+
"chargePower": 5000,
117+
"chargeLimit": 55,
118+
}
119+
],
120+
"dischargeTimeList": [],
121+
}
122+
123+
async def test_a_periodic_system_reports_why_it_is_in_that_state(
124+
self, mock_hass, make_coordinator
125+
):
126+
coordinator = make_coordinator()
127+
coordinator.data = {"AL_SERIAL": {"Model": "SMILE-G3-S5-INV"}}
128+
coordinator._periodic_readable["AL_SERIAL"] = True
129+
coordinator._periodic_schedules["AL_SERIAL"] = self._periodic()
130+
coordinator.set_periodic_enable_intent(
131+
"AL_SERIAL", grid_charge=1, ctr_dis=0,
132+
)
133+
coordinator.last_charge_update["AL_SERIAL"] = time_mod.monotonic()
134+
entry = FakeEntry()
135+
entry.runtime_data = coordinator
136+
137+
result = await async_get_config_entry_diagnostics(mock_hass, entry)
138+
schedule = result["schedule"]["inverter_1"]
139+
140+
assert schedule["governing_store"] == "periodic"
141+
assert schedule["periodic_read"] == "readable"
142+
assert schedule["periodic_write_denied"] is False
143+
# The write-only pair: the only record there is.
144+
assert schedule["enable_intent"] == {
145+
"gridChargeCycle": 1, "ctrDisCycle": 0,
146+
}
147+
# The resource itself, so an empty list is visible without asking.
148+
assert schedule["periodic_snapshot"]["dischargeTimeList"] == []
149+
assert schedule["capabilities"]["can_modify_time_controls"] is True
150+
assert schedule["draft"] is None
151+
assert schedule["seconds_since_last_charge_write"] is not None
152+
assert schedule["seconds_since_last_discharge_write"] is None
153+
assert result["coordinator"]["api"]["fast_api_lane"] is True
154+
155+
async def test_a_backup_system_reports_the_legacy_snapshot(
156+
self, mock_hass, make_coordinator
157+
):
158+
"""The store a migrated system no longer uses is exactly what needs to
159+
be visible when it is the one being read."""
160+
coordinator = make_coordinator()
161+
coordinator.data = {"AL_SERIAL": {"Model": "SMILE5-INV"}}
162+
coordinator._periodic_readable["AL_SERIAL"] = False
163+
coordinator._legacy_schedules["AL_SERIAL"] = {
164+
"charge": {"gridCharge": 0, "timeChaf1": "17:00", "timeChae1": "17:15"},
165+
"discharge": {"ctrDis": 0, "timeDisf1": "00:00", "timeDise1": "00:00"},
166+
}
167+
entry = FakeEntry()
168+
entry.runtime_data = coordinator
169+
170+
result = await async_get_config_entry_diagnostics(mock_hass, entry)
171+
schedule = result["schedule"]["inverter_1"]
172+
173+
assert schedule["governing_store"] == "legacy-backup"
174+
assert schedule["legacy_snapshot"]["charge"]["timeChaf1"] == "17:00"
175+
assert schedule["capabilities"]["can_reset_schedule"] is True
176+
177+
async def test_an_open_draft_is_described(self, mock_hass, make_coordinator):
178+
coordinator = make_coordinator()
179+
coordinator.data = {"AL_SERIAL": {"Model": "SMILE-G3-S5-INV"}}
180+
coordinator._periodic_readable["AL_SERIAL"] = True
181+
coordinator._periodic_schedules["AL_SERIAL"] = self._periodic()
182+
coordinator.set_periodic_enable_intent(
183+
"AL_SERIAL", grid_charge=1, ctr_dis=1,
184+
)
185+
coordinator.stage_schedule_change("AL_SERIAL", charge={"timeChaf1": "02:00"})
186+
entry = FakeEntry()
187+
entry.runtime_data = coordinator
188+
189+
result = await async_get_config_entry_diagnostics(mock_hass, entry)
190+
draft = result["schedule"]["inverter_1"]["draft"]
191+
192+
assert draft["dirty"]["charge"] == ["timeChaf1"]
193+
assert draft["apply_in_progress"] is False
194+
assert draft["staged"]["charge"]["timeChaf1"] == "02:00"
195+
196+
async def test_a_system_with_no_usable_store_says_so(
197+
self, mock_hass, make_coordinator
198+
):
199+
coordinator = make_coordinator()
200+
coordinator.data = {"AL_SERIAL": {"Model": "SMILE5-INV"}}
201+
entry = FakeEntry()
202+
entry.runtime_data = coordinator
203+
204+
result = await async_get_config_entry_diagnostics(mock_hass, entry)
205+
schedule = result["schedule"]["inverter_1"]
206+
207+
assert schedule["governing_store"] == "none"
208+
assert schedule["periodic_read"] == "unknown"
209+
assert schedule["enable_intent"] is None
210+
assert schedule["capabilities"]["can_stage_schedule"] is False

0 commit comments

Comments
 (0)