Skip to content

Commit d2a2edc

Browse files
committed
Write the API traffic out when debug logging is on
Working out what this API does has twice meant asking a user to write and sign their own requests, because nothing in the integration reported what it sent or what came back. Every call already goes through one gate, so that is where the trace belongs. With debug on, each request is written out as it would be typed, with its full response, how long it took and how long it waited for the call spacing. Rejections carry the return code, transport failures the exception, and a retry after a 6053 says so. Nothing is rendered when debug is off, and the clock is not even read then, so the cost is a level check per call. Long responses are capped rather than dropped - one day of power data is thousands of samples. Three additions around it: the write log now carries the enable pair it sent, which is the field that decides the inverter's working mode and the one that was missing when it mattered; staging logs each field and the resulting draft; and a lock or unlock of the schedule surface is announced once, with the read state, the timed-control answer and the recorded flags that decided it. A user reporting that the controls vanished should be answerable from the log alone. README says how to turn it on, and warns that the log carries the serial.
1 parent 72b9408 commit d2a2edc

4 files changed

Lines changed: 261 additions & 4 deletions

File tree

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,26 @@ fast lane is still active, which is what to check when something else is sharing
363363
the same API account. Serials are replaced with `inverter_1`, `inverter_2` and so
364364
on, and credentials, keys and addresses are redacted.
365365

366+
For anything the download cannot show — what happened, in what order — turn on
367+
debug logging:
368+
369+
```yaml
370+
logger:
371+
default: warning
372+
logs:
373+
custom_components.alphaess: debug
374+
alphaess: debug
375+
```
376+
377+
Every OpenAPI request and **its full response** is then written out, along with
378+
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
380+
same information a hand-signed API request would give you, without signing one.
381+
Very large responses are capped rather than dropped. Requests carry no
382+
credentials — those live in the headers, which are not logged — but the log does
383+
contain your serial number, so trim it before posting if you would rather not
384+
share it.
385+
366386
### AlphaESS API limitations
367387
368388
- The periodic API requires at least one complete charge period **and** one

custom_components/alphaess/const.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@
4444
# up to five. Any 6053 drops the session back to the documented
4545
# MIN_API_CALL_INTERVAL_SECONDS and retries that one call once.
4646
FAST_API_CALL_INTERVAL_SECONDS = 1
47+
48+
# Debug logging writes every API response out in full. getOneDayPowerBySn can
49+
# run to thousands of samples, so long ones are capped rather than dropped.
50+
MAX_LOGGED_RESPONSE_CHARS = 4000
4751
RATE_LIMIT_CODE = 6053
4852
MIN_FAST_SCAN_INTERVAL_SECONDS = MIN_API_CALL_INTERVAL_SECONDS
4953
MAX_FAST_SCAN_INTERVAL_SECONDS = 300

custom_components/alphaess/coordinator.py

Lines changed: 101 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
DOMAIN,
2525
FAST_API_CALL_INTERVAL_SECONDS,
2626
LOWER_INVERTER_API_CALL_LIST,
27+
MAX_LOGGED_RESPONSE_CHARS,
2728
MIN_API_CALL_INTERVAL_SECONDS,
2829
RATE_LIMIT_CODE,
2930
SCAN_INTERVAL,
@@ -519,6 +520,10 @@ def __init__(
519520
self._periodic_enable_intent: dict[str, dict[str, int]] = {}
520521
self._periodic_enable_sent: dict[str, dict[str, int]] = {}
521522

523+
# Last logged schedule surface state per serial, so a lock or unlock is
524+
# reported once with its reason rather than on every poll.
525+
self._schedule_state_logged: dict[str, tuple] = {}
526+
522527
# Entity changes are drafts. A user can edit start/end/limits/switches
523528
# without emitting a series of unsafe intermediate full replacements,
524529
# then commit once with the Apply Schedule button.
@@ -574,17 +579,51 @@ def _call_interval(self) -> float:
574579
)
575580
return self.throttle_multiplier
576581

582+
@staticmethod
583+
def _describe_call(method, args, kwargs) -> str:
584+
"""Render one call the way it would be written by hand."""
585+
shown = [repr(arg) for arg in args]
586+
shown += [f"{key}={value!r}" for key, value in kwargs.items()]
587+
return f"{getattr(method, '__name__', method)}({', '.join(shown)})"
588+
589+
@staticmethod
590+
def _describe_response(value: Any) -> str:
591+
"""Render a response, capping the ones that run to thousands of lines."""
592+
text = repr(value)
593+
if len(text) <= MAX_LOGGED_RESPONSE_CHARS:
594+
return text
595+
return f"{text[:MAX_LOGGED_RESPONSE_CHARS]}... [{len(text)} chars]"
596+
577597
async def _call_cloud_api(self, method, *args, **kwargs) -> Any:
578-
"""Call one OpenAPI endpoint after the currently required interval."""
598+
"""Call one OpenAPI endpoint after the currently required interval.
599+
600+
With debug logging on, every request and every response is written out.
601+
Establishing what this API actually returns has needed hand-signed
602+
requests more than once (#267); it should come out of a log instead.
603+
"""
604+
tracing = _LOGGER.isEnabledFor(logging.DEBUG)
605+
call = self._describe_call(method, args, kwargs) if tracing else ""
606+
waited = 0.0
579607
async with self._api_request_lock:
580608
if self._last_api_request_completed is not None:
581609
remaining = self._call_interval() - (
582610
time_mod.monotonic() - self._last_api_request_completed
583611
)
584612
if remaining > 0:
613+
waited = remaining
585614
await asyncio.sleep(remaining)
615+
# Only read the clock when the answer will be used: tests and
616+
# production alike should not pay for tracing that is switched off.
617+
started = time_mod.monotonic() if tracing else 0.0
586618
try:
587-
return await method(*args, **kwargs)
619+
result = await method(*args, **kwargs)
620+
if tracing:
621+
_LOGGER.debug(
622+
"API %s -> %s (%.2fs, waited %.2fs)",
623+
call, self._describe_response(result),
624+
time_mod.monotonic() - started, waited,
625+
)
626+
return result
588627
except AlphaESSApiError as err:
589628
if err.code == RATE_LIMIT_CODE and self._fast_api_lane:
590629
# The fast lane overshot this server's limits. Fall back
@@ -596,7 +635,27 @@ async def _call_cloud_api(self, method, *args, **kwargs) -> Any:
596635
"dropping to %ss spacing", self.throttle_multiplier,
597636
)
598637
await asyncio.sleep(self.throttle_multiplier)
599-
return await method(*args, **kwargs)
638+
retried = await method(*args, **kwargs)
639+
if tracing:
640+
_LOGGER.debug(
641+
"API %s retried after 6053 -> %s",
642+
call, self._describe_response(retried),
643+
)
644+
return retried
645+
if tracing:
646+
_LOGGER.debug(
647+
"API %s -> %s (%.2fs, waited %.2fs)",
648+
call, describe_api_error(err),
649+
time_mod.monotonic() - started, waited,
650+
)
651+
raise
652+
except Exception as err:
653+
if tracing:
654+
_LOGGER.debug(
655+
"API %s raised %s: %s (%.2fs, waited %.2fs)",
656+
call, type(err).__name__, err,
657+
time_mod.monotonic() - started, waited,
658+
)
600659
raise
601660
finally:
602661
# A rejected or indeterminate request still consumed an API
@@ -1227,11 +1286,44 @@ def _overlay_schedule_view(self, serial: str, data: dict[str, Any]) -> None:
12271286
committed[0] == 1 or committed[1] == 1
12281287
)
12291288

1289+
def _note_schedule_surface(self, serial: str) -> None:
1290+
"""Log a lock or unlock once, with what decided it.
1291+
1292+
A user reports "the controls went away"; this is the line that says
1293+
when, and on the strength of what.
1294+
"""
1295+
state = (
1296+
self.get_periodic_read_state(serial),
1297+
self.is_time_based_control_active(serial),
1298+
self.can_modify_time_controls(serial),
1299+
)
1300+
if self._schedule_state_logged.get(serial) == state:
1301+
return
1302+
first_time = serial not in self._schedule_state_logged
1303+
self._schedule_state_logged[serial] = state
1304+
read_state, active, modifiable = state
1305+
message = (
1306+
"Schedule surface for %s is %s: periodic read %s, timed control %s, "
1307+
"recorded enable flags %s"
1308+
)
1309+
args = (
1310+
serial,
1311+
"editable" if modifiable else "locked",
1312+
read_state,
1313+
{True: "active", False: "inactive", None: "unknown"}[active],
1314+
self._periodic_enable_intent.get(serial) or "none recorded",
1315+
)
1316+
if first_time:
1317+
_LOGGER.debug(message, *args)
1318+
else:
1319+
_LOGGER.info(message, *args)
1320+
12301321
def _publish_schedule_view(self, serial: str) -> None:
12311322
"""Publish a schedule view change to all coordinator entities."""
12321323
if serial not in self.data:
12331324
return
12341325
self._overlay_schedule_view(serial, self.data[serial])
1326+
self._note_schedule_surface(serial)
12351327
self.async_set_updated_data({key: dict(value) for key, value in self.data.items()})
12361328

12371329
def stage_schedule_change(
@@ -1329,6 +1421,10 @@ def stage_schedule_change(
13291421

13301422
for side, changes in (("charge", charge), ("discharge", discharge)):
13311423
if changes:
1424+
_LOGGER.debug(
1425+
"Staged %s for %s: %s (draft now %s)",
1426+
side, serial, changes, self._schedule_drafts[serial][side],
1427+
)
13321428
self._schedule_drafts[serial][side].update(changes)
13331429
self._schedule_draft_dirty[serial][side].update(changes)
13341430
self._schedule_draft_revisions[serial] = (
@@ -2141,8 +2237,9 @@ async def _safe_async_apply_schedule_locked(
21412237
self._periodic_enable_intent[serial] = dict(enable)
21422238
self._periodic_enable_sent[serial] = dict(enable)
21432239
_LOGGER.info(
2144-
"Wrote periodic schedule for %s - charge: %s, discharge: %s",
2240+
"Wrote periodic schedule for %s - enable %s, charge: %s, discharge: %s",
21452241
serial,
2242+
enable,
21462243
periodic_proposed["chargeTimeList"],
21472244
periodic_proposed["dischargeTimeList"],
21482245
)

tests/test_schedule_edge_coverage.py

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from unittest.mock import AsyncMock
77

88
import pytest
9+
from alphaess.alphaess import AlphaESSApiError
910
from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError
1011

1112
from custom_components.alphaess.button import AlphaESSBatteryButton
@@ -1509,3 +1510,138 @@ def test_a_quick_button_will_not_create_a_period_without_a_cutoff(
15091510
discharge=None,
15101511
now_window=True,
15111512
)
1513+
1514+
1515+
class TestApiTracing:
1516+
"""With debug logging on, the log should contain what a hand-signed request
1517+
would have told you - that is what it took to work out #267."""
1518+
1519+
async def test_a_call_and_its_answer_are_both_written_out(
1520+
self, make_coordinator, mock_api, caplog
1521+
):
1522+
coordinator = make_coordinator()
1523+
schedule = _daily_schedule()
1524+
mock_api.getTimeChargeBySn.return_value = schedule
1525+
1526+
with caplog.at_level(logging.DEBUG, logger="custom_components.alphaess.coordinator"):
1527+
await coordinator._call_cloud_api(mock_api.getTimeChargeBySn, SERIAL)
1528+
1529+
trace = [r.getMessage() for r in caplog.records if "API " in r.getMessage()]
1530+
assert trace, caplog.text
1531+
assert SERIAL in trace[0]
1532+
assert "chargeTimeList" in trace[0]
1533+
1534+
async def test_keyword_arguments_are_shown_as_written(
1535+
self, make_coordinator, mock_api, caplog
1536+
):
1537+
coordinator = make_coordinator()
1538+
1539+
with caplog.at_level(logging.DEBUG, logger="custom_components.alphaess.coordinator"):
1540+
await coordinator._call_cloud_api(
1541+
mock_api.setTimeChargeBySn,
1542+
sysSn=SERIAL,
1543+
executeCycleType=0,
1544+
chargeTimeList=[],
1545+
dischargeTimeList=[],
1546+
gridChargeCycle=1,
1547+
ctrDisCycle=0,
1548+
)
1549+
1550+
# The pair that decides the working mode has to be in the log.
1551+
assert "gridChargeCycle=1" in caplog.text
1552+
assert "ctrDisCycle=0" in caplog.text
1553+
1554+
async def test_a_rejection_is_traced_with_its_return_code(
1555+
self, make_coordinator, mock_api, caplog
1556+
):
1557+
coordinator = make_coordinator()
1558+
mock_api.getTimeChargeBySn.side_effect = _api_error(6017)
1559+
1560+
with caplog.at_level(logging.DEBUG, logger="custom_components.alphaess.coordinator"):
1561+
with pytest.raises(AlphaESSApiError):
1562+
await coordinator._call_cloud_api(mock_api.getTimeChargeBySn, SERIAL)
1563+
1564+
assert "6017" in caplog.text
1565+
1566+
async def test_a_transport_failure_is_traced_too(
1567+
self, make_coordinator, mock_api, caplog
1568+
):
1569+
coordinator = make_coordinator()
1570+
mock_api.getTimeChargeBySn.side_effect = TimeoutError("no response")
1571+
1572+
with caplog.at_level(logging.DEBUG, logger="custom_components.alphaess.coordinator"):
1573+
with pytest.raises(TimeoutError):
1574+
await coordinator._call_cloud_api(mock_api.getTimeChargeBySn, SERIAL)
1575+
1576+
assert "TimeoutError" in caplog.text
1577+
1578+
async def test_a_retry_after_a_rate_limit_is_traced(
1579+
self, make_coordinator, mock_api, caplog
1580+
):
1581+
coordinator = make_coordinator()
1582+
mock_api.getTimeChargeBySn.side_effect = [_api_error(6053), {"ok": True}]
1583+
1584+
with caplog.at_level(logging.DEBUG, logger="custom_components.alphaess.coordinator"):
1585+
result = await coordinator._call_cloud_api(
1586+
mock_api.getTimeChargeBySn, SERIAL,
1587+
)
1588+
1589+
assert result == {"ok": True}
1590+
assert "retried after 6053" in caplog.text
1591+
1592+
def test_a_huge_response_is_capped_rather_than_dropped(self, make_coordinator):
1593+
"""getOneDayPowerBySn runs to thousands of samples; the log stays
1594+
usable without losing the fact that an answer arrived."""
1595+
coordinator = make_coordinator()
1596+
rendered = coordinator._describe_response([{"cbat": 55}] * 5000)
1597+
1598+
assert rendered.endswith("chars]")
1599+
assert len(rendered) < 4200
1600+
1601+
async def test_nothing_is_rendered_when_tracing_is_off(
1602+
self, make_coordinator, mock_api, caplog
1603+
):
1604+
coordinator = make_coordinator()
1605+
mock_api.getTimeChargeBySn.return_value = _daily_schedule()
1606+
1607+
with caplog.at_level(logging.INFO, logger="custom_components.alphaess.coordinator"):
1608+
await coordinator._call_cloud_api(mock_api.getTimeChargeBySn, SERIAL)
1609+
1610+
assert "API " not in caplog.text
1611+
1612+
1613+
class TestScheduleSurfaceLogging:
1614+
"""A user reports that the controls went away; the log should say when and
1615+
on the strength of what."""
1616+
1617+
async def test_a_lock_is_announced_with_its_reason(
1618+
self, make_coordinator, mock_api, caplog
1619+
):
1620+
coordinator = _seed(
1621+
make_coordinator(), periodic=_daily_schedule(), readable=True,
1622+
)
1623+
coordinator._publish_schedule_view(SERIAL) # first pass: debug only
1624+
1625+
with caplog.at_level(logging.INFO, logger="custom_components.alphaess.coordinator"):
1626+
coordinator.set_periodic_enable_intent(
1627+
SERIAL, grid_charge=0, ctr_dis=0,
1628+
)
1629+
coordinator._publish_schedule_view(SERIAL)
1630+
1631+
assert "is locked" in caplog.text
1632+
assert "periodic read readable" in caplog.text
1633+
assert "timed control inactive" in caplog.text
1634+
1635+
async def test_the_same_state_is_not_repeated_every_poll(
1636+
self, make_coordinator, mock_api, caplog
1637+
):
1638+
coordinator = _seed(
1639+
make_coordinator(), periodic=_daily_schedule(), readable=True,
1640+
)
1641+
coordinator._publish_schedule_view(SERIAL)
1642+
1643+
with caplog.at_level(logging.INFO, logger="custom_components.alphaess.coordinator"):
1644+
for _ in range(5):
1645+
coordinator._publish_schedule_view(SERIAL)
1646+
1647+
assert "Schedule surface" not in caplog.text

0 commit comments

Comments
 (0)