From 9db7a715c574538c69ca5ee473e6d50fe857642a Mon Sep 17 00:00:00 2001 From: Vokupt Date: Sat, 12 Sep 2026 23:54:11 +0700 Subject: [PATCH 01/24] test: drive a real MQTT frame to real entity state end to end The unit tests stop at module boundaries, and the failures that reach users live between them: an entity key that drifts from its parser output, a dispatcher signal built from two format strings that no longer match, a const key that gets renamed on one side only. None of those are visible to a per-module test, and all three have been real defects here. tests/test_e2e_mqtt_to_entity.py runs the whole path with real Home Assistant objects: hass.config_entries.async_setup() executes the shipping async_setup_entry, which builds the real API client, the real MQTT client and the real entities. Only two process boundaries are faked -- the HTTP server via HA's own aioclient_mock, and the MQTT socket by replacing connect. The client's __init__, its disconnect, and its entire message path (_on_message -> parse -> dispatch -> entity) are the real code. Three things this test had to work around, each documented in the file: * HA's loader cannot find the integration. content_in_root: true makes the repo root the package, so Integration.resolve_from_root looks for custom_components/lumentree/manifest.json and finds nothing. The Integration is built from the real manifest instead. * Frames are built, not captured. The one recording in the tree (mqtt_discovery.json) has had its non-ASCII bytes flattened to U+FFFD, so 0 of its 4,350 entries pass CRC. Building the frame keeps the file honest about what it feeds in. * _queue_update coalesces into a bare asyncio.create_task, which hass.async_block_till_done() cannot see. _settle() sleeps for the coalescing window, then blocks. Also here: * __init__.py hoists the coordinator stagger delay to a module-level _STAGGER_DELAY_SECONDS. The value is unchanged in production (10s); the test shrinks it to 0.01 because nothing under test is about that gap, and ten seconds per test was the whole suite runtime (61s -> 1.3s). * pyproject.toml adds custom_components.* and pytest_homeassistant_custom_component.* to the mypy ignore list. The first exists only while pytest runs -- conftest synthesises it -- so mypy cannot resolve it, though the modules behind it are checked under their real paths. * tests/conftest.py selects SelectorEventLoop on Windows, which aiodns requires, and probes for a real Home Assistant before installing stubs so a stubbed sys.modules entry cannot shadow the installed package. --- __init__.py | 8 +- pyproject.toml | 7 + tests/conftest.py | 55 +++- tests/test_e2e_mqtt_to_entity.py | 422 +++++++++++++++++++++++++++++++ 4 files changed, 483 insertions(+), 9 deletions(-) create mode 100644 tests/test_e2e_mqtt_to_entity.py diff --git a/__init__.py b/__init__.py index 8c74958..bedb88d 100644 --- a/__init__.py +++ b/__init__.py @@ -36,6 +36,12 @@ PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.BINARY_SENSOR] +# Delay before the monthly/yearly/total coordinators are warmed, so the backfill +# that the daily coordinator kicks off has time to fill the cache they read. +# Module-level so a test can shrink it; ten real seconds per entry otherwise +# dominates the end-to-end suite. +_STAGGER_DELAY_SECONDS = 10.0 + async def async_setup(hass: HomeAssistant, config: dict) -> bool: """Set up the Lumentree integration.""" @@ -122,7 +128,7 @@ async def _prime_coordinators_staggered(): except Exception as e: _LOGGER.warning("Daily coordinator first refresh failed: %s", e) # Give backfill time to populate cache, then refresh stats coordinators - await asyncio.sleep(10) + await asyncio.sleep(_STAGGER_DELAY_SECONDS) for coord, label in ( (monthly_coord, "monthly"), (yearly_coord, "yearly"), diff --git a/pyproject.toml b/pyproject.toml index 0f10dcf..a973174 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,13 @@ module = [ "paho.mqtt.*", "crcmod.*", "homeassistant.*", + # ``custom_components`` exists only while pytest runs: tests/conftest.py + # synthesises it so the root, which is itself the package, can be imported + # under a parent named custom_components.lumentree. mypy never executes that + # file, so it cannot resolve the name -- but the modules behind it are + # checked anyway, under their real paths. + "custom_components.*", + "pytest_homeassistant_custom_component.*", ] ignore_missing_imports = true diff --git a/tests/conftest.py b/tests/conftest.py index 76a58c9..a9ab840 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -24,6 +24,7 @@ from __future__ import annotations +import asyncio import importlib.util import socket import sys @@ -35,9 +36,46 @@ ROOT = Path(__file__).resolve().parent.parent -# Home Assistant is not a dependency of this suite. The modules under test -# (the MQTT payload parser and the cache store) are plain Python; only their -# module-level imports need these names to exist. + +# --- Windows event loop ------------------------------------------------------ +# Home Assistant's aiohttp connector wires in ``aiodns`` whenever the running +# loop is a ``SelectorEventLoop``, and ``aiodns`` raises outright when it is +# not: "aiodns needs a SelectorEventLoop on Windows". Windows defaults to +# ``ProactorEventLoop``, so any test that lets the integration build its own +# ``async_get_clientsession`` dies inside setup, before its first assertion. +# Linux (and therefore CI) is unaffected. +# +# The policy cannot simply be replaced: ``pytest_homeassistant_custom_component`` +# installs ``HassEventLoopPolicy`` at plugin import and then hard-disables +# further changes with ``asyncio.set_event_loop_policy = lambda policy: None``. +# That policy subclasses ``DefaultEventLoopPolicy`` and leaves ``_loop_factory`` +# at the platform default, so the loop it builds is a Proactor loop. Swapping +# ``_loop_factory`` is what actually selects the loop; it must happen here, +# before the pytest plugin applies its lock. +if sys.platform == "win32": + _policy = asyncio.get_event_loop_policy() + if getattr(_policy, "_loop_factory", None) is not asyncio.SelectorEventLoop: + _policy._loop_factory = asyncio.SelectorEventLoop # type: ignore[attr-defined] + +# Home Assistant is an optional dependency of this suite. The modules under +# test (the MQTT payload parser and the cache store) are plain Python; only +# their module-level imports need these names to exist. The end-to-end test +# does need the real package, so probe for it before deciding which way to go. +# +# The probe must happen here rather than lazily: these names go into +# ``sys.modules`` before any test module imports them, and a stubbed entry that +# is already in ``sys.modules`` shadows a perfectly good installed package for +# the rest of the run. +def _home_assistant_installed() -> bool: + try: + import homeassistant # noqa: F401 + except ImportError: + return False + return True + + +_HAS_REAL_HA = _home_assistant_installed() + _HA_STUBS = ( "homeassistant", "homeassistant.components", @@ -57,12 +95,13 @@ "homeassistant.util.dt", ) -for _name in _HA_STUBS: - sys.modules.setdefault(_name, MagicMock()) +if not _HAS_REAL_HA: + for _name in _HA_STUBS: + sys.modules.setdefault(_name, MagicMock()) -# const.py calls these two at import time to build its timezone helper. -sys.modules["homeassistant.util.dt"].get_time_zone = MagicMock(return_value=None) # type: ignore[attr-defined] -sys.modules["homeassistant.util.dt"].get_default_time_zone = MagicMock(return_value=None) # type: ignore[attr-defined] + # const.py calls these two at import time to build its timezone helper. + sys.modules["homeassistant.util.dt"].get_time_zone = MagicMock(return_value=None) # type: ignore[attr-defined] + sys.modules["homeassistant.util.dt"].get_default_time_zone = MagicMock(return_value=None) # type: ignore[attr-defined] def _package(dotted: str, path: Path) -> types.ModuleType: diff --git a/tests/test_e2e_mqtt_to_entity.py b/tests/test_e2e_mqtt_to_entity.py new file mode 100644 index 0000000..c150fb2 --- /dev/null +++ b/tests/test_e2e_mqtt_to_entity.py @@ -0,0 +1,422 @@ +"""End-to-end: a real MQTT frame becomes Home Assistant entity state. + +This test drives the whole real-time path with real Home Assistant objects: +``hass.config_entries.async_setup(entry)`` runs the shipping +``__init__.async_setup_entry``, which builds the real API client, the real MQTT +client and the real sensor entities. Only two things are faked, both at a +process boundary: + +* the HTTP server, via HA's own ``aioclient_mock`` -- the vendor is never + contacted; +* the MQTT socket, by replacing ``connect`` on the client -- the broker is never + contacted. The client's ``__init__``, its ``disconnect``, and its entire + message path (``_on_message`` -> parse -> dispatch) are the real code. + +That is the point of this file. The unit tests stop at module boundaries, and +what silently breaks is the wiring *between* the modules: an entity key that +drifts, a dispatcher signal built from two different format strings, a ``const`` +key that gets renamed. None of those are visible to a per-module test. + +The frames are built by ``build_frame`` below, a copy of the helper in +``test_realtime_parser.py``: a real Modbus-RTU 0x03 response with a valid CRC +over the same register layout the device publishes. A captured dump would be +truer to life, but the only recording in the tree (``mqtt_discovery.json``) has +had its non-ASCII bytes flattened to U+FFFD, so every frame in it fails CRC. +Building the frame keeps this file honest about what it is feeding in. + +Skipped unless Home Assistant is installed. ``pytest tests/`` on a bare +interpreter (CI installs pytest + aiohttp + paho + crcmod only) still collects +and passes the rest of the suite. +""" + +from __future__ import annotations + +import asyncio +import re + +import pytest + +try: + from homeassistant.core import HomeAssistant + from pytest_homeassistant_custom_component.common import MockConfigEntry + + HA_AVAILABLE = True +except ImportError: # pragma: no cover - exercised only without Home Assistant + HA_AVAILABLE = False + +pytestmark = pytest.mark.skipif( + not HA_AVAILABLE, reason="homeassistant is not installed; the e2e path needs it" +) + +DEVICE_SN = "P250812039" + +# Same wire format the device uses: a "++++" separator, then a 0x03 response. +SEPARATOR = "2b2b2b2b" + + +def _crc16(data: bytes) -> int: + """Modbus CRC16, little-endian on the wire.""" + crc = 0xFFFF + for byte in data: + crc ^= byte + for _ in range(8): + crc = (crc >> 1) ^ 0xA001 if crc & 1 else crc >> 1 + return crc + + +def build_frame(registers: dict[int, int], n_regs: int = 151) -> str: + """Build a CRC-valid 0x03 response carrying the given register values. + + 151 registers is the full frame the device sends; the byte-count field is a + single byte on the wire, so its 302-byte body declares 46. + """ + body = bytearray() + for i in range(n_regs): + body += (registers.get(i, 0) & 0xFFFF).to_bytes(2, "big") + pdu = bytes([0x01, 0x03, len(body) & 0xFF]) + bytes(body) + return SEPARATOR + (pdu + _crc16(pdu).to_bytes(2, "little")).hex() + + +# A full frame with one value pinned, so an assertion has something exact to +# compare against without hardcoding the whole register map here. +FRAME_FULL = build_frame({50: 77}) # register 50 = BATTERY_SOC +BAD_CRC_FRAME = (SEPARATOR + "01030000ffff") + +DEVICE_INFO = { + "deviceId": DEVICE_SN, + "deviceSn": DEVICE_SN, + "deviceType": "SUNT-6.0kW-HT", + "controllerVersion": "1.0.0", +} + +# ``deviceManage`` answers ``data.devices[0]``, not ``data`` itself -- reflected +# here because ``async_setup_entry`` aborts with "Device not found" otherwise. +DEVICE_MANAGE = {"returnValue": 1, "data": {"devices": [DEVICE_INFO]}} + +# The vendor answers every read endpoint with this envelope. Returning it for +# anything unregistered keeps a coordinator refresh from raising mid-setup; the +# value under test comes from the MQTT frame, not from HTTP. +EMPTY_OK = {"returnValue": 1, "data": {}} + + +@pytest.fixture +def lumentree_integration(hass: HomeAssistant): + """Make Home Assistant able to find this integration. + + The repository root *is* the ``lumentree`` package (``hacs.json`` sets + ``content_in_root: true``), so there is no ``custom_components/lumentree`` + directory for HA's loader to scan: ``Integration.resolve_from_root`` looks + for ``custom_components/lumentree/manifest.json`` and finds nothing. + + So the ``Integration`` is built here from the real ``manifest.json`` and the + real repository root, and registered in HA's integration cache under the + domain. The component itself is *not* faked -- ``pkg_path`` is the name + ``conftest`` already registered in ``sys.modules``, so + ``Integration.get_component()`` imports the shipping ``__init__.py``. + + Every field HA would have derived is read from the manifest rather than + hardcoded, so a manifest that stops being loadable fails here. + """ + import json + import pathlib + + import custom_components.lumentree as package + from homeassistant import loader + + root = pathlib.Path(__file__).resolve().parent.parent + + # ``conftest`` registers ``custom_components.lumentree`` as an empty + # namespace package so the unit tests can import submodules without running + # the integration's entry point. HA's loader caches components by module + # object, so the real ``__init__.py`` has to be executed into *that* module + # before the loader asks for it -- otherwise setup finds an empty module + # with no ``async_setup_entry``. + init_py = root / "__init__.py" + exec(compile(init_py.read_text(encoding="utf-8"), str(init_py), "exec"), package.__dict__) + + assert hasattr(package, "async_setup_entry"), ( + "__init__.py did not define async_setup_entry after being executed" + ) + + # Setup waits ten seconds between warming the daily coordinator and the + # stats coordinators, so a real backfill can land. Nothing here is about + # that gap, and ten seconds times every test is the whole runtime, so run it + # as fast as the scheduler allows. + package._STAGGER_DELAY_SECONDS = 0.01 + + manifest = json.loads((root / "manifest.json").read_text(encoding="utf-8")) + assert manifest["domain"] == "lumentree", "manifest domain drifted" + + integration = loader.Integration( + hass, + pkg_path="custom_components.lumentree", + file_path=root, + manifest=manifest, + top_level_files={entry.name for entry in root.iterdir()}, + ) + hass.data.setdefault(loader.DATA_INTEGRATIONS, {})["lumentree"] = integration + yield integration + hass.data.get(loader.DATA_INTEGRATIONS, {}).pop("lumentree", None) + + +@pytest.fixture +def vendor_http(aioclient_mock): + """Intercept the vendor's HTTP API. + + ``aioclient_mock`` is Home Assistant's own aiohttp mocker: it replaces + ``_async_create_clientsession``, so it catches the session the integration + asks HA for. Any route not registered here raises ``AssertionError`` from + the mocker, so a forgotten call fails the test instead of reaching the + internet. + """ + # ``aioclient_mock``'s response object exposes ``.status`` but not + # ``response.ok``, which is the aiohttp property ``api_client._request`` + # reads. Deriving it here keeps the shim at the mock boundary instead of + # changing shipping code to accommodate a test double. + def _with_ok(response): + response.ok = response.status < 400 + return response + + _original_match = aioclient_mock.match_request + + async def _match(method, url, **kwargs): + return _with_ok(await _original_match(method, url, **kwargs)) + + aioclient_mock.match_request = _match + + # ``deviceManage`` is a POST with the SN in the query string -- asserted + # here because it is a vendor contract, not a choice this repo controls. + aioclient_mock.post( + re.compile(r"http://lesvr\.suntcn\.com/lesvr/deviceManage.*"), + json=DEVICE_MANAGE, + ) + # Coordinator refreshes run during setup and hit other endpoints. They are + # not what this file tests, but an unanswered call aborts setup, so answer + # them with an empty success rather than letting them fail. + for method in ("get", "post"): + getattr(aioclient_mock, method)( + re.compile(r"http://lesvr\.suntcn\.com/.*"), + json=EMPTY_OK, + ) + return aioclient_mock + + +@pytest.fixture +async def entry(hass: HomeAssistant, lumentree_integration): + """Config entry shaped exactly like the one the config flow produces.""" + entry = MockConfigEntry( + domain="lumentree", + title="Test Inverter", + unique_id=DEVICE_SN, + data={ + "device_sn": DEVICE_SN, + "device_id": DEVICE_SN, + "http_token": "test-token", + }, + ) + entry.add_to_hass(hass) + yield entry + + # Setup arms a 5s poll timer and a nightly timer. HA's ``verify_cleanup`` + # fixture fails the test if either is still pending at teardown, so unload + # the entry the same way a user removing it would. Doing it here rather than + # in each test also means every test exercises the real unload path. + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + +async def _setup(hass: HomeAssistant, entry, mqtt: FakeTransport, monkeypatch) -> dict: + """Run the integration's real setup and return the entities by sensor key.""" + from custom_components.lumentree.const import DOMAIN + from custom_components.lumentree.core import mqtt_client as mqtt_module + + # Replace the transport only. ``disconnect`` is deliberately left real: it + # is the code that cancels the offline timer, and stubbing it out leaks a + # 12.5s ``async_call_later`` into teardown. With no ``_mqttc`` (because + # ``connect`` never built one) its body is just the cancel calls and a log + # line, so running it is both safe and the point of the exercise. + monkeypatch.setattr(mqtt_module.LumentreeMqttClient, "connect", mqtt.connect) + monkeypatch.setattr(mqtt_module.LumentreeMqttClient, "async_request_data", mqtt.noop) + monkeypatch.setattr( + mqtt_module.LumentreeMqttClient, "async_request_battery_cells", mqtt.noop + ) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert DOMAIN in hass.data and entry.entry_id in hass.data[DOMAIN], "setup left no state" + client = hass.data[DOMAIN][entry.entry_id].get("mqtt_client") + assert client is not None, "entry state carries no MQTT client" + + # Read the entities back through the registry so the assertions are about + # what Home Assistant actually holds, not a list this test kept itself. + # + # Two maps come back. ``entities`` holds only entities with live state, and + # is what the state assertions index into. ``all_keys`` holds every key the + # integration registers, including the ones it ships disabled: HA registers + # those but deliberately creates no state until a user enables them, so the + # drift check must still count them as "has an entity". + from homeassistant.helpers import entity_registry as er + + registry = er.async_get(hass) + entities: dict[str, str] = {} + all_keys: set[str] = set() + for reg_entry in er.async_entries_for_config_entry(registry, entry.entry_id): + key = reg_entry.unique_id.removeprefix(f"{DEVICE_SN}_") + all_keys.add(key) + if reg_entry.disabled_by is not None: + continue + state = hass.states.get(reg_entry.entity_id) + assert state is not None, f"{reg_entry.entity_id} is registered but has no state" + entities[key] = reg_entry.entity_id + assert entities, "setup registered no sensor entities" + return {"client": client, "entities": entities, "all_keys": all_keys} + + +class FakeTransport: + """Stands in for the MQTT socket; the message path stays real. + + Only ``connect`` is stubbed. The real ``disconnect`` is what cancels the + client's timers, so it runs -- see ``_setup``. + """ + + async def connect(self) -> None: + return None + + async def noop(self, *args, **kwargs) -> None: + return None + + +def _deliver(client, payload_hex: str, topic: str | None = None) -> None: + """Invoke the client's paho message handler with a captured frame. + + The first two arguments are paho's client and userdata, which + ``_on_message`` never reads; only ``msg.topic`` and ``msg.payload`` matter. + """ + msg = type("Msg", (), {"topic": topic or client._topic_sub, "payload": bytes.fromhex(payload_hex)}) + client._on_message(None, None, msg) + + +async def _settle(hass: HomeAssistant, client) -> None: + """Wait for a delivered frame to reach entity state. + + The client does not dispatch inline: ``_queue_update`` coalesces into a + 100ms ``asyncio.create_task`` so a burst of frames becomes one update. + ``async_block_till_done`` does not wait for that task -- HA never tracked it + -- so the sleep here is the wait, and the block afterwards drains the + dispatcher callbacks it schedules. + """ + await asyncio.sleep(0.15) + await hass.async_block_till_done() + + +def _state(hass: HomeAssistant, entity_id: str): + """Return a live state, failing the test rather than typing around it.""" + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} has no state" + return state + + +async def test_captured_frame_reaches_entity_state(hass: HomeAssistant, entry, vendor_http, monkeypatch): + """A captured frame ends up as decoded numbers on the entity states.""" + ctx = await _setup(hass, entry, FakeTransport(), monkeypatch) + + _deliver(ctx["client"], FRAME_FULL) + await _settle(hass, ctx["client"]) + + from custom_components.lumentree.core.realtime_parser import parse_mqtt_payload + + expected = parse_mqtt_payload(FRAME_FULL) + assert expected, "the captured frame did not parse; fixture is stale" + + soc = _state(hass, ctx["entities"]["battery_soc"]) + assert soc.state not in ("unknown", "unavailable"), ( + f"battery_soc stayed {soc.state!r}; the frame never reached the entity" + ) + assert float(soc.state) == pytest.approx(float(expected["battery_soc"])) + + +async def test_every_parsed_key_has_an_entity(hass: HomeAssistant, entry, vendor_http, monkeypatch): + """Each value the parser produces is consumed by a real entity. + + This is the drift check: a new key in the parser with no matching entity + description, or a renamed one, is invisible to a per-module test and to a + user until a value silently never updates. + """ + ctx = await _setup(hass, entry, FakeTransport(), monkeypatch) + + from custom_components.lumentree.const import KEY_LAST_RAW_MQTT + from custom_components.lumentree.core.realtime_parser import parse_mqtt_payload + + parsed = parse_mqtt_payload(FRAME_FULL) + assert parsed + + # The client injects these two itself; they are not parser outputs. + client_injected = {"online_status", KEY_LAST_RAW_MQTT} + orphaned = set(parsed) - ctx["all_keys"] - client_injected + assert not orphaned, ( + f"parser produced keys with no entity: {sorted(orphaned)}. " + "Add a description or stop emitting the key." + ) + + +async def test_dispatcher_signal_matches_between_client_and_sensor(hass: HomeAssistant, entry, vendor_http, monkeypatch): + """The client's signal and the entities' subscription are one string. + + Both are built from ``SIGNAL_UPDATE_FORMAT`` in different modules. If either + format string drifts, every sensor silently stops updating while the + integration still looks healthy -- so pin the round trip. + """ + ctx = await _setup(hass, entry, FakeTransport(), monkeypatch) + + from homeassistant.helpers.dispatcher import async_dispatcher_send + + async_dispatcher_send(hass, ctx["client"]._signal_update, {"battery_soc": 77}) + await hass.async_block_till_done() + + soc = _state(hass, ctx["entities"]["battery_soc"]) + assert float(soc.state) == pytest.approx(77.0), ( + "entity ignored the client's dispatcher signal " + f"({ctx['client']._signal_update!r}); the two formats have drifted" + ) + + +async def test_malformed_frame_leaves_previous_state_intact(hass: HomeAssistant, entry, vendor_http, monkeypatch): + """Garbage on the topic must not blank out entity state.""" + ctx = await _setup(hass, entry, FakeTransport(), monkeypatch) + + _deliver(ctx["client"], FRAME_FULL) + await _settle(hass, ctx["client"]) + before = _state(hass, ctx["entities"]["battery_soc"]).state + assert before not in ("unknown", "unavailable") + + # Bad CRC: the parser rejects the frame, so nothing is dispatched. + _deliver(ctx["client"], BAD_CRC_FRAME) + await _settle(hass, ctx["client"]) + + assert _state(hass, ctx["entities"]["battery_soc"]).state == before + + +async def test_unexpected_topic_is_ignored(hass: HomeAssistant, entry, vendor_http, monkeypatch): + """A message on a topic the client does not own changes nothing.""" + ctx = await _setup(hass, entry, FakeTransport(), monkeypatch) + before = _state(hass, ctx["entities"]["battery_soc"]).state + + _deliver(ctx["client"], FRAME_FULL, topic="reportApp/SOMEONE_ELSE") + await _settle(hass, ctx["client"]) + + assert _state(hass, ctx["entities"]["battery_soc"]).state == before + + +async def test_frame_marks_the_device_online(hass: HomeAssistant, entry, vendor_http, monkeypatch): + """The first good frame flips online_status, which has no register of its own.""" + ctx = await _setup(hass, entry, FakeTransport(), monkeypatch) + assert "online_status" in ctx["entities"] + + before = _state(hass, ctx["entities"]["online_status"]).state + _deliver(ctx["client"], FRAME_FULL) + await _settle(hass, ctx["client"]) + + after = _state(hass, ctx["entities"]["online_status"]).state + assert after != before or after == "on", ( + f"online_status did not react to a frame (before={before!r}, after={after!r})" + ) From 3e06df787fd68de7c062ec3375ae9c6a845fd757 Mon Sep 17 00:00:00 2001 From: Vokupt Date: Sat, 12 Sep 2026 23:56:24 +0700 Subject: [PATCH 02/24] feat: fetch a day's statistics in one request via getAllDayData get_daily_stats made three concurrent HTTP calls (PV, battery, grid/load) where the vendor now offers one. /lesvr/getAllDayData returns every metric together and was measured to give the same values as the three legacy endpoints on the same day, so the combined path is preferred and the old three-call path is kept as a fallback -- a failure degrades speed, not function. Two representation differences from the legacy endpoints, both handled in _merge_all_day_payload rather than at the call site: bat charge series, unsigned batF discharge series, unsigned, and OMITTED entirely when the day had no discharge The legacy path used one signed series. Two unsigned series are combined into one signed series and padded rather than truncated: a short batF is the normal case, and truncating would silently drop the charge tail. When bat is present and batF is absent the discharge total is set to 0.0, not None -- the legacy endpoint returned an explicit zero there, and leaving None would surface as `unknown` instead of `0 kWh`, so the two sources would not actually be interchangeable. The per-metric series math (5-minute kWh, hourly kWh, sums) moved into shared builders (_build_pv_result / _build_grid_result / _build_load_result / _build_battery_result). Both paths describe the same six metrics with the same tableValue/tableValueInfo shape, and keeping one copy is what stops the two paths from quietly disagreeing after someone edits only one. Note the path: the APK contains "lesvr/v2/getAllDayData" because app 3.2.4 targets lesvrjm.suntcn.com, but against lesvr.suntcn.com the v2 path returns 998 (does not exist) and /lesvr/getAllDayData works. No host change and no sign scheme is needed. Ported onto the current main rather than cherry-picked: the original commit was written before the audit branch landed, and it also predates the credential masking in const.py, so it would have reverted both. --- const.py | 5 + core/api_client.py | 471 +++++++++++++++++++------------- tests/test_day_stats_mapping.py | 179 ++++++++++++ 3 files changed, 468 insertions(+), 187 deletions(-) create mode 100644 tests/test_day_stats_mapping.py diff --git a/const.py b/const.py index 8d9c285..f21638a 100644 --- a/const.py +++ b/const.py @@ -28,6 +28,11 @@ def get_timezone(hass: HomeAssistant): URL_GET_BAT_DAY_DATA: Final = "/lesvr/getBatDayData" URL_GET_YEAR_DATA: Final = "/lesvr/getYearData" URL_GET_MONTH_DATA: Final = "/lesvr/getMonthData" +# Single-call replacement for the three per-metric day endpoints above. +# Note the absence of a "v2/" prefix: the APK contains "lesvr/v2/getAllDayData" +# because app 3.2.4 targets a different host (lesvrjm.suntcn.com), but against +# lesvr.suntcn.com the v2 path returns 998 (does not exist) and this one works. +URL_GET_ALL_DAY_DATA: Final = "/lesvr/getAllDayData" DEFAULT_HEADERS: Final = { "versionCode": "1.6.3", diff --git a/core/api_client.py b/core/api_client.py index 3ccf09c..23ad827 100644 --- a/core/api_client.py +++ b/core/api_client.py @@ -16,6 +16,7 @@ BASE_URL, DEFAULT_HEADERS, URL_DEVICE_MANAGE, + URL_GET_ALL_DAY_DATA, URL_GET_BAT_DAY_DATA, URL_GET_MONTH_DATA, URL_GET_OTHER_DAY_DATA, @@ -98,6 +99,161 @@ def _sum(series: list[float]) -> float: # Keep full precision return sum(series) if series else 0.0 + # --------------------------- + # Per-metric builders + # + # Both the three legacy per-metric day endpoints and the combined + # getAllDayData endpoint describe the same six metrics with the same + # tableValue/tableValueInfo shape, so both paths go through these builders. + # Keeping one copy of the series math is what stops the two paths from + # quietly disagreeing after someone edits only one of them. + # --------------------------- + + @staticmethod + def _metric_total_kwh(metric: Any) -> float | None: + """Return a metric's daily total in kWh, or None when absent. + + The API reports daily totals in 0.1 kWh units. + """ + if not isinstance(metric, dict): + return None + val = metric.get("tableValue") + if val is None: + return None + try: + return float(val) / 10.0 + except (TypeError, ValueError): + return None + + @staticmethod + def _metric_series_w(metric: Any) -> list[float]: + """Return a metric's 5-minute series in W (empty when absent).""" + if not isinstance(metric, dict): + return [] + return LumentreeHttpApiClient._to_float_list(metric.get("tableValueInfo")) + + @classmethod + def _build_pv_result(cls, metric: Any) -> dict[str, Any]: + result: dict[str, Any] = {"pv_today": cls._metric_total_kwh(metric)} + series_w = cls._metric_series_w(metric) + if series_w: + series_kwh5 = cls._series_5min_kwh(series_w) + result.update({ + "pv_series_5min_w": series_w, + "pv_series_5min_kwh": series_kwh5, + "pv_series_hour_kwh": cls._series_hour_kwh(series_kwh5), + "pv_sum_kwh": cls._sum(series_kwh5), + }) + return result + + @classmethod + def _build_grid_result(cls, metric: Any) -> dict[str, Any]: + result: dict[str, Any] = {"grid_in_today": cls._metric_total_kwh(metric)} + series_w = cls._metric_series_w(metric) + if series_w: + g5 = cls._series_5min_kwh(series_w) + result.update({ + "grid_series_5min_w": series_w, + "grid_series_5min_kwh": g5, + "grid_series_hour_kwh": cls._series_hour_kwh(g5), + }) + return result + + @classmethod + def _build_load_result(cls, load_metric: Any, essential_metric: Any) -> dict[str, Any]: + """Build the household/essential/total-load metrics. + + The three are produced together because total load is the sum of the + other two -- both as a daily total and as a series -- so splitting them + across callers would only create a way for them to disagree. + """ + result: dict[str, Any] = {} + load_total = cls._metric_total_kwh(load_metric) + essential_total = cls._metric_total_kwh(essential_metric) + if load_total is not None: + result["load_today"] = load_total + if essential_total is not None: + result["essential_today"] = essential_total + + if load_total is not None or essential_total is not None: + result["total_load_today"] = float(load_total or 0.0) + float(essential_total or 0.0) + + load_series_w = cls._metric_series_w(load_metric) + essential_series_w = cls._metric_series_w(essential_metric) + + if load_series_w: + l5 = cls._series_5min_kwh(load_series_w) + result.update({ + "load_series_5min_w": load_series_w, + "load_series_5min_kwh": l5, + "load_series_hour_kwh": cls._series_hour_kwh(l5), + }) + if essential_series_w: + e5 = cls._series_5min_kwh(essential_series_w) + result.update({ + "essential_series_5min_w": essential_series_w, + "essential_series_5min_kwh": e5, + "essential_series_hour_kwh": cls._series_hour_kwh(e5), + }) + + if load_series_w and essential_series_w: + # The two series can differ in length; pad rather than truncate so a + # short series contributes zeros instead of silently dropping the + # tail of the longer one. + length = max(len(load_series_w), len(essential_series_w)) + load_padded = list(load_series_w) + [0.0] * (length - len(load_series_w)) + essential_padded = list(essential_series_w) + [0.0] * (length - len(essential_series_w)) + total_load_w = [float(a or 0.0) + float(b or 0.0) for a, b in zip(load_padded, essential_padded, strict=False)] + + load_kwh5 = result.get("load_series_5min_kwh", []) + essential_kwh5 = result.get("essential_series_5min_kwh", []) + kwh_length = max(len(load_kwh5), len(essential_kwh5)) + load_kwh_padded = list(load_kwh5) + [0.0] * (kwh_length - len(load_kwh5)) + essential_kwh_padded = list(essential_kwh5) + [0.0] * (kwh_length - len(essential_kwh5)) + total_load_kwh5 = [float(a or 0.0) + float(b or 0.0) for a, b in zip(load_kwh_padded, essential_kwh_padded, strict=False)] + + result.update({ + "total_load_series_5min_w": total_load_w, + "total_load_series_5min_kwh": total_load_kwh5, + "total_load_series_hour_kwh": cls._series_hour_kwh(total_load_kwh5), + }) + + if "total_load_today" not in result: + result["total_load_today"] = cls._sum(total_load_kwh5) + + return result + + @classmethod + def _build_battery_result( + cls, + series_positive_charge: list[float], + charge_today: float | None, + discharge_today: float | None, + ) -> dict[str, Any]: + """Build battery metrics from a series where positive means charge. + + The two day endpoints disagree on representation, so each converts to + this one convention first: + * getBatDayData ships a single *signed* series with positive meaning + discharge, which the caller negates before calling in. + * getAllDayData ships separate unsigned `bat` (charge) and `batF` + (discharge) series, which the caller subtracts. + Downstream consumers (entities/sensor.py) expect positive = charge. + """ + result: dict[str, Any] = { + "charge_today": charge_today, + "discharge_today": discharge_today, + } + if series_positive_charge: + charge_kwh5 = cls._series_5min_kwh([w if w > 0 else 0.0 for w in series_positive_charge]) + discharge_kwh5 = cls._series_5min_kwh([abs(w) if w < 0 else 0.0 for w in series_positive_charge]) + result.update({ + "battery_series_5min_w": series_positive_charge, + "battery_charge_series_hour_kwh": cls._series_hour_kwh(charge_kwh5), + "battery_discharge_series_hour_kwh": cls._series_hour_kwh(discharge_kwh5), + }) + return result + def set_token(self, token: str | None) -> None: """Set the authentication token. @@ -440,7 +596,13 @@ async def get_device_info(self, device_id: str) -> dict[str, Any]: return {"_error": f"Unexpected error: {exc}"} async def get_daily_stats(self, device_identifier: str, query_date: str) -> dict[str, Any]: - """Get daily statistics with concurrent API calls. + """Get daily statistics, preferring the single combined endpoint. + + Tries GET /lesvr/getAllDayData first: one request instead of three, and + one round of server-side work instead of three. Falls back to the three + per-metric legacy endpoints when that returns nothing usable -- they + were the only path for a long time and still answer identically, so a + failure of the combined endpoint degrades speed rather than function. Args: device_identifier: Device ID or serial number @@ -452,9 +614,19 @@ async def get_daily_stats(self, device_identifier: str, query_date: str) -> dict if _LOGGER.isEnabledFor(logging.DEBUG): _LOGGER.debug("Fetching daily stats for %s @ %s", device_identifier, query_date) + combined = await self.get_all_day_data(device_identifier, query_date) + if combined: + return combined + + _LOGGER.info( + "Combined day endpoint returned no data for %s @ %s; " + "falling back to the three per-metric endpoints", + device_identifier, query_date, + ) + base_params = {"deviceId": device_identifier, "queryDate": query_date} - # Call 3 APIs concurrently for 3x speed improvement + # Call 3 APIs concurrently pv_task = self._fetch_pv_data(base_params) bat_task = self._fetch_battery_data(base_params) other_task = self._fetch_other_data(base_params) @@ -465,6 +637,7 @@ async def get_daily_stats(self, device_identifier: str, query_date: str) -> dict # Merge results return self._merge_stats_results(results) + async def get_year_data(self, device_identifier: str, year: int) -> dict[str, Any]: """Get yearly statistics data (12 months aggregated). @@ -574,7 +747,7 @@ async def get_month_data(self, device_identifier: str, year: int, month: int) -> } async def _fetch_pv_data(self, base_params: dict[str, str]) -> dict[str, Any]: - """Fetch PV generation data. + """Fetch PV generation data from the per-metric legacy endpoint. Args: base_params: Base query parameters @@ -583,32 +756,8 @@ async def _fetch_pv_data(self, base_params: dict[str, str]) -> dict[str, Any]: PV data dictionary """ try: - resp = await self._request( - "GET", URL_GET_PV_DAY_DATA, params=base_params, requires_auth=True - ) - data = resp.get("data", {}) - pv_data = data.get("pv", {}) - - result: dict[str, Any] = {} - - val = pv_data.get("tableValue") - result["pv_today"] = float(val) / 10.0 if val is not None else None - - # Optional series (5‑minute W) - series_w = self._to_float_list(pv_data.get("tableValueInfo")) - if series_w: - series_kwh5 = self._series_5min_kwh(series_w) - series_hour = self._series_hour_kwh(series_kwh5) - result.update( - { - "pv_series_5min_w": series_w, - "pv_series_5min_kwh": series_kwh5, - "pv_series_hour_kwh": series_hour, - "pv_sum_kwh": self._sum(series_kwh5), - } - ) - - return result + resp = await self._request("GET", URL_GET_PV_DAY_DATA, params=base_params, requires_auth=True) + return self._build_pv_result((resp.get("data") or {}).get("pv")) except (ApiException, AuthException) as exc: _LOGGER.warning(f"Failed PV stats ({type(exc).__name__}): {exc}") return {"pv_today": None} @@ -617,7 +766,7 @@ async def _fetch_pv_data(self, base_params: dict[str, str]) -> dict[str, Any]: return {"pv_today": None} async def _fetch_battery_data(self, base_params: dict[str, str]) -> dict[str, Any]: - """Fetch battery charge/discharge data. + """Fetch battery charge/discharge data from the legacy endpoint. Args: base_params: Base query parameters @@ -626,51 +775,25 @@ async def _fetch_battery_data(self, base_params: dict[str, str]) -> dict[str, An Battery data dictionary """ try: - resp = await self._request( - "GET", URL_GET_BAT_DAY_DATA, params=base_params, requires_auth=True - ) - data = resp.get("data", {}) + resp = await self._request("GET", URL_GET_BAT_DAY_DATA, params=base_params, requires_auth=True) + data = resp.get("data") or {} bats_data = data.get("bats", []) - result: dict[str, Any] = {"charge_today": None, "discharge_today": None} - + charge_today: float | None = None + discharge_today: float | None = None if isinstance(bats_data, list): - if len(bats_data) > 0 and "tableValue" in bats_data[0]: - result["charge_today"] = float(bats_data[0]["tableValue"]) / 10.0 - if len(bats_data) > 1 and "tableValue" in bats_data[1]: - result["discharge_today"] = float(bats_data[1]["tableValue"]) / 10.0 - - # Signed power series → split charge/discharge - # NOTE: API actually returns REVERSED: positive = discharge, negative = charge - # This contradicts API_PROTOCOL.md but matches actual device behavior - # Positive (+) = Discharge (pin phát năng lượng) - # Negative (-) = Charge (pin nhận năng lượng) + if len(bats_data) > 0 and isinstance(bats_data[0], dict) and "tableValue" in bats_data[0]: + charge_today = float(bats_data[0]["tableValue"]) / 10.0 + if len(bats_data) > 1 and isinstance(bats_data[1], dict) and "tableValue" in bats_data[1]: + discharge_today = float(bats_data[1]["tableValue"]) / 10.0 + + # This endpoint's series is signed with positive meaning DISCHARGE + # (which contradicts the old API_PROTOCOL.md; the device is the + # authority). Negate so the shared builder sees positive = charge. series_w = self._to_float_list(data.get("tableValueInfo")) - if series_w: - # Invert signs: API positive = discharge, API negative = charge - # For processing: Charge = negative values (invert to positive for kWh), Discharge = positive values - # But keep original signed in battery_series_5min_w for chart (will be inverted in sensor) - inverted_series_w = [ - -w for w in series_w - ] # Invert: positive becomes negative (charge), negative becomes positive (discharge) - # Charge: was negative in API, now positive after inversion - charge_kwh5 = self._series_5min_kwh( - [w if w > 0 else 0.0 for w in inverted_series_w] - ) - # Discharge: was positive in API, now negative after inversion - discharge_kwh5 = self._series_5min_kwh( - [abs(w) if w < 0 else 0.0 for w in inverted_series_w] - ) - # Store inverted series for sensor processing (sensor expects: positive = charge, negative = discharge) - result.update( - { - "battery_series_5min_w": inverted_series_w, - "battery_charge_series_hour_kwh": self._series_hour_kwh(charge_kwh5), - "battery_discharge_series_hour_kwh": self._series_hour_kwh(discharge_kwh5), - } - ) - - return result + return self._build_battery_result( + [-w for w in series_w], charge_today, discharge_today + ) except (ApiException, AuthException) as exc: _LOGGER.warning(f"Failed battery stats ({type(exc).__name__}): {exc}") return {"charge_today": None, "discharge_today": None} @@ -679,7 +802,7 @@ async def _fetch_battery_data(self, base_params: dict[str, str]) -> dict[str, An return {"charge_today": None, "discharge_today": None} async def _fetch_other_data(self, base_params: dict[str, str]) -> dict[str, Any]: - """Fetch grid and load data. + """Fetch grid and load data from the legacy endpoint. Args: base_params: Base query parameters @@ -688,129 +811,103 @@ async def _fetch_other_data(self, base_params: dict[str, str]) -> dict[str, Any] Grid and load data dictionary """ try: - resp = await self._request( - "GET", URL_GET_OTHER_DAY_DATA, params=base_params, requires_auth=True - ) - data = resp.get("data", {}) - - result: dict[str, Any] = {"grid_in_today": None, "load_today": None} - - # Grid - grid_data = data.get("grid", {}) - grid_val = grid_data.get("tableValue") - if grid_val is not None: - result["grid_in_today"] = float(grid_val) / 10.0 - grid_series_w = self._to_float_list(grid_data.get("tableValueInfo")) - if grid_series_w: - g5 = self._series_5min_kwh(grid_series_w) - result.update( - { - "grid_series_5min_w": grid_series_w, - "grid_series_5min_kwh": g5, - "grid_series_hour_kwh": self._series_hour_kwh(g5), - } - ) + resp = await self._request("GET", URL_GET_OTHER_DAY_DATA, params=base_params, requires_auth=True) + data = resp.get("data") or {} + result = self._build_grid_result(data.get("grid")) + result.update(self._build_load_result(data.get("homeload"), data.get("essentialLoad"))) + return result + except (ApiException, AuthException) as exc: + _LOGGER.warning(f"Failed other stats ({type(exc).__name__}): {exc}") + return {"grid_in_today": None, "load_today": None} + except Exception: + _LOGGER.exception("Unexpected other stats error") + return {"grid_in_today": None, "load_today": None} - # Load and Essential (read together, process together) - load_data = data.get("homeload", {}) - essential_data = data.get("essentialLoad", {}) - - # Extract daily totals - load_val = load_data.get("tableValue") if load_data else None - e_val = essential_data.get("tableValue") if isinstance(essential_data, dict) else None - - if load_val is not None: - result["load_today"] = float(load_val) / 10.0 - if e_val is not None: - result["essential_today"] = float(e_val) / 10.0 - - # Calculate total_load_today immediately when we have both values - load_value = result.get("load_today") - essential_value = result.get("essential_today") - if load_value is not None or essential_value is not None: - total_load_value = float(load_value or 0.0) + float(essential_value or 0.0) - if total_load_value > 0 or (load_value is not None and essential_value is not None): - result["total_load_today"] = total_load_value - - # Extract and process series data in parallel - load_series_w = ( - self._to_float_list(load_data.get("tableValueInfo")) if load_data else [] - ) - e_series_w = ( - self._to_float_list(essential_data.get("tableValueInfo")) - if isinstance(essential_data, dict) - else [] - ) + @classmethod + def _merge_all_day_payload(cls, payload: Any) -> dict[str, Any]: + """Turn a getAllDayData `data` object into the standard stats dict. - # Process load series - if load_series_w: - l5 = self._series_5min_kwh(load_series_w) - result.update( - { - "load_series_5min_w": load_series_w, - "load_series_5min_kwh": l5, - "load_series_hour_kwh": self._series_hour_kwh(l5), - } - ) + Split out from the request method so the mapping can be tested against + captured payloads without a live session -- the sign convention and the + absent-`batF` case are the parts worth pinning down, and neither needs + the network to exercise. + """ + if not isinstance(payload, dict) or not payload: + return {} + + data = payload + result = cls._build_pv_result(data.get("pv")) + result.update(cls._build_grid_result(data.get("grid"))) + result.update(cls._build_load_result(data.get("homeload"), data.get("essentialLoad"))) + + charge_series = cls._metric_series_w(data.get("bat")) + discharge_series = cls._metric_series_w(data.get("batF")) + # Two unsigned series -> one signed series, positive meaning charge. + # Pad rather than truncate: a missing or short discharge series is the + # normal case (no discharge omits batF entirely rather than zero-filling + # it), and truncating would silently drop the charge series' tail. + length = max(len(charge_series), len(discharge_series)) + charge_padded = list(charge_series) + [0.0] * (length - len(charge_series)) + discharge_padded = list(discharge_series) + [0.0] * (length - len(discharge_series)) + signed_series = [c - d for c, d in zip(charge_padded, discharge_padded, strict=False)] + + charge_today = cls._metric_total_kwh(data.get("bat")) + discharge_today = cls._metric_total_kwh(data.get("batF")) + # The combined endpoint omits batF when the day had no discharge, where + # the legacy endpoint it replaces returned an explicit zero. Left as + # None that difference would leak to callers as `unknown` instead of + # `0 kWh`, so the two sources would not actually be interchangeable. + # A battery that reported charge is a battery that was present, so a + # missing batF alongside a present bat means "no discharge", not + # "no data". + if charge_today is not None and discharge_today is None: + discharge_today = 0.0 + + result.update(cls._build_battery_result(signed_series, charge_today, discharge_today)) + return result + + async def get_all_day_data(self, device_identifier: str, query_date: str) -> dict[str, Any]: + """Fetch a whole day's statistics in one request. + + Replaces three concurrent calls (PV, battery, grid/load) with one. The + response carries every metric under `data`, plus a `titleParams` array + describing them. + + Two representation differences from the legacy endpoints, both handled + in `_merge_all_day_payload` rather than at the call site: + + * Battery arrives as two *unsigned* series -- `bat` (charge) and `batF` + (discharge) -- where the legacy endpoint used one signed series. + * `batF` is **omitted entirely** when the day had no discharge, rather + than being present and zero. - # Process essential series - if e_series_w: - e5 = self._series_5min_kwh(e_series_w) - result.update( - { - "essential_series_5min_w": e_series_w, - "essential_series_5min_kwh": e5, - "essential_series_hour_kwh": self._series_hour_kwh(e5), - } - ) + Args: + device_identifier: Device ID or serial number + query_date: Date in YYYY-MM-DD format - # Calculate total_load series immediately when we have both series - load_w = result.get("load_series_5min_w", []) - essential_w = result.get("essential_series_5min_w", []) - if load_w and essential_w: - # Handle different lengths by padding with zeros - max_len = max(len(load_w), len(essential_w)) - load_w_padded = list(load_w) + [0.0] * (max_len - len(load_w)) - essential_w_padded = list(essential_w) + [0.0] * (max_len - len(essential_w)) - total_load_w = [ - float(lo or 0) + float(es or 0) - for lo, es in zip(load_w_padded, essential_w_padded, strict=False) - ] - - load_5min_kwh = result.get("load_series_5min_kwh", []) - essential_5min_kwh = result.get("essential_series_5min_kwh", []) - # Handle different lengths for kWh series too - max_len_kwh = max(len(load_5min_kwh), len(essential_5min_kwh)) - load_5min_kwh_padded = list(load_5min_kwh) + [0.0] * ( - max_len_kwh - len(load_5min_kwh) - ) - essential_5min_kwh_padded = list(essential_5min_kwh) + [0.0] * ( - max_len_kwh - len(essential_5min_kwh) - ) - total_load_5min_kwh = [ - float(lo or 0) + float(es or 0) - for lo, es in zip(load_5min_kwh_padded, essential_5min_kwh_padded, strict=False) - ] - - result.update( - { - "total_load_series_5min_w": total_load_w, - "total_load_series_5min_kwh": total_load_5min_kwh, - "total_load_series_hour_kwh": self._series_hour_kwh(total_load_5min_kwh), - } - ) + Returns: + Dictionary with the same keys the legacy three-call path produces, + so callers cannot tell which endpoint served them. + """ + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug("Fetching all-day data for %s @ %s", device_identifier, query_date) - # If we have series but no daily total yet, calculate from series sum - if "total_load_today" not in result and total_load_5min_kwh: - result["total_load_today"] = self._sum(total_load_5min_kwh) + try: + resp = await self._request( + "GET", + URL_GET_ALL_DAY_DATA, + params={"deviceId": device_identifier, "queryDate": query_date}, + requires_auth=True, + ) + return self._merge_all_day_payload(resp.get("data")) - return result except (ApiException, AuthException) as exc: - _LOGGER.warning(f"Failed other stats ({type(exc).__name__}): {exc}") - return {"grid_in_today": None, "load_today": None} + _LOGGER.warning(f"Failed all-day stats ({type(exc).__name__}): {exc}") + return {} except Exception: - _LOGGER.exception("Unexpected other stats error") - return {"grid_in_today": None, "load_today": None} + _LOGGER.exception("Unexpected all-day stats error") + return {} + def _merge_stats_results(self, results: Iterable[Any]) -> dict[str, Any]: """Merge results from concurrent API calls. diff --git a/tests/test_day_stats_mapping.py b/tests/test_day_stats_mapping.py new file mode 100644 index 0000000..6d247e4 --- /dev/null +++ b/tests/test_day_stats_mapping.py @@ -0,0 +1,179 @@ +"""Tests for the day-statistics mapping in core/api_client.py. + +The integration reads a day's PV/battery/grid/load figures from one of two +sources: the combined ``getAllDayData`` endpoint, or the three per-metric +endpoints it supersedes. Both funnel through the same builders, and the whole +point of the builders is that a caller cannot tell which source served it. + +The two sources genuinely disagree on two things, and both are silent failures +if mapped wrong: + +* Battery. ``getBatDayData`` gives one *signed* series where positive means + discharge; ``getAllDayData`` gives two *unsigned* series, ``bat`` (charge) and + ``batF`` (discharge). Getting the sign backwards reports a discharging + battery as a charging one, with no error anywhere. +* ``batF`` absence. With no discharge, the combined endpoint omits ``batF`` + entirely rather than returning zeros. Code that indexes it raises. + +These tests use captured payloads from the real device, so a change in the +server's shape shows up as a test failure rather than as wrong kWh. +""" + +from __future__ import annotations + +import pytest + +# Shape captured from GET /lesvr/getAllDayData on a real device (abridged). +# Two things to preserve when editing: bat/batF are separate unsigned series, +# and batF is missing rather than zero when the day had no discharge. +ALL_DAY_WITH_DISCHARGE = { + "pv": {"tableValue": 60, "tableValueInfo": [0, 0, 120, 240]}, + "grid": {"tableValue": 116, "tableValueInfo": [485, 485, 0, 400]}, + "homeload": {"tableValue": 170, "tableValueInfo": [469, 469, 0, 425]}, + "essentialLoad": {"tableValue": 0, "tableValueInfo": [0, 0, 0, 0]}, + "bat": {"tableValue": 30, "tableValueInfo": [0, 500, 0, 0]}, + "batF": {"tableValue": 12, "tableValueInfo": [0, 0, 300, 0]}, +} + +# The common case for this device: no battery at all, so batF is absent. +ALL_DAY_NO_DISCHARGE = { + "pv": {"tableValue": 60, "tableValueInfo": [0, 0, 120, 240]}, + "grid": {"tableValue": 116, "tableValueInfo": [485, 485, 0, 400]}, + "homeload": {"tableValue": 170, "tableValueInfo": [469, 469, 0, 425]}, + "essentialLoad": {"tableValue": 0, "tableValueInfo": [0, 0, 0, 0]}, + "bat": {"tableValue": 0, "tableValueInfo": [0, 0, 0, 0]}, +} + + +class TestMetricHelpers: + def test_total_is_converted_from_tenths_of_a_kwh(self, lumentree_api_client) -> None: + assert lumentree_api_client.LumentreeHttpApiClient._metric_total_kwh( + {"tableValue": 170} + ) == 17.0 + + @pytest.mark.parametrize("value", [None, "not-a-number", {}]) + def test_unusable_totals_come_back_as_none(self, lumentree_api_client, value) -> None: + """A missing or malformed total must be None, never a fake 0.0. + + None propagates to the sensor as unknown; 0.0 would render as a real + reading of zero kWh. + """ + if value == {}: + assert lumentree_api_client.LumentreeHttpApiClient._metric_total_kwh(value) is None + else: + assert lumentree_api_client.LumentreeHttpApiClient._metric_total_kwh( + {"tableValue": value} + ) is None + + def test_missing_metric_yields_no_series(self, lumentree_api_client) -> None: + assert lumentree_api_client.LumentreeHttpApiClient._metric_series_w(None) == [] + + +class TestBatterySignConvention: + def test_positive_charge_series_splits_into_charge_and_discharge( + self, lumentree_api_client + ) -> None: + """Positive values are charge, negative are discharge. + + This is the convention entities/sensor.py reads. If the builder + inverted these, the charge and discharge sensors would swap. + """ + built = lumentree_api_client.LumentreeHttpApiClient._build_battery_result( + [500.0, -300.0, 0.0], 30.0, 12.0 + ) + # 500 W over a 5-minute step, positive => charge + assert built["battery_charge_series_hour_kwh"][0] == pytest.approx(500 * (5 / 60) / 1000) + # -300 W in step 1 => discharge, reported as a positive magnitude + assert built["battery_discharge_series_hour_kwh"][0] == pytest.approx(300 * (5 / 60) / 1000) + + def test_a_five_minute_step_feeds_only_one_side(self, lumentree_api_client) -> None: + """At 5-minute resolution a step is charge or discharge, never both. + + The hourly rollup below deliberately can contain both -- see the note + there -- so the exclusive property belongs at the 5-minute level. + """ + built = lumentree_api_client.LumentreeHttpApiClient._build_battery_result( + [100.0, -100.0], None, None + ) + charge_5min = built["battery_charge_series_hour_kwh"] + discharge_5min = built["battery_discharge_series_hour_kwh"] + # Two steps land in hour 0; exactly one contributes charge, one discharge. + assert charge_5min[0] > 0 + assert discharge_5min[0] > 0 + # No step adds to both totals: the magnitudes are the two inputs, not doubled. + assert charge_5min[0] + discharge_5min[0] == pytest.approx( + (100 + 100) * (5 / 60) / 1000 + ) + + def test_hourly_rollup_may_contain_both_charge_and_discharge(self, lumentree_api_client) -> None: + """A single hour can legitimately show both, and the totalling must not hide it. + + 12 five-minute steps fold into one hour. A battery that charges for part + of an hour and discharges for the rest produces a non-zero entry in both + hourly series -- asserting they are mutually exclusive would be wrong. + """ + built = lumentree_api_client.LumentreeHttpApiClient._build_battery_result( + [100.0, -100.0], None, None + ) + assert built["battery_charge_series_hour_kwh"][0] > 0 + assert built["battery_discharge_series_hour_kwh"][0] > 0 + + +class TestAllDayDataMapping: + def _merged(self, api_client, payload): + """Run a payload through the same merge the client uses.""" + return api_client.LumentreeHttpApiClient._merge_all_day_payload(payload) + + def test_totals_match_the_legacy_per_metric_endpoints(self, lumentree_api_client) -> None: + """The combined endpoint must report the same totals, not merely similar ones. + + Values checked against a live comparison between the two paths. + """ + merged = self._merged(lumentree_api_client, ALL_DAY_WITH_DISCHARGE) + assert merged["pv_today"] == 6.0 + assert merged["grid_in_today"] == 11.6 + assert merged["load_today"] == 17.0 + assert merged["essential_today"] == 0.0 + assert merged["total_load_today"] == 17.0 + assert merged["charge_today"] == 3.0 + assert merged["discharge_today"] == 1.2 + + def test_missing_batf_does_not_raise(self, lumentree_api_client) -> None: + """No discharge means batF is absent, not zero. + + Indexing it would raise KeyError on every quiet day -- which is most + days for a device without a battery. Discharge must instead read as 0. + """ + merged = self._merged(lumentree_api_client, ALL_DAY_NO_DISCHARGE) + assert merged["discharge_today"] == 0.0 + assert merged["charge_today"] == 0.0 + + def test_absent_batf_does_not_truncate_the_charge_series(self, lumentree_api_client) -> None: + """The charge series keeps its full length when discharge is absent.""" + merged = self._merged(lumentree_api_client, ALL_DAY_NO_DISCHARGE) + assert len(merged["battery_series_5min_w"]) == 4 + + def test_battery_series_is_signed_charge_minus_discharge(self, lumentree_api_client) -> None: + """Two unsigned series become one signed series, positive = charge.""" + merged = self._merged(lumentree_api_client, ALL_DAY_WITH_DISCHARGE) + # bat=[0,500,0,0], batF=[0,0,300,0] => [0, +500, -300, 0] + assert merged["battery_series_5min_w"] == [0.0, 500.0, -300.0, 0.0] + + def test_empty_payload_does_not_raise(self, lumentree_api_client) -> None: + """A response with no metrics at all yields no data rather than an error.""" + assert self._merged(lumentree_api_client, {}) == {} + + +class TestPayloadShapeGuards: + """Malformed responses must degrade, not crash the coordinator.""" + + @pytest.mark.parametrize( + "payload", + [ + {"pv": None, "grid": "nonsense"}, + {"bat": {"tableValueInfo": "not-a-list"}}, + {"homeload": [], "essentialLoad": {"tableValue": "abc"}}, + ], + ) + def test_malformed_metrics_do_not_raise(self, lumentree_api_client, payload) -> None: + lumentree_api_client.LumentreeHttpApiClient._merge_all_day_payload(payload) From fb0a46309766a4ae7ce3d74e531bfa897a45a40a Mon Sep 17 00:00:00 2001 From: Vokupt Date: Sat, 12 Sep 2026 23:58:57 +0700 Subject: [PATCH 03/24] docs: close the getErrorLog question with a measured answer The discovery doc left /manage/lesvr/getErrorLog open as the one candidate for fault/alarm history, with an unanswered question about probing the lehtapi host. It has now been probed, once, read-only, with an empty body, and the answer is no. POST https://lehtapi.suntcn.com/manage/lesvr/getErrorLog returns returnValue 1000 ("login required") -- not 998, so the route is real. It is still unusable from here for two independent reasons: * Auth is a different mechanism. The admin console logs in via POST /security/login and keeps a session cookie (withCredentials: true in web_app.js's axios config). The app token from shareDevices is valid only as an Authorization header on lesvr.suntcn.com; there is no path that turns it into a console cookie. * There is no account to log in with. /manage/ is the dealer/distributor area, and this repo does not hold -- and should not hold -- vendor admin credentials. Corroborating evidence from the app side: the 38 lesvr/* strings in libapp.so include no error, fault or history endpoint at all. getErrorLog exists only in the vendor's web bundle, so even the official app cannot read fault history. This is out of scope for a cloud_polling integration. Also carries the Task B probe records the doc links to (getAllDayData and the day-endpoint comparison), which were extracted alongside. All six probe files were scanned before committing: no JWT, no live token, no uid / nickname / phone, no IP -- matching the doc's own redaction rule. The doc was merged onto the current main rather than cherry-picked, and the salt and AES key values it used to spell out are masked here as they already were on main. --- docs/api/API_ENDPOINTS_DISCOVERED.md | 190 +- docs/probe_compare_day_endpoints.json | 61 + docs/probe_results_errorlog.json | 16 + docs/probe_results_getalldaydata.json | 3030 ++++++++++++++++++ docs/probe_results_tier1_authed.json | 3873 +++++++++++++++++++++++ docs/probe_results_tier1_corrected.json | 621 ++++ 6 files changed, 7755 insertions(+), 36 deletions(-) create mode 100644 docs/probe_compare_day_endpoints.json create mode 100644 docs/probe_results_errorlog.json create mode 100644 docs/probe_results_getalldaydata.json create mode 100644 docs/probe_results_tier1_authed.json create mode 100644 docs/probe_results_tier1_corrected.json diff --git a/docs/api/API_ENDPOINTS_DISCOVERED.md b/docs/api/API_ENDPOINTS_DISCOVERED.md index f06b95b..9e33b24 100644 --- a/docs/api/API_ENDPOINTS_DISCOVERED.md +++ b/docs/api/API_ENDPOINTS_DISCOVERED.md @@ -1,13 +1,49 @@ -# API Discovery — LightEarth 3.2.4 vs. lumentreeHA 5.1.3 +# API Discovery — LightEarth 3.2.4 vs. lumentreeHA 5.2.0 -> **Trạng thái:** khảo sát (survey). Chưa implement sensor nào. +> **Trạng thái:** khảo sát (survey) + đính chính lần hai. Chưa implement sensor nào. > **Nguồn:** `blutter` trên `libapp.so` (Dart 3.9.2, `light_earth` 3.2.4), `web_app.js` (admin console), EMQX exports, và probe read-only có kiểm soát. -> **Ngày:** 2026-09-12 +> **Ngày:** 2026-09-12 (đính chính lần hai cùng ngày, xem mục 0) Mục đích của tài liệu này là chốt **bản đồ API thật** trước khi mở rộng integration, và đính chính một số giả định sai đang tồn tại trong repo. --- +## 0. ĐÍNH CHÍNH LẦN HAI — probe đầu tiên gửi sai tên header auth + +**Toàn bộ kết quả probe trong bản đầu của tài liệu này là không hợp lệ.** Lý do: + +`tools/probe_tier1_readonly.py` gửi token trong header tên `token`: + +```python +session.headers["token"] = token # SAI +``` + +Nhưng [core/api_client.py](../../core/api_client.py) — code đang chạy thật — gửi `Authorization`: + +```python +headers["Authorization"] = self._token +``` + +Vì header sai tên nên **không request nào thực sự được xác thực**. Những gì bản đầu ghi là `returnValue: 2` (dịch là *"服务器繁忙"* = "server bận") thực chất là **phản hồi của một caller chưa đăng nhập**. Bản đầu đã dựng hẳn một hàng trong bảng mã lỗi để giải thích mã `2`, và kết luận nhiều endpoint Tier-1 "không dùng được" — tất cả đều là hệ quả của lỗi này. + +Bằng chứng đối chứng: chạy lại **cùng endpoint, cùng tham số, cùng ngày**, chỉ đổi tên header: + +| Endpoint | Header `token` (sai) | Header `Authorization` (đúng) | +|----------|---------------------|------------------------------| +| `getMonthData` | `rv=2` | **`rv=1`**, `data` có `bat/homeload/pv/grid/essentialLoad/batF` | +| `getYearData` | `rv=2` | **`rv=1`**, cùng shape | +| `getHistoryYearData` | `rv=2` | **`rv=1`**, có thêm `firstYear` | +| `getDevice` | `rv=203` | **`rv=1`**, `data.devices` | +| `getUserDevice` | `rv=203` | **`rv=1`**, `data.notices/oldData/devices` | +| `userDeviceList` | `rv=203` | **`rv=1`**, `data.devices/errorNum/onlineNum/offlineNum` | +| `deviceInfo` | `rv=1` | `rv=1` (không đổi — endpoint này không cần auth) | + +Kết quả hợp lệ nằm ở [`docs/probe_results_tier1_authed.json`](probe_results_tier1_authed.json). Lần chạy đầu tiên với header đúng — 8 probe, thu hẹp phạm vi để xác nhận nguyên nhân trước khi quét rộng — nằm ở [`docs/probe_results_tier1_corrected.json`](probe_results_tier1_corrected.json). File kết quả cũ [`docs/probe_results_tier1.json`](probe_results_tier1.json) được **giữ lại nhưng đã bị vô hiệu hoá** — nó là bằng chứng cho lỗi này, không phải dữ liệu để trích dẫn. + +**Bài học phương pháp:** một probe trả `2`/`203` hàng loạt trên những endpoint mà integration gọi thành công mỗi ngày là dấu hiệu probe sai, không phải server hỏng. Khi mọi thứ đều fail, nghi ngờ harness trước. + +--- + ## 1. Đính chính quan trọng Ba điều dưới đây trái với giả định đang lưu hành trong repo và trong các lần khảo sát trước. Cần sửa nhận thức trước khi làm bất cứ điều gì khác. @@ -22,24 +58,39 @@ Body thật của 998 là: Dịch: *"trang bạn truy cập không tồn tại"*. Đây là **catch-all 404**. -Bằng chứng đối chiếu từ probe (mã thật ≠ 998): +Bằng chứng đối chiếu từ probe **có xác thực đúng**: | Mã | Nghĩa thật | Ví dụ đã gặp | |----|-----------|--------------| | `1` | Thành công | `/lesvr/deviceInfo` → `{"masterStatus": 0}` | -| `2` | Server bận, thử lại sau | `/lesvr/getMonthData` (POST) | -| `3` | Sai HTTP method | `/lesvr/checkUpdate` (cần GET) | -| `203` | Thiếu/sai quyền | `/lesvr/getUserDevice`, `/lesvr/getUserInfo` | -| `998` | **Không tồn tại** | `/lesvr/userLogin` | +| `2` | Endpoint thật nhưng request không được xử lý — thấy ở `getDT`, `getVersionInfo`, `checkUpdate` | `/lesvr/getDT` | +| `3` | Sai HTTP method | `/lesvr/checkUpdate` (POST → cần GET) | +| `203` | Sai quyền — tài khoản guest không đủ | `/lesvr/getUserInfo` | +| `210` | Thiếu quan hệ master/slave | `/lesvr/getMasterSlaveInfo` (device này không có slave) | +| `998` | **Không tồn tại** | `/lesvr/v2/getAllDayData`, `/lesvr/userLogin` | | `20001` | Thiếu tham số | `shareDevices` thiếu `expiredTime` | -**Hệ quả:** toàn bộ danh sách "endpoint 998" từ các lần khảo sát trước (`login`, `profile`, `plantList`, `station`, `myDevices`, `account`, …) là **đường dẫn đoán mò không tồn tại**. Phải loại bỏ, không tốn công "authenticate". +**Cảnh báo về mã `2`:** bản đầu của tài liệu này giải thích `2` là "server bận, thử lại sau" và dùng nó để loại nhiều endpoint. Giải thích đó **sai** — nó được rút ra từ probe chưa xác thực (xem mục 0). `2` vẫn chưa được giải mã đầy đủ; chỉ biết nó không phải "thành công" và không phải "không tồn tại". + +**Hệ quả:** danh sách "endpoint 998" từ các lần khảo sát trước vẫn đúng ở kết luận (998 = không tồn tại), nhưng **danh sách endpoint bị loại vì `2`/`203` phải được xét lại** — nhiều khả năng chúng thật, chỉ chưa được gọi đúng. + +### 1.2. `getServerTime` là legacy; `v2/getAllDayData` **không** phải đường đi đúng + +Hai kết luận của bản đầu, cả hai đều cần chỉnh: + +**a) `getServerTime` có 0 lần xuất hiện trong `libapp.so` 3.2.4 — nhưng nó vẫn là endpoint dùng được.** Integration gọi nó mỗi lần lấy token và chạy tốt. Đây là endpoint legacy còn sống, không phải endpoint chết. + +**b) `v2/getAllDayData` trả `998` (không tồn tại) trên host hiện tại — kể cả khi đã xác thực đúng.** Endpoint gộp thật nằm ở **`/lesvr/getAllDayData`, KHÔNG có tiền tố `v2/`**: + +``` +/lesvr/v2/getAllDayData → rv=998 (không tồn tại) +/lesvr/getAllDayData → rv=1 ✅ data: bat, homeload, grid, pv, essentialLoad, titleParams +``` -### 1.2. Bốn endpoint integration đang dùng là **alias legacy** +Tiền tố `v2/` xuất hiện trong `libapp.so` vì app 3.2.4 trỏ vào host **khác** (`lesvrjm.suntcn.com`). Trên host integration đang dùng, đường dẫn không có `v2/`. Bản đầu kết luận "muốn dùng endpoint gộp thì phải chuyển host" — **kết luận đó sai**; không cần chuyển host. -Quét `libapp.so` 3.2.4: `getServerTime`, `getPVDayData`, `getBatDayData`, `getOtherDayData` có **0 lần xuất hiện**. App hiện tại dùng `lesvr/v2/getAllDayData`. +Các endpoint ngày khác (`getPVDayData`, `getBatDayData`, `getOtherDayData`) vẫn chạy, và **cho cùng giá trị** với `getAllDayData` trên cùng một ngày — xem mục 7. -Nghĩa là 4 endpoint đó vẫn chạy phía server (và integration vẫn hoạt động), nhưng đã bị app bỏ. **Không được coi chúng là "endpoint hiện hành" khi viết tài liệu mới.** ### 1.3. `iot_class: cloud_polling` ⇒ chỉ Tier 1 @@ -58,7 +109,8 @@ Integration khai báo `cloud_polling`. Do đó **mọi endpoint ghi (Tier 3) và Bằng chứng: string duy nhất trong `asm/light_earth/communication/server_api.dart` là `"https://lesvrjm.suntcn.com/"`, dùng làm base URL cho mọi call. -**Phát hiện then chốt:** `lesvr/v2/getAllDayData` trả `998` (không tồn tại) trên host cũ, và chỉ sống trên host mới. Đây là lý do kỹ thuật khiến integration hiện tại không thể dùng endpoint gộp này. +**Đính chính (bản đầu viết sai ở đây):** bản đầu tuyên bố *"`lesvr/v2/getAllDayData` chỉ sống trên host mới"* và suy ra integration phải chuyển host. **Sai.** Endpoint gộp tồn tại trên **host cũ** ở đường dẫn `/lesvr/getAllDayData` — **không có `v2/`**. Tiền tố `v2/` chỉ đúng trên host `lesvrjm`. Integration **không cần đổi host và không cần cơ chế `sign`**. + --- @@ -89,25 +141,32 @@ Cộng thêm `_decrypt` dùng một hằng số AES (`svr_aes`) và một key đ ## 4. Endpoint mới — phân tầng -### Tier 1 — Ứng viên implement (read-only, đã probe) +### Tier 1 — Ứng viên implement (read-only, đã probe **có xác thực đúng**) -| Endpoint | Method | Probe | Payload thật | -|----------|--------|-------|--------------| -| `lesvr/v2/getAllDayData` | GET | ❌ `998` trên host cũ | `deviceId`, `queryDate` (`yyyy-MM-dd`) | -| `lesvr/getHistoryYearData` | GET | ❌ `2` | `deviceId`, `queryYear` | -| `lesvr/getMonthData` | GET | ❌ `2` | `deviceId`, `year`, `month` (đã dùng) | -| `lesvr/getYearData` | GET | ❌ `2` | `deviceId`, `year` (đã dùng) | +| Endpoint | Method | Kết quả | Payload thật | +|----------|--------|---------|--------------| +| `lesvr/getAllDayData` | GET | ✅ `1` | **Gộp cả ngày** — `pv`, `bat`, `batF`, `homeload`, `essentialLoad`, `grid`, `titleParams` | +| `lesvr/getHistoryYearData` | GET | ✅ `1` | Thêm `firstYear` so với `getYearData` | +| `lesvr/getMonthData` | GET | ✅ `1` | `deviceId`, `year`, `month` | +| `lesvr/getYearData` | GET | ✅ `1` | `deviceId`, `year` | | `lesvr/getOnlineStatus` | GET | ✅ `1` | `{"onlineStatus": 0}` | | `lesvr/deviceInfo` | GET | ✅ `1` | `{"masterStatus": 0}` | +| `lesvr/getDevice` | GET | ✅ `1` | `data.devices` | +| `lesvr/getUserDevice` | GET | ✅ `1` | `data.notices/oldData/devices` | +| `lesvr/userDeviceList` | GET | ✅ `1` | `data.devices/errorNum/onlineNum/offlineNum` | | `lesvr/getVersion` | GET | ✅ `1` | 4 nhóm firmware: `controller`, `controller2`, `screen`, `liquidCrystal` | | `app/getAppParam` | GET | ✅ `1` | `maxDiffData`, `userKeepTime`, `snParam`, `deviceTypeImg`, … | | `device/getTypeList` | GET | ✅ `1` | `deviceTypes[]`: `SUNT-4.0kW-HP`, `SUNT-6.0kW-HT`, … | | `lesvr/deviceTypeSettingList` | GET | ✅ `1` | Cần `blueTooth` + `version` | -| `lesvr/getMasterSlaveInfo` | GET | ❌ `2` | `deviceId` | -| `lesvr/getDevice` | GET | ❌ `203` | `deviceId` | -| `lesvr/getUserInfo` / `getUserDevice` / `userDeviceList` | GET | ❌ `203` | quyền `userType` | +| `lesvr/getMasterSlaveInfo` | GET | ⚠️ `210` | Device này không có slave — chưa kết luận được | +| `lesvr/getDT` | GET | ❌ `2` | Cần tham số đúng, chưa rõ | +| `lesvr/getVersionInfo` | GET | ❌ `2` | Chưa rõ | +| `lesvr/checkUpdate` | GET | ❌ `2` | Từng trả `3` (sai method) trên POST → endpoint thật | +| `lesvr/v2/getAllDayData` | GET | ❌ `998` | **Sai path** — đúng là `/lesvr/getAllDayData` | +| `lesvr/getUserInfo` | GET | ❌ `203` | Tài khoản guest không đủ quyền (không phải lỗi endpoint) | + +**Giá trị cao nhất:** `/lesvr/getAllDayData` — gộp PV + battery + load + grid vào **một call duy nhất**, thay cho 3 call riêng (`getPVDayData` + `getBatDayData` + `getOtherDayData`) mà integration đang dùng. Đã đo là cho **cùng giá trị** — xem mục 7.1. -**Giá trị cao nhất:** `v2/getAllDayData` — gộp PV + battery + load + grid vào **một call duy nhất**, thay cho 3 call riêng (`getPVDayData` + `getBatDayData` + `getOtherDayData`) mà integration đang dùng. ### Tier 2 — Cần đánh giá thêm @@ -195,30 +254,89 @@ Client ID của app có dạng `app_{epochMillis}_{random}`. | `/manage/lesvr/queryOnlineSnByArea` | Tra cứu theo khu vực | | `/manage/lesvr/getMasterSlaveInfo` | Master/slave | -**Chưa probe** — cần xác minh host `lehtapi.suntcn.com` có chấp nhận token kiểu shareDevices hay không. +**Đã probe (1 request, read-only).** `POST https://lehtapi.suntcn.com/manage/lesvr/getErrorLog` +với body rỗng trả: + +```json +{"returnValue": 1000, "msg": "需要登录"} +``` + +Ghi chép thô: [`docs/probe_results_errorlog.json`](probe_results_errorlog.json). + +`1000` = *"cần đăng nhập"* — **khác hẳn `998`**, nên endpoint có thật. Nhưng nó không +dùng được với credential mà integration đang có, vì hai lý do độc lập: + +1. **Auth khác cơ chế.** Console này đăng nhập qua `POST /security/login` và giữ + **cookie session** (`withCredentials: true` trong axios config của `web_app.js`). + App token từ `shareDevices` chỉ hợp lệ với header `Authorization` trên + `lesvr.suntcn.com`; không có đường nào để BIẾN nó thành cookie của console. +2. **Không có tài khoản.** Đây là khu `/manage/` của **dealer/distributor**. Repo + không có — và không nên có — credential admin của vendor. + +Bằng chứng cùng chiều từ phía app: quét `libapp.so` được **38** chuỗi `lesvr/*`, và +**không có endpoint lỗi/cảnh báo/lịch sử nào**. `getErrorLog` chỉ tồn tại trong bundle +web của vendor. Nghĩa là ngay cả app chính thức cũng không đọc được log lỗi — tính +năng này không thuộc phạm vi một integration `cloud_polling`. + +**Kết luận: đóng. Không implement.** Giữ lại ghi chép này để lần sau không ai probe +lại `/manage/*` với cùng kỳ vọng. --- ## 7. Kết quả probe (đã sanitize) -File: [`docs/probe_results_tier1.json`](probe_results_tier1.json) +### 7.1. Lần chạy hợp lệ — header `Authorization` đúng + +File: [`docs/probe_results_tier1_authed.json`](probe_results_tier1_authed.json) -- 19 endpoint Tier-1, GET-only, 1 request/endpoint, timeout 10s, tuần tự, không retry, sleep 0.5s. -- **7 endpoint trả `returnValue: 1`** (thành công): `getOnlineStatus`, `deviceInfo`, `getDeviceQuickList`, `getVersion`, `app/getAppParam`, `device/getTypeList`, `deviceTypeSettingList`. -- `lesvrjm.suntcn.com` trả `2` (*server bận*) cho `getServerTime` — **chưa lấy được token trên host mới**. +- 19 endpoint, GET-only, 1 request/endpoint, timeout 10s, tuần tự, không retry, sleep 2s, cap 20. +- **13 endpoint trả `returnValue: 1`**: `getAllDayData`, `getHistoryYearData`, `getMonthData`, `getYearData`, `getOnlineStatus`, `deviceInfo`, `getDevice`, `getUserDevice`, `userDeviceList`, `getVersion`, `app/getAppParam`, `device/getTypeList`, `deviceTypeSettingList`. +- **6 endpoint không trả `1`:** `checkUpdate` (`2`), `v2/getAllDayData` (`998`, sai path), `getDT` (`2`), `getMasterSlaveInfo` (`210`), `getUserInfo` (`203`), `getVersionInfo` (`2`). - Đã redact `token`, `uid`, `nickname`, `phone` → ``. -**Hạn chế đã gặp:** sau ~30 request, host cũ bắt đầu ngắt kết nối (`RemoteDisconnected`), kể cả với `getServerTime`. Đã dừng probe theo luật rate-limit trong plan, không retry. +### 7.2. Đối chiếu `getAllDayData` với 3 endpoint legacy + +File: [`docs/probe_compare_day_endpoints.json`](probe_compare_day_endpoints.json) + +Cùng thiết bị, cùng `queryDate`, so từng chỉ số: + +| Chỉ số | Legacy | `getAllDayData` | Khớp | +|--------|--------|-----------------|------| +| `pv.tableValue` | 60 (`getPVDayData`) | 60 | ✅ | +| `grid.tableValue` | 116 (`getOtherDayData`) | 116 | ✅ | +| `homeload.tableValue` | 170 (`getOtherDayData`) | 170 | ✅ | +| `essentialLoad.tableValue` | 0 (`getOtherDayData`) | 0 | ✅ | +| `bat` (charge) | 0 (`getBatDayData:bats[0]`) | 0 | ✅ | +| `batF` (discharge) | 0 (`getBatDayData:bats[1]`) | `None` | ⚠️ | + +**Hai điểm cần lưu khi implement:** + +1. **`batF` vắng mặt khi không có phát điện.** Legacy trả `bats[1].tableValue = 0`; `getAllDayData` **bỏ hẳn key `batF`** khỏi `data` (và `titleParams` có `batF` với `tableValueInfo` rỗng). Code đọc `data["batF"]` phải chịu được key vắng, không được `KeyError`. +2. **Pin chưa được chứng minh.** Thiết bị test báo không có pin (`battery_type = No Battery`), nên cả hai đường đều ra 0 và phép so sánh **không kiểm chứng được** phần pin. Mapping PV/grid/load thì đã kiểm chứng thật. + +### 7.3. Kết quả CŨ — KHÔNG dùng (header sai tên) + +File: [`docs/probe_results_tier1.json`](probe_results_tier1.json) — giữ lại làm bằng chứng cho lỗi ở mục 0, **không trích dẫn số liệu**. + +- 19 endpoint, nhưng gửi token trong header tên `token` ⇒ không request nào được xác thực. +- Bản đầu ghi nhận "7 endpoint thành công" — số đó tình cờ đúng với **những endpoint không cần auth**, nên vẫn khớp. Còn mọi kết luận dựa trên `2`/`203` đều sai. + +### 7.4. Rate limit của host + +Sau ~30 request nhanh, host cũ bắt đầu ngắt kết nối (`RemoteDisconnected`) kể cả với `getServerTime`. Lần chạy hợp lệ dùng `sleep(2)` và không gặp hiện tượng này. **Đây là rate limit thật, cần tôn trọng khi implement polling.** + --- -## 8. Câu hỏi mở (cần anh quyết) +## 8. Câu hỏi mở (cập nhật sau đính chính lần hai) + +1. ~~**Host nào là hướng phát triển?**~~ **ĐÃ TRẢ LỜI — không cần chuyển host.** `/lesvr/getAllDayData` (không `v2/`) chạy trên host hiện tại `lesvr.suntcn.com` và trả dữ liệu gộp đầy đủ. Kết luận "phải chuyển sang `lesvrjm`" ở bản đầu là hệ quả của probe sai (mục 0). +2. ~~**Có đáng theo đuổi `sign` scheme không?**~~ **KHÔNG CẦN.** Cơ chế `sign` thuộc host `lesvrjm`, mà host đó không cần thiết để lấy `getAllDayData`. Bỏ qua hoàn toàn. +3. **`device/sendMsg` fallback HTTP** — vẫn là **không** đưa vào doc như đường dùng được; ghi rõ nó là Tier 3 (đường gửi lệnh xuống thiết bị). +4. ~~**`getErrorLog`** (`lehtapi`) — vẫn là tính năng đáng giá (lịch sử lỗi). Cần probe host `lehtapi` riêng; xem task D.~~ **ĐÃ TRẢ LỜI — đóng, không implement.** Endpoint có thật nhưng trả `1000` (cần đăng nhập) và auth của nó là cookie session của console dealer, không phải app token; app chính thức cũng không có endpoint lỗi nào. Chi tiết + bằng chứng ở mục 6. +5. **Key đi kèm `svr_aes`** — không còn cấp thiết: nó gắn với `_decrypt` của host `lesvrjm`, mà hướng đó đã bị loại ở (2). Hạ ưu tiên xuống "chỉ đọc nếu sau này cần host mới". Giá trị thật không chép vào repo. +6. **`getDT`, `getVersionInfo`, `checkUpdate`** trả `2` — chưa rõ nghĩa mã `2` và chưa rõ tham số đúng. Không cấp thiết vì `getVersion` đã trả đủ 4 nhóm firmware. -1. **Host nào là hướng phát triển?** `v2/getAllDayData` chỉ sống trên `lesvrjm.suntcn.com`. Nếu muốn dùng endpoint gộp, integration phải chuyển host — kèm rủi ro vì phải làm chủ cơ chế `sign` (mục 3) chưa xác minh. -2. **Có đáng theo đuổi `sign` scheme không?** Nếu chỉ cần `getAllDayData`, chi phí là: implement MD5 sign + nonce + timestamp, và chấp nhận rằng công thức chưa được xác minh live. -3. **`device/sendMsg` fallback HTTP** — có nên đưa vào doc như một "cửa hậu" tiềm năng không? Em đề xuất **không**, và ghi rõ nó là Tier 3. -4. **`getErrorLog`** (`lehtapi`) — đây là tính năng mới thật sự đáng giá (lịch sử lỗi). Có cần em probe host `lehtapi` riêng không? -5. **Key đi kèm `svr_aes`** — em chưa chứng minh được nó là AES key hay app key; nó nằm cạnh hằng số AES trong `_decrypt`. Giá trị thật không chép vào repo. Cần đọc sâu hơn nếu quyết định làm (`encrypt` package), nhưng chỉ khi hướng "host mới" được duyệt. --- diff --git a/docs/probe_compare_day_endpoints.json b/docs/probe_compare_day_endpoints.json new file mode 100644 index 0000000..72289ea --- /dev/null +++ b/docs/probe_compare_day_endpoints.json @@ -0,0 +1,61 @@ +{ + "date": "2026-09-11", + "device": "H240909079", + "compared": [ + { + "metric": "pv.tableValue", + "legacy": 60, + "new": 60, + "legacy_source": "getPVDayData:data.pv.tableValue", + "new_source": "getAllDayData:data.pv.tableValue", + "match": true + }, + { + "metric": "grid.tableValue", + "legacy": 116, + "new": 116, + "legacy_source": "getOtherDayData:data.grid.tableValue", + "new_source": "getAllDayData:data.grid.tableValue", + "match": true + }, + { + "metric": "homeload.tableValue", + "legacy": 170, + "new": 170, + "legacy_source": "getOtherDayData:data.homeload.tableValue", + "new_source": "getAllDayData:data.homeload.tableValue", + "match": true + }, + { + "metric": "essentialLoad.tableValue", + "legacy": 0, + "new": 0, + "legacy_source": "getOtherDayData:data.essentialLoad.tableValue", + "new_source": "getAllDayData:data.essentialLoad.tableValue", + "match": true + }, + { + "metric": "bat.charge", + "legacy": 0, + "new": 0, + "legacy_source": "getBatDayData:data.bats[0].tableValue", + "new_source": "getAllDayData:data.bat.tableValue", + "match": true + }, + { + "metric": "bat.discharge", + "legacy": 0, + "new": null, + "legacy_source": "getBatDayData:data.bats[1].tableValue", + "new_source": "getAllDayData:data.batF.tableValue", + "match": false + } + ], + "all_match": false, + "battery_caveat": "device reports no battery; bat/batF totals are 0 on both paths, so this comparison cannot confirm battery mapping", + "series_lengths": { + "legacy_bat_signed": 288, + "new_bat_charge": 288, + "new_batF_discharge": 0 + } +} \ No newline at end of file diff --git a/docs/probe_results_errorlog.json b/docs/probe_results_errorlog.json new file mode 100644 index 0000000..f46b0f8 --- /dev/null +++ b/docs/probe_results_errorlog.json @@ -0,0 +1,16 @@ +{ + "tool_version": "probe_errorlog/1.0", + "probes": [ + { + "endpoint": "/manage/lesvr/getErrorLog", + "method": "POST", + "http_status": 200, + "content_type": "application/json;charset=UTF-8", + "set_cookie": false, + "raw_response": "{\"returnValue\":1000,\"msg\":\"需要登录\"}" + } + ], + "purpose": "Task D: determine whether /manage/lesvr/getErrorLog is usable by this integration.", + "safety": "Single POST with an EMPTY body, timeout 10, no retry, no credentials sent. The route is named 'get' and the empty body contains no verb, so nothing can be written.", + "conclusion": "Route exists (rv=1000 'login required', not 998). Not usable: the admin console authenticates with a session cookie from POST /security/login, while this integration only holds an app token for the Authorization header on lesvr.suntcn.com. The vendor app itself exposes no error/fault endpoint at all (38 lesvr/* strings scanned)." +} \ No newline at end of file diff --git a/docs/probe_results_getalldaydata.json b/docs/probe_results_getalldaydata.json new file mode 100644 index 0000000..dc909d2 --- /dev/null +++ b/docs/probe_results_getalldaydata.json @@ -0,0 +1,3030 @@ +{ + "tool_version": "probe_getalldaydata/1.0", + "timestamp": "2026-09-12T03:14:40.588348+00:00", + "note": "earlier 998 on this path was measured with a misnamed auth header", + "safety": "GET-only, 1 req/endpoint, no retry", + "results": [ + { + "host": "http://lesvr.suntcn.com", + "label": "path_v2", + "endpoint": "/lesvr/v2/getAllDayData", + "params": { + "deviceId": "H240909079", + "queryDate": "2026-09-11" + }, + "http_status": 200, + "return_value": 998, + "shape": "NoneType None", + "raw_response": { + "returnValue": 998, + "msg": "您访问对页面不存在" + }, + "success": false + }, + { + "host": "http://lesvr.suntcn.com", + "label": "path_v1", + "endpoint": "/lesvr/getAllDayData", + "params": { + "deviceId": "H240909079", + "queryDate": "2026-09-11" + }, + "http_status": 200, + "return_value": 1, + "shape": "dict keys=['bat', 'homeload', 'grid', 'pv', 'titleParams', 'essentialLoad']", + "raw_response": { + "returnValue": 1, + "data": { + "bat": { + "tableKey": "bat", + "tableName": "电池充电电量", + "tableValue": 0, + "tableValueInfo": [ + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 0, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 1, + 1, + 1, + 0, + 1, + 1, + 1, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 0, + 0, + 0, + 1, + 1, + 1, + 1, + 1, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 1, + 1, + 1, + 1, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 0, + 0, + 0, + 1, + 1, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 1, + 1, + 0, + 0, + 0, + 1, + 1, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + }, + "homeload": { + "tableKey": "homeload", + "tableName": "家庭负载耗电量", + "tableValue": 170, + "tableValueInfo": [ + 469, + 469, + 469, + 0, + 425, + 425, + 430, + 430, + 426, + 426, + 424, + 424, + 424, + 0, + 469, + 469, + 471, + 471, + 461, + 461, + 440, + 440, + 426, + 426, + 421, + 421, + 417, + 417, + 418, + 418, + 410, + 410, + 413, + 413, + 404, + 404, + 406, + 406, + 398, + 398, + 393, + 393, + 402, + 402, + 399, + 399, + 395, + 395, + 392, + 392, + 396, + 396, + 395, + 395, + 397, + 397, + 363, + 363, + 360, + 360, + 360, + 360, + 356, + 356, + 359, + 359, + 367, + 367, + 356, + 356, + 383, + 383, + 383, + 383, + 349, + 349, + 436, + 436, + 515, + 515, + 458, + 458, + 534, + 534, + 375, + 375, + 311, + 311, + 204, + 204, + 257, + 257, + 247, + 247, + 251, + 251, + 251, + 0, + 0, + 0, + 391, + 391, + 388, + 388, + 387, + 387, + 436, + 436, + 452, + 452, + 478, + 478, + 458, + 458, + 2360, + 2360, + 541, + 541, + 541, + 0, + 0, + 0, + 1569, + 1569, + 1613, + 1613, + 1613, + 0, + 2444, + 2444, + 1415, + 1415, + 1317, + 1317, + 1317, + 0, + 1343, + 1343, + 2837, + 2837, + 1986, + 1986, + 1331, + 1331, + 1293, + 1293, + 1303, + 1303, + 1303, + 0, + 695, + 695, + 568, + 568, + 558, + 558, + 450, + 450, + 1260, + 1260, + 2096, + 2096, + 1414, + 1414, + 1255, + 1255, + 1336, + 1336, + 1335, + 1335, + 1406, + 1406, + 1406, + 0, + 1399, + 1399, + 1438, + 1438, + 1295, + 1295, + 1304, + 1304, + 578, + 578, + 578, + 0, + 570, + 570, + 490, + 490, + 490, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1430, + 1430, + 516, + 516, + 460, + 460, + 521, + 521, + 521, + 0, + 0, + 0, + 461, + 461, + 473, + 473, + 537, + 537, + 618, + 618, + 584, + 584, + 2567, + 2567, + 489, + 489, + 546, + 546, + 1388, + 1388, + 1388, + 0, + 503, + 503, + 463, + 463, + 463, + 0, + 716, + 716, + 712, + 712, + 716, + 716, + 704, + 704, + 647, + 647, + 647, + 0, + 0, + 0, + 719, + 719, + 702, + 702, + 702, + 0, + 729, + 729, + 716, + 716, + 734, + 734, + 734, + 0, + 0, + 0, + 508, + 508, + 472, + 472, + 449, + 449, + 412, + 412, + 403, + 403, + 411, + 411, + 408, + 408, + 402, + 402, + 409, + 409, + 428, + 428, + 414, + 414 + ] + }, + "grid": { + "tableKey": "grid", + "tableName": "电网输入电量", + "tableValue": 116, + "tableValueInfo": [ + 485, + 485, + 485, + 0, + 441, + 441, + 446, + 446, + 442, + 442, + 440, + 440, + 440, + 0, + 485, + 485, + 486, + 486, + 477, + 477, + 456, + 456, + 442, + 442, + 437, + 437, + 433, + 433, + 434, + 434, + 426, + 426, + 429, + 429, + 420, + 420, + 422, + 422, + 414, + 414, + 409, + 409, + 418, + 418, + 415, + 415, + 411, + 411, + 408, + 408, + 412, + 412, + 411, + 411, + 413, + 413, + 379, + 379, + 376, + 376, + 376, + 376, + 372, + 372, + 375, + 375, + 383, + 383, + 370, + 370, + 392, + 392, + 381, + 381, + 334, + 334, + 409, + 409, + 471, + 471, + 410, + 410, + 455, + 455, + 281, + 281, + 213, + 213, + 78, + 78, + 123, + 123, + 110, + 110, + 87, + 87, + 87, + 0, + 0, + 0, + 177, + 177, + 150, + 150, + 146, + 146, + 158, + 158, + 195, + 195, + 322, + 322, + 145, + 145, + 1589, + 1589, + 23, + 23, + 23, + 0, + 0, + 0, + 618, + 618, + 663, + 663, + 663, + 0, + 1412, + 1412, + 382, + 382, + 578, + 578, + 578, + 0, + 243, + 243, + 1709, + 1709, + 1230, + 1230, + 160, + 160, + 175, + 175, + 620, + 620, + 620, + 0, + 290, + 290, + 11, + 11, + 9, + 9, + 9, + 9, + 140, + 140, + 1245, + 1245, + 326, + 326, + 911, + 911, + 931, + 931, + 650, + 650, + 668, + 668, + 668, + 0, + 435, + 435, + 1139, + 1139, + 846, + 846, + 719, + 719, + 9, + 9, + 9, + 0, + 267, + 267, + 234, + 234, + 234, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1105, + 1105, + 314, + 314, + 324, + 324, + 405, + 405, + 405, + 0, + 0, + 0, + 431, + 431, + 462, + 462, + 543, + 543, + 631, + 631, + 600, + 600, + 2587, + 2587, + 506, + 506, + 563, + 563, + 1405, + 1405, + 1405, + 0, + 519, + 519, + 480, + 480, + 480, + 0, + 734, + 734, + 729, + 729, + 733, + 733, + 722, + 722, + 665, + 665, + 665, + 0, + 0, + 0, + 736, + 736, + 719, + 719, + 719, + 0, + 746, + 746, + 733, + 733, + 751, + 751, + 751, + 0, + 0, + 0, + 524, + 524, + 488, + 488, + 466, + 466, + 428, + 428, + 419, + 419, + 427, + 427, + 424, + 424, + 418, + 418, + 424, + 424, + 443, + 443, + 429, + 429 + ] + }, + "pv": { + "tableKey": "pv", + "tableName": "PV发电量", + "tableValue": 60, + "tableValueInfo": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 4, + 4, + 14, + 14, + 27, + 27, + 40, + 40, + 55, + 55, + 62, + 62, + 92, + 92, + 103, + 103, + 110, + 110, + 138, + 138, + 146, + 146, + 150, + 150, + 177, + 177, + 177, + 0, + 0, + 0, + 229, + 229, + 250, + 250, + 255, + 255, + 290, + 290, + 277, + 277, + 176, + 176, + 325, + 325, + 809, + 809, + 543, + 543, + 543, + 0, + 0, + 0, + 1007, + 1007, + 1009, + 1009, + 1009, + 0, + 1097, + 1097, + 1100, + 1100, + 785, + 785, + 785, + 0, + 1171, + 1171, + 1201, + 1201, + 808, + 808, + 1245, + 1245, + 1189, + 1189, + 732, + 732, + 732, + 0, + 428, + 428, + 583, + 583, + 582, + 582, + 465, + 465, + 1188, + 1188, + 907, + 907, + 1160, + 1160, + 370, + 370, + 427, + 427, + 733, + 733, + 784, + 784, + 784, + 0, + 1023, + 1023, + 322, + 322, + 474, + 474, + 624, + 624, + 600, + 600, + 600, + 0, + 330, + 330, + 270, + 270, + 270, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 351, + 351, + 217, + 217, + 147, + 147, + 124, + 124, + 124, + 0, + 0, + 0, + 42, + 42, + 24, + 24, + 4, + 4, + 4, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + "titleParams": [ + { + "tableKey": "pv", + "tableName": "PV发电量", + "tableValue": 60, + "tableValueInfo": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 4, + 4, + 14, + 14, + 27, + 27, + 40, + 40, + 55, + 55, + 62, + 62, + 92, + 92, + 103, + 103, + 110, + 110, + 138, + 138, + 146, + 146, + 150, + 150, + 177, + 177, + 177, + 0, + 0, + 0, + 229, + 229, + 250, + 250, + 255, + 255, + 290, + 290, + 277, + 277, + 176, + 176, + 325, + 325, + 809, + 809, + 543, + 543, + 543, + 0, + 0, + 0, + 1007, + 1007, + 1009, + 1009, + 1009, + 0, + 1097, + 1097, + 1100, + 1100, + 785, + 785, + 785, + 0, + 1171, + 1171, + 1201, + 1201, + 808, + 808, + 1245, + 1245, + 1189, + 1189, + 732, + 732, + 732, + 0, + 428, + 428, + 583, + 583, + 582, + 582, + 465, + 465, + 1188, + 1188, + 907, + 907, + 1160, + 1160, + 370, + 370, + 427, + 427, + 733, + 733, + 784, + 784, + 784, + 0, + 1023, + 1023, + 322, + 322, + 474, + 474, + 624, + 624, + 600, + 600, + 600, + 0, + 330, + 330, + 270, + 270, + 270, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 351, + 351, + 217, + 217, + 147, + 147, + 124, + 124, + 124, + 0, + 0, + 0, + 42, + 42, + 24, + 24, + 4, + 4, + 4, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + { + "tableKey": "essentialLoad", + "tableName": "不断电负载耗电量", + "tableValue": 0, + "tableValueInfo": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + { + "tableKey": "grid", + "tableName": "电网输入电量", + "tableValue": 116, + "tableValueInfo": [ + 485, + 485, + 485, + 0, + 441, + 441, + 446, + 446, + 442, + 442, + 440, + 440, + 440, + 0, + 485, + 485, + 486, + 486, + 477, + 477, + 456, + 456, + 442, + 442, + 437, + 437, + 433, + 433, + 434, + 434, + 426, + 426, + 429, + 429, + 420, + 420, + 422, + 422, + 414, + 414, + 409, + 409, + 418, + 418, + 415, + 415, + 411, + 411, + 408, + 408, + 412, + 412, + 411, + 411, + 413, + 413, + 379, + 379, + 376, + 376, + 376, + 376, + 372, + 372, + 375, + 375, + 383, + 383, + 370, + 370, + 392, + 392, + 381, + 381, + 334, + 334, + 409, + 409, + 471, + 471, + 410, + 410, + 455, + 455, + 281, + 281, + 213, + 213, + 78, + 78, + 123, + 123, + 110, + 110, + 87, + 87, + 87, + 0, + 0, + 0, + 177, + 177, + 150, + 150, + 146, + 146, + 158, + 158, + 195, + 195, + 322, + 322, + 145, + 145, + 1589, + 1589, + 23, + 23, + 23, + 0, + 0, + 0, + 618, + 618, + 663, + 663, + 663, + 0, + 1412, + 1412, + 382, + 382, + 578, + 578, + 578, + 0, + 243, + 243, + 1709, + 1709, + 1230, + 1230, + 160, + 160, + 175, + 175, + 620, + 620, + 620, + 0, + 290, + 290, + 11, + 11, + 9, + 9, + 9, + 9, + 140, + 140, + 1245, + 1245, + 326, + 326, + 911, + 911, + 931, + 931, + 650, + 650, + 668, + 668, + 668, + 0, + 435, + 435, + 1139, + 1139, + 846, + 846, + 719, + 719, + 9, + 9, + 9, + 0, + 267, + 267, + 234, + 234, + 234, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1105, + 1105, + 314, + 314, + 324, + 324, + 405, + 405, + 405, + 0, + 0, + 0, + 431, + 431, + 462, + 462, + 543, + 543, + 631, + 631, + 600, + 600, + 2587, + 2587, + 506, + 506, + 563, + 563, + 1405, + 1405, + 1405, + 0, + 519, + 519, + 480, + 480, + 480, + 0, + 734, + 734, + 729, + 729, + 733, + 733, + 722, + 722, + 665, + 665, + 665, + 0, + 0, + 0, + 736, + 736, + 719, + 719, + 719, + 0, + 746, + 746, + 733, + 733, + 751, + 751, + 751, + 0, + 0, + 0, + 524, + 524, + 488, + 488, + 466, + 466, + 428, + 428, + 419, + 419, + 427, + 427, + 424, + 424, + 418, + 418, + 424, + 424, + 443, + 443, + 429, + 429 + ] + }, + { + "tableKey": "homeload", + "tableName": "家庭负载耗电量", + "tableValue": 170, + "tableValueInfo": [ + 469, + 469, + 469, + 0, + 425, + 425, + 430, + 430, + 426, + 426, + 424, + 424, + 424, + 0, + 469, + 469, + 471, + 471, + 461, + 461, + 440, + 440, + 426, + 426, + 421, + 421, + 417, + 417, + 418, + 418, + 410, + 410, + 413, + 413, + 404, + 404, + 406, + 406, + 398, + 398, + 393, + 393, + 402, + 402, + 399, + 399, + 395, + 395, + 392, + 392, + 396, + 396, + 395, + 395, + 397, + 397, + 363, + 363, + 360, + 360, + 360, + 360, + 356, + 356, + 359, + 359, + 367, + 367, + 356, + 356, + 383, + 383, + 383, + 383, + 349, + 349, + 436, + 436, + 515, + 515, + 458, + 458, + 534, + 534, + 375, + 375, + 311, + 311, + 204, + 204, + 257, + 257, + 247, + 247, + 251, + 251, + 251, + 0, + 0, + 0, + 391, + 391, + 388, + 388, + 387, + 387, + 436, + 436, + 452, + 452, + 478, + 478, + 458, + 458, + 2360, + 2360, + 541, + 541, + 541, + 0, + 0, + 0, + 1569, + 1569, + 1613, + 1613, + 1613, + 0, + 2444, + 2444, + 1415, + 1415, + 1317, + 1317, + 1317, + 0, + 1343, + 1343, + 2837, + 2837, + 1986, + 1986, + 1331, + 1331, + 1293, + 1293, + 1303, + 1303, + 1303, + 0, + 695, + 695, + 568, + 568, + 558, + 558, + 450, + 450, + 1260, + 1260, + 2096, + 2096, + 1414, + 1414, + 1255, + 1255, + 1336, + 1336, + 1335, + 1335, + 1406, + 1406, + 1406, + 0, + 1399, + 1399, + 1438, + 1438, + 1295, + 1295, + 1304, + 1304, + 578, + 578, + 578, + 0, + 570, + 570, + 490, + 490, + 490, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1430, + 1430, + 516, + 516, + 460, + 460, + 521, + 521, + 521, + 0, + 0, + 0, + 461, + 461, + 473, + 473, + 537, + 537, + 618, + 618, + 584, + 584, + 2567, + 2567, + 489, + 489, + 546, + 546, + 1388, + 1388, + 1388, + 0, + 503, + 503, + 463, + 463, + 463, + 0, + 716, + 716, + 712, + 712, + 716, + 716, + 704, + 704, + 647, + 647, + 647, + 0, + 0, + 0, + 719, + 719, + 702, + 702, + 702, + 0, + 729, + 729, + 716, + 716, + 734, + 734, + 734, + 0, + 0, + 0, + 508, + 508, + 472, + 472, + 449, + 449, + 412, + 412, + 403, + 403, + 411, + 411, + 408, + 408, + 402, + 402, + 409, + 409, + 428, + 428, + 414, + 414 + ] + }, + { + "tableKey": "bat", + "tableName": "电池充电电量", + "tableValue": 0, + "tableValueInfo": [ + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 0, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 1, + 1, + 1, + 0, + 1, + 1, + 1, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 0, + 0, + 0, + 1, + 1, + 1, + 1, + 1, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 1, + 1, + 1, + 1, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 0, + 0, + 0, + 1, + 1, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 1, + 1, + 0, + 0, + 0, + 1, + 1, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + }, + { + "tableKey": "batF", + "tableName": "电池放电电量", + "tableValue": 0, + "tableValueInfo": null + } + ], + "essentialLoad": { + "tableKey": "essentialLoad", + "tableName": "不断电负载耗电量", + "tableValue": 0, + "tableValueInfo": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + } + } + }, + "success": true + }, + { + "host": "http://lesvr.suntcn.com", + "label": "day_v2_date", + "endpoint": "/v2/lesvr/getAllDayData", + "params": { + "deviceId": "H240909079", + "queryDate": "2026-09-11" + }, + "http_status": 200, + "return_value": 998, + "shape": "NoneType None", + "raw_response": { + "returnValue": 998, + "msg": "您访问对页面不存在" + }, + "success": false + }, + { + "host": "https://lesvrjm.suntcn.com", + "label": "auth", + "raw_response": { + "stage": "getServerTime", + "response": { + "returnValue": 2, + "msg": "服务器繁忙,请稍后重试" + } + }, + "success": false + } + ] +} \ No newline at end of file diff --git a/docs/probe_results_tier1_authed.json b/docs/probe_results_tier1_authed.json new file mode 100644 index 0000000..6c170e1 --- /dev/null +++ b/docs/probe_results_tier1_authed.json @@ -0,0 +1,3873 @@ +{ + "tool_version": "probe_tier1_authed/1.0", + "timestamp": "2026-09-12T03:15:19.573684+00:00", + "supersedes": "probe_results_tier1.json (token sent in a header named 'token')", + "safety": "GET-only, 1 req/endpoint, no retry, cap 20", + "probes": [ + { + "label": "day_all", + "method": "GET", + "endpoint": "/lesvr/getAllDayData", + "params": { + "deviceId": "H240909079", + "queryDate": "2026-09-11" + }, + "http_status": 200, + "return_value": 1, + "shape": "dict keys=['bat', 'homeload', 'grid', 'pv', 'titleParams', 'essentialLoad']", + "raw_response": { + "returnValue": 1, + "data": { + "bat": { + "tableKey": "bat", + "tableName": "电池充电电量", + "tableValue": 0, + "tableValueInfo": [ + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 0, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 1, + 1, + 1, + 0, + 1, + 1, + 1, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 0, + 0, + 0, + 1, + 1, + 1, + 1, + 1, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 1, + 1, + 1, + 1, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 0, + 0, + 0, + 1, + 1, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 1, + 1, + 0, + 0, + 0, + 1, + 1, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + }, + "homeload": { + "tableKey": "homeload", + "tableName": "家庭负载耗电量", + "tableValue": 170, + "tableValueInfo": [ + 469, + 469, + 469, + 0, + 425, + 425, + 430, + 430, + 426, + 426, + 424, + 424, + 424, + 0, + 469, + 469, + 471, + 471, + 461, + 461, + 440, + 440, + 426, + 426, + 421, + 421, + 417, + 417, + 418, + 418, + 410, + 410, + 413, + 413, + 404, + 404, + 406, + 406, + 398, + 398, + 393, + 393, + 402, + 402, + 399, + 399, + 395, + 395, + 392, + 392, + 396, + 396, + 395, + 395, + 397, + 397, + 363, + 363, + 360, + 360, + 360, + 360, + 356, + 356, + 359, + 359, + 367, + 367, + 356, + 356, + 383, + 383, + 383, + 383, + 349, + 349, + 436, + 436, + 515, + 515, + 458, + 458, + 534, + 534, + 375, + 375, + 311, + 311, + 204, + 204, + 257, + 257, + 247, + 247, + 251, + 251, + 251, + 0, + 0, + 0, + 391, + 391, + 388, + 388, + 387, + 387, + 436, + 436, + 452, + 452, + 478, + 478, + 458, + 458, + 2360, + 2360, + 541, + 541, + 541, + 0, + 0, + 0, + 1569, + 1569, + 1613, + 1613, + 1613, + 0, + 2444, + 2444, + 1415, + 1415, + 1317, + 1317, + 1317, + 0, + 1343, + 1343, + 2837, + 2837, + 1986, + 1986, + 1331, + 1331, + 1293, + 1293, + 1303, + 1303, + 1303, + 0, + 695, + 695, + 568, + 568, + 558, + 558, + 450, + 450, + 1260, + 1260, + 2096, + 2096, + 1414, + 1414, + 1255, + 1255, + 1336, + 1336, + 1335, + 1335, + 1406, + 1406, + 1406, + 0, + 1399, + 1399, + 1438, + 1438, + 1295, + 1295, + 1304, + 1304, + 578, + 578, + 578, + 0, + 570, + 570, + 490, + 490, + 490, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1430, + 1430, + 516, + 516, + 460, + 460, + 521, + 521, + 521, + 0, + 0, + 0, + 461, + 461, + 473, + 473, + 537, + 537, + 618, + 618, + 584, + 584, + 2567, + 2567, + 489, + 489, + 546, + 546, + 1388, + 1388, + 1388, + 0, + 503, + 503, + 463, + 463, + 463, + 0, + 716, + 716, + 712, + 712, + 716, + 716, + 704, + 704, + 647, + 647, + 647, + 0, + 0, + 0, + 719, + 719, + 702, + 702, + 702, + 0, + 729, + 729, + 716, + 716, + 734, + 734, + 734, + 0, + 0, + 0, + 508, + 508, + 472, + 472, + 449, + 449, + 412, + 412, + 403, + 403, + 411, + 411, + 408, + 408, + 402, + 402, + 409, + 409, + 428, + 428, + 414, + 414 + ] + }, + "grid": { + "tableKey": "grid", + "tableName": "电网输入电量", + "tableValue": 116, + "tableValueInfo": [ + 485, + 485, + 485, + 0, + 441, + 441, + 446, + 446, + 442, + 442, + 440, + 440, + 440, + 0, + 485, + 485, + 486, + 486, + 477, + 477, + 456, + 456, + 442, + 442, + 437, + 437, + 433, + 433, + 434, + 434, + 426, + 426, + 429, + 429, + 420, + 420, + 422, + 422, + 414, + 414, + 409, + 409, + 418, + 418, + 415, + 415, + 411, + 411, + 408, + 408, + 412, + 412, + 411, + 411, + 413, + 413, + 379, + 379, + 376, + 376, + 376, + 376, + 372, + 372, + 375, + 375, + 383, + 383, + 370, + 370, + 392, + 392, + 381, + 381, + 334, + 334, + 409, + 409, + 471, + 471, + 410, + 410, + 455, + 455, + 281, + 281, + 213, + 213, + 78, + 78, + 123, + 123, + 110, + 110, + 87, + 87, + 87, + 0, + 0, + 0, + 177, + 177, + 150, + 150, + 146, + 146, + 158, + 158, + 195, + 195, + 322, + 322, + 145, + 145, + 1589, + 1589, + 23, + 23, + 23, + 0, + 0, + 0, + 618, + 618, + 663, + 663, + 663, + 0, + 1412, + 1412, + 382, + 382, + 578, + 578, + 578, + 0, + 243, + 243, + 1709, + 1709, + 1230, + 1230, + 160, + 160, + 175, + 175, + 620, + 620, + 620, + 0, + 290, + 290, + 11, + 11, + 9, + 9, + 9, + 9, + 140, + 140, + 1245, + 1245, + 326, + 326, + 911, + 911, + 931, + 931, + 650, + 650, + 668, + 668, + 668, + 0, + 435, + 435, + 1139, + 1139, + 846, + 846, + 719, + 719, + 9, + 9, + 9, + 0, + 267, + 267, + 234, + 234, + 234, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1105, + 1105, + 314, + 314, + 324, + 324, + 405, + 405, + 405, + 0, + 0, + 0, + 431, + 431, + 462, + 462, + 543, + 543, + 631, + 631, + 600, + 600, + 2587, + 2587, + 506, + 506, + 563, + 563, + 1405, + 1405, + 1405, + 0, + 519, + 519, + 480, + 480, + 480, + 0, + 734, + 734, + 729, + 729, + 733, + 733, + 722, + 722, + 665, + 665, + 665, + 0, + 0, + 0, + 736, + 736, + 719, + 719, + 719, + 0, + 746, + 746, + 733, + 733, + 751, + 751, + 751, + 0, + 0, + 0, + 524, + 524, + 488, + 488, + 466, + 466, + 428, + 428, + 419, + 419, + 427, + 427, + 424, + 424, + 418, + 418, + 424, + 424, + 443, + 443, + 429, + 429 + ] + }, + "pv": { + "tableKey": "pv", + "tableName": "PV发电量", + "tableValue": 60, + "tableValueInfo": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 4, + 4, + 14, + 14, + 27, + 27, + 40, + 40, + 55, + 55, + 62, + 62, + 92, + 92, + 103, + 103, + 110, + 110, + 138, + 138, + 146, + 146, + 150, + 150, + 177, + 177, + 177, + 0, + 0, + 0, + 229, + 229, + 250, + 250, + 255, + 255, + 290, + 290, + 277, + 277, + 176, + 176, + 325, + 325, + 809, + 809, + 543, + 543, + 543, + 0, + 0, + 0, + 1007, + 1007, + 1009, + 1009, + 1009, + 0, + 1097, + 1097, + 1100, + 1100, + 785, + 785, + 785, + 0, + 1171, + 1171, + 1201, + 1201, + 808, + 808, + 1245, + 1245, + 1189, + 1189, + 732, + 732, + 732, + 0, + 428, + 428, + 583, + 583, + 582, + 582, + 465, + 465, + 1188, + 1188, + 907, + 907, + 1160, + 1160, + 370, + 370, + 427, + 427, + 733, + 733, + 784, + 784, + 784, + 0, + 1023, + 1023, + 322, + 322, + 474, + 474, + 624, + 624, + 600, + 600, + 600, + 0, + 330, + 330, + 270, + 270, + 270, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 351, + 351, + 217, + 217, + 147, + 147, + 124, + 124, + 124, + 0, + 0, + 0, + 42, + 42, + 24, + 24, + 4, + 4, + 4, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + "titleParams": [ + { + "tableKey": "pv", + "tableName": "PV发电量", + "tableValue": 60, + "tableValueInfo": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 4, + 4, + 14, + 14, + 27, + 27, + 40, + 40, + 55, + 55, + 62, + 62, + 92, + 92, + 103, + 103, + 110, + 110, + 138, + 138, + 146, + 146, + 150, + 150, + 177, + 177, + 177, + 0, + 0, + 0, + 229, + 229, + 250, + 250, + 255, + 255, + 290, + 290, + 277, + 277, + 176, + 176, + 325, + 325, + 809, + 809, + 543, + 543, + 543, + 0, + 0, + 0, + 1007, + 1007, + 1009, + 1009, + 1009, + 0, + 1097, + 1097, + 1100, + 1100, + 785, + 785, + 785, + 0, + 1171, + 1171, + 1201, + 1201, + 808, + 808, + 1245, + 1245, + 1189, + 1189, + 732, + 732, + 732, + 0, + 428, + 428, + 583, + 583, + 582, + 582, + 465, + 465, + 1188, + 1188, + 907, + 907, + 1160, + 1160, + 370, + 370, + 427, + 427, + 733, + 733, + 784, + 784, + 784, + 0, + 1023, + 1023, + 322, + 322, + 474, + 474, + 624, + 624, + 600, + 600, + 600, + 0, + 330, + 330, + 270, + 270, + 270, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 351, + 351, + 217, + 217, + 147, + 147, + 124, + 124, + 124, + 0, + 0, + 0, + 42, + 42, + 24, + 24, + 4, + 4, + 4, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + { + "tableKey": "essentialLoad", + "tableName": "不断电负载耗电量", + "tableValue": 0, + "tableValueInfo": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + { + "tableKey": "grid", + "tableName": "电网输入电量", + "tableValue": 116, + "tableValueInfo": [ + 485, + 485, + 485, + 0, + 441, + 441, + 446, + 446, + 442, + 442, + 440, + 440, + 440, + 0, + 485, + 485, + 486, + 486, + 477, + 477, + 456, + 456, + 442, + 442, + 437, + 437, + 433, + 433, + 434, + 434, + 426, + 426, + 429, + 429, + 420, + 420, + 422, + 422, + 414, + 414, + 409, + 409, + 418, + 418, + 415, + 415, + 411, + 411, + 408, + 408, + 412, + 412, + 411, + 411, + 413, + 413, + 379, + 379, + 376, + 376, + 376, + 376, + 372, + 372, + 375, + 375, + 383, + 383, + 370, + 370, + 392, + 392, + 381, + 381, + 334, + 334, + 409, + 409, + 471, + 471, + 410, + 410, + 455, + 455, + 281, + 281, + 213, + 213, + 78, + 78, + 123, + 123, + 110, + 110, + 87, + 87, + 87, + 0, + 0, + 0, + 177, + 177, + 150, + 150, + 146, + 146, + 158, + 158, + 195, + 195, + 322, + 322, + 145, + 145, + 1589, + 1589, + 23, + 23, + 23, + 0, + 0, + 0, + 618, + 618, + 663, + 663, + 663, + 0, + 1412, + 1412, + 382, + 382, + 578, + 578, + 578, + 0, + 243, + 243, + 1709, + 1709, + 1230, + 1230, + 160, + 160, + 175, + 175, + 620, + 620, + 620, + 0, + 290, + 290, + 11, + 11, + 9, + 9, + 9, + 9, + 140, + 140, + 1245, + 1245, + 326, + 326, + 911, + 911, + 931, + 931, + 650, + 650, + 668, + 668, + 668, + 0, + 435, + 435, + 1139, + 1139, + 846, + 846, + 719, + 719, + 9, + 9, + 9, + 0, + 267, + 267, + 234, + 234, + 234, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1105, + 1105, + 314, + 314, + 324, + 324, + 405, + 405, + 405, + 0, + 0, + 0, + 431, + 431, + 462, + 462, + 543, + 543, + 631, + 631, + 600, + 600, + 2587, + 2587, + 506, + 506, + 563, + 563, + 1405, + 1405, + 1405, + 0, + 519, + 519, + 480, + 480, + 480, + 0, + 734, + 734, + 729, + 729, + 733, + 733, + 722, + 722, + 665, + 665, + 665, + 0, + 0, + 0, + 736, + 736, + 719, + 719, + 719, + 0, + 746, + 746, + 733, + 733, + 751, + 751, + 751, + 0, + 0, + 0, + 524, + 524, + 488, + 488, + 466, + 466, + 428, + 428, + 419, + 419, + 427, + 427, + 424, + 424, + 418, + 418, + 424, + 424, + 443, + 443, + 429, + 429 + ] + }, + { + "tableKey": "homeload", + "tableName": "家庭负载耗电量", + "tableValue": 170, + "tableValueInfo": [ + 469, + 469, + 469, + 0, + 425, + 425, + 430, + 430, + 426, + 426, + 424, + 424, + 424, + 0, + 469, + 469, + 471, + 471, + 461, + 461, + 440, + 440, + 426, + 426, + 421, + 421, + 417, + 417, + 418, + 418, + 410, + 410, + 413, + 413, + 404, + 404, + 406, + 406, + 398, + 398, + 393, + 393, + 402, + 402, + 399, + 399, + 395, + 395, + 392, + 392, + 396, + 396, + 395, + 395, + 397, + 397, + 363, + 363, + 360, + 360, + 360, + 360, + 356, + 356, + 359, + 359, + 367, + 367, + 356, + 356, + 383, + 383, + 383, + 383, + 349, + 349, + 436, + 436, + 515, + 515, + 458, + 458, + 534, + 534, + 375, + 375, + 311, + 311, + 204, + 204, + 257, + 257, + 247, + 247, + 251, + 251, + 251, + 0, + 0, + 0, + 391, + 391, + 388, + 388, + 387, + 387, + 436, + 436, + 452, + 452, + 478, + 478, + 458, + 458, + 2360, + 2360, + 541, + 541, + 541, + 0, + 0, + 0, + 1569, + 1569, + 1613, + 1613, + 1613, + 0, + 2444, + 2444, + 1415, + 1415, + 1317, + 1317, + 1317, + 0, + 1343, + 1343, + 2837, + 2837, + 1986, + 1986, + 1331, + 1331, + 1293, + 1293, + 1303, + 1303, + 1303, + 0, + 695, + 695, + 568, + 568, + 558, + 558, + 450, + 450, + 1260, + 1260, + 2096, + 2096, + 1414, + 1414, + 1255, + 1255, + 1336, + 1336, + 1335, + 1335, + 1406, + 1406, + 1406, + 0, + 1399, + 1399, + 1438, + 1438, + 1295, + 1295, + 1304, + 1304, + 578, + 578, + 578, + 0, + 570, + 570, + 490, + 490, + 490, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1430, + 1430, + 516, + 516, + 460, + 460, + 521, + 521, + 521, + 0, + 0, + 0, + 461, + 461, + 473, + 473, + 537, + 537, + 618, + 618, + 584, + 584, + 2567, + 2567, + 489, + 489, + 546, + 546, + 1388, + 1388, + 1388, + 0, + 503, + 503, + 463, + 463, + 463, + 0, + 716, + 716, + 712, + 712, + 716, + 716, + 704, + 704, + 647, + 647, + 647, + 0, + 0, + 0, + 719, + 719, + 702, + 702, + 702, + 0, + 729, + 729, + 716, + 716, + 734, + 734, + 734, + 0, + 0, + 0, + 508, + 508, + 472, + 472, + 449, + 449, + 412, + 412, + 403, + 403, + 411, + 411, + 408, + 408, + 402, + 402, + 409, + 409, + 428, + 428, + 414, + 414 + ] + }, + { + "tableKey": "bat", + "tableName": "电池充电电量", + "tableValue": 0, + "tableValueInfo": [ + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 0, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 1, + 1, + 1, + 0, + 1, + 1, + 1, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 0, + 0, + 0, + 1, + 1, + 1, + 1, + 1, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 1, + 1, + 1, + 1, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 0, + 0, + 0, + 1, + 1, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 1, + 1, + 0, + 0, + 0, + 1, + 1, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + }, + { + "tableKey": "batF", + "tableName": "电池放电电量", + "tableValue": 0, + "tableValueInfo": null + } + ], + "essentialLoad": { + "tableKey": "essentialLoad", + "tableName": "不断电负载耗电量", + "tableValue": 0, + "tableValueInfo": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + } + } + }, + "success": true + }, + { + "label": "day_all_v2", + "method": "GET", + "endpoint": "/lesvr/v2/getAllDayData", + "params": { + "deviceId": "H240909079", + "queryDate": "2026-09-11" + }, + "http_status": 200, + "return_value": 998, + "shape": "NoneType None", + "raw_response": { + "returnValue": 998, + "msg": "您访问对页面不存在" + }, + "success": false + }, + { + "label": "history_year", + "method": "GET", + "endpoint": "/lesvr/getHistoryYearData", + "params": { + "deviceId": "H240909079", + "year": "2026" + }, + "http_status": 200, + "return_value": 1, + "shape": "dict keys=['firstYear', 'bat', 'homeload', 'pv', 'grid', 'essentialLoad', 'batF']", + "raw_response": { + "returnValue": 1, + "data": { + "firstYear": "2024", + "bat": { + "tableKey": "bat", + "tableName": "电池充电电量", + "tableValueInfo": [ + 12, + 16853, + 5738 + ] + }, + "homeload": { + "tableKey": "homeload", + "tableName": "家庭负载耗电量", + "tableValueInfo": [ + 3787, + 29320, + 18995 + ] + }, + "pv": { + "tableKey": "pv", + "tableName": "PV发电量", + "tableValueInfo": [ + 997, + 33955, + 18653 + ] + }, + "grid": { + "tableKey": "grid", + "tableName": "电网输入电量", + "tableValueInfo": [ + 3062, + 16609, + 22880 + ] + }, + "essentialLoad": { + "tableKey": "essentialLoad", + "tableName": "不断电负载耗电量", + "tableValueInfo": [ + 0, + 12785, + 18378 + ] + }, + "batF": { + "tableKey": "batF", + "tableName": "电池放电电量", + "tableValueInfo": [ + 56, + 15282, + 5284 + ] + } + } + }, + "success": true + }, + { + "label": "month", + "method": "GET", + "endpoint": "/lesvr/getMonthData", + "params": { + "deviceId": "H240909079", + "year": "2026", + "month": "9" + }, + "http_status": 200, + "return_value": 1, + "shape": "dict keys=['bat', 'homeload', 'pv', 'grid', 'essentialLoad', 'batF']", + "raw_response": { + "returnValue": 1, + "data": { + "bat": { + "tableKey": "bat", + "tableName": "电池充电电量", + "tableValueInfo": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + "homeload": { + "tableKey": "homeload", + "tableName": "家庭负载耗电量", + "tableValueInfo": [ + 83, + 153, + 244, + 223, + 176, + 260, + 193, + 205, + 205, + 203, + 170, + 45, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + "pv": { + "tableKey": "pv", + "tableName": "PV发电量", + "tableValueInfo": [ + 28, + 70, + 77, + 59, + 38, + 53, + 50, + 55, + 61, + 45, + 60, + 12, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + "grid": { + "tableKey": "grid", + "tableName": "电网输入电量", + "tableValueInfo": [ + 59, + 89, + 174, + 170, + 142, + 213, + 148, + 156, + 150, + 164, + 116, + 35, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + "essentialLoad": { + "tableKey": "essentialLoad", + "tableName": "不断电负载耗电量", + "tableValueInfo": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + "batF": { + "tableKey": "batF", + "tableName": "电池放电电量", + "tableValueInfo": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + } + } + }, + "success": true + }, + { + "label": "year", + "method": "GET", + "endpoint": "/lesvr/getYearData", + "params": { + "deviceId": "H240909079", + "year": "2026" + }, + "http_status": 200, + "return_value": 1, + "shape": "dict keys=['bat', 'homeload', 'pv', 'grid', 'essentialLoad', 'batF']", + "raw_response": { + "returnValue": 1, + "data": { + "bat": { + "tableKey": "bat", + "tableName": "电池充电电量", + "tableValueInfo": [ + 1242, + 790, + 1234, + 1219, + 1690, + 1573, + 1698, + 1851, + 1820, + 1485, + 1133, + 1118 + ] + }, + "homeload": { + "tableKey": "homeload", + "tableName": "家庭负载耗电量", + "tableValueInfo": [ + 2787, + 2572, + 3311, + 3184, + 3699, + 2940, + 2339, + 2066, + 1598, + 1588, + 1618, + 1618 + ] + }, + "pv": { + "tableKey": "pv", + "tableName": "PV发电量", + "tableValueInfo": [ + 2217, + 1423, + 2376, + 2357, + 3117, + 3156, + 3495, + 3503, + 3602, + 3088, + 2851, + 2770 + ] + }, + "grid": { + "tableKey": "grid", + "tableName": "电网输入电量", + "tableValueInfo": [ + 1228, + 1609, + 1516, + 1372, + 1212, + 1648, + 1314, + 716, + 802, + 1418, + 2046, + 1728 + ] + }, + "essentialLoad": { + "tableKey": "essentialLoad", + "tableName": "不断电负载耗电量", + "tableValueInfo": [ + 0, + 0, + 0, + 0, + 0, + 1078, + 1637, + 1303, + 1967, + 2112, + 2540, + 2148 + ] + }, + "batF": { + "tableKey": "batF", + "tableName": "电池放电电量", + "tableValueInfo": [ + 1077, + 669, + 1102, + 1098, + 1499, + 1422, + 1535, + 1685, + 1705, + 1376, + 1067, + 1047 + ] + } + } + }, + "success": true + }, + { + "label": "master_slave", + "method": "GET", + "endpoint": "/lesvr/getMasterSlaveInfo", + "params": { + "deviceId": "H240909079" + }, + "http_status": 200, + "return_value": 210, + "shape": "NoneType None", + "raw_response": { + "returnValue": 210, + "msg": "没有权限" + }, + "success": false + }, + { + "label": "online_status", + "method": "GET", + "endpoint": "/lesvr/getOnlineStatus", + "params": { + "deviceId": "H240909079" + }, + "http_status": 200, + "return_value": 1, + "shape": "dict keys=['onlineStatus']", + "raw_response": { + "returnValue": 1, + "data": { + "onlineStatus": 0 + } + }, + "success": true + }, + { + "label": "device_info", + "method": "GET", + "endpoint": "/lesvr/deviceInfo", + "params": { + "deviceId": "H240909079" + }, + "http_status": 200, + "return_value": 1, + "shape": "dict keys=['masterStatus']", + "raw_response": { + "returnValue": 1, + "data": { + "masterStatus": 0 + } + }, + "success": true + }, + { + "label": "get_device", + "method": "GET", + "endpoint": "/lesvr/getDevice", + "params": { + "deviceId": "H240909079" + }, + "http_status": 200, + "return_value": 1, + "shape": "dict keys=['devices']", + "raw_response": { + "returnValue": 1, + "data": { + "devices": [ + { + "deviceId": "H240909079", + "deviceType": "SUNT-4.0kW-H", + "onlineStatus": 1, + "remarkName": "", + "errorStatus": null, + "deviceImg": "/typeimg/big/H.png", + "country": null, + "countryEName": null + } + ] + } + }, + "success": true + }, + { + "label": "get_dt", + "method": "GET", + "endpoint": "/lesvr/getDT", + "params": { + "deviceId": "H240909079" + }, + "http_status": 200, + "return_value": 2, + "shape": "NoneType None", + "raw_response": { + "returnValue": 2, + "msg": "服务器繁忙,请稍后重试" + }, + "success": false + }, + { + "label": "user_device", + "method": "GET", + "endpoint": "/lesvr/getUserDevice", + "params": {}, + "http_status": 200, + "return_value": 1, + "shape": "dict keys=['notices', 'oldData', 'devices']", + "raw_response": { + "returnValue": 1, + "data": { + "notices": [], + "oldData": { + "id": null, + "userId": null, + "pv": 53593, + "es": 31163, + "home": 52057, + "grid": 42516, + "bat": 22603, + "batf": 20622, + "updateDate": null + }, + "devices": [ + { + "deviceId": "H240909079", + "deviceType": "SUNT-4.0kW-H", + "onlineStatus": 1, + "remarkName": "", + "errorStatus": null, + "deviceImg": "/typeimg/big/H.png", + "country": null, + "countryEName": null + } + ] + } + }, + "success": true + }, + { + "label": "user_device_list", + "method": "GET", + "endpoint": "/lesvr/userDeviceList", + "params": {}, + "http_status": 200, + "return_value": 1, + "shape": "dict keys=['devices', 'errorNum', 'onlineNum', 'offlineNum']", + "raw_response": { + "returnValue": 1, + "data": { + "devices": [ + { + "deviceId": "H240909079", + "deviceType": "SUNT-4.0kW-H", + "onlineStatus": 1, + "remarkName": "", + "errorStatus": 0, + "deviceImg": "/typeimg/big/H.png", + "country": null, + "countryEName": null + } + ], + "errorNum": 0, + "onlineNum": 1, + "offlineNum": 0 + } + }, + "success": true + }, + { + "label": "user_info", + "method": "GET", + "endpoint": "/lesvr/getUserInfo", + "params": {}, + "http_status": 200, + "return_value": 203, + "shape": "NoneType None", + "raw_response": { + "returnValue": 203, + "msg": "未登录" + }, + "success": false + }, + { + "label": "version_info", + "method": "GET", + "endpoint": "/lesvr/getVersionInfo", + "params": {}, + "http_status": 200, + "return_value": 2, + "shape": "NoneType None", + "raw_response": { + "returnValue": 2, + "msg": "服务器繁忙,请稍后重试" + }, + "success": false + }, + { + "label": "get_version", + "method": "GET", + "endpoint": "/lesvr/getVersion", + "params": {}, + "http_status": 200, + "return_value": 1, + "shape": "dict keys=['controller', 'controller2', 'screen', 'liquidCrystal']", + "raw_response": { + "returnValue": 1, + "data": { + "controller": { + "firmwareType": "2", + "firmwareName": "控制固件列表", + "firmwareEnName": "Control Firmware Listt", + "versions": [] + }, + "controller2": { + "firmwareType": "0", + "firmwareName": "副控制板", + "firmwareEnName": "controller2", + "versions": [] + }, + "screen": { + "firmwareType": "3", + "firmwareName": "屏幕", + "firmwareEnName": "screen", + "versions": [] + }, + "liquidCrystal": { + "firmwareType": "1", + "firmwareName": "液晶屏固件列表", + "firmwareEnName": "LCD screen firmware list", + "versions": [] + } + } + }, + "success": true + }, + { + "label": "check_update_get", + "method": "GET", + "endpoint": "/lesvr/checkUpdate", + "params": {}, + "http_status": 200, + "return_value": 2, + "shape": "NoneType None", + "raw_response": { + "returnValue": 2, + "msg": "服务器繁忙,请稍后重试" + }, + "success": false + }, + { + "label": "app_param", + "method": "GET", + "endpoint": "/app/getAppParam", + "params": {}, + "http_status": 200, + "return_value": 1, + "shape": "dict keys=['continentImgUrl', 'maxDiffData', 'maxEsMsgUnreadCount', 'delUserOpen', 'snParam', 'userKeepTime', 'deviceTypeImg', 'emailSendCount', 'yearPre', 'defaultAvatar', 'extFirmware', 'imgServer']", + "raw_response": { + "returnValue": 1, + "data": { + "continentImgUrl": "{\"亚洲\":\"https://rsdown.suntcn.com/dark/img/yz2@3x.png\",\"欧洲\":\"https://rsdown.suntcn.com/dark/img/oz2@3x.png\",\"北美洲\":\"https://rsdown.suntcn.com/dark/img/b...", + "maxDiffData": "10000", + "maxEsMsgUnreadCount": "500", + "delUserOpen": "0", + "snParam": "{\"snHead\":\"P,H\",\"snYear\":\"24,25,26\",\"deviceTypes\":\"3.6kw,5.5kw,6.0kw,8.0kw,4.0kw,10kw,12kw\"}", + "userKeepTime": "1051200", + "deviceTypeImg": "https://rsdown.suntcn.com/dark/typeimg/", + "emailSendCount": "3", + "yearPre": "1", + "defaultAvatar": "http://sq-res.eenovance.cn//defaultAvatar/1.png", + "extFirmware": "{\"inreview\":\"1\",\"version_android\":\"1.0.6\",\"isrenew_android\":\"0\",\"renewUrl_android\":\"http://down.suntcn.com/AndroidApk/lightearth.apk\",\"ios_url\":\"https...", + "imgServer": "https://rsdown.suntcn.com/dark/", + "oriShareOpen": "1", + "breakSns": "P240308001,H230426002,P240719008,H240911053,H250319016,H240911007,H240911097,P250114007,P241224009,P250709321,P250820923,H250309003,P240902049,P251010...", + "updateUserOpen": "0", + "deviceSetting": "{\"powerLevel\":{\"1\":\"3.6kw\",\"2\":\"5.5kw\",\"3\":\"4kw\"},\"deviceType\":{\"200\":\"组串机\",\"300\":\"储能机\",\"400\":\"微逆机\"},\"firmwareType\":[{\"firmwareName\":\"液晶屏固件列表\",\"firmwa...", + "userManual": "https://rsdown.suntcn.com/dark/others/userManual.pdf", + "stopSign": "https://rsdown.suntcn.com/dark/others/stopSign.pdf", + "lhgImg": "https://rsdown.suntcn.com/dark/img/lhg.png", + "whiteUserIds": "11132", + "maxMsgUnreadCount": "10000", + "htDeviceType": "4.0,6.0,8.0,10.0,12.0", + "deviceTypes": "3.6kw,5.5kw,6.0kw,8.0kw,4.0kw", + "userManualHt": "https://rsdown.suntcn.com/dark/others/userManual.html", + "privateManual": "https://rsdown.suntcn.com/dark/bins/bluet/private.html", + "stopSignHt": "https://rsdown.suntcn.com/dark/others/stopSign.html" + } + }, + "success": true + }, + { + "label": "device_type_list", + "method": "GET", + "endpoint": "/device/getTypeList", + "params": {}, + "http_status": 200, + "return_value": 1, + "shape": "dict keys=['deviceTypes']", + "raw_response": { + "returnValue": 1, + "data": { + "deviceTypes": [ + { + "id": 2, + "deviceType": "SUNT-4.0kW-HP V1.1", + "userDesc": "https://rsdown.suntcn.com/dark/PDF/20260707090317026.pdf", + "del": 0, + "sortNo": 0 + }, + { + "id": 3, + "deviceType": "SUNT-6.0kW-HP V1.1", + "userDesc": "https://rsdown.suntcn.com/dark/PDF/20260707090500035.pdf", + "del": 0, + "sortNo": 0 + }, + { + "id": 4, + "deviceType": "SUNT-8.0kW-HP V1.1", + "userDesc": "https://rsdown.suntcn.com/dark/PDF/20260707090612324.pdf", + "del": 0, + "sortNo": 0 + }, + { + "id": 5, + "deviceType": "SUNT-6.0kW-HT V1.1", + "userDesc": "https://rsdown.suntcn.com/dark/PDF/20260707090733137.pdf", + "del": 0, + "sortNo": 0 + }, + { + "id": 6, + "deviceType": "SUNT-8.0kW-HT V1.1", + "userDesc": "https://rsdown.suntcn.com/dark/PDF/20260707093128759.pdf", + "del": 0, + "sortNo": 0 + }, + { + "id": 7, + "deviceType": "SUNT-12.0kW-HT V1.1", + "userDesc": "https://rsdown.suntcn.com/dark/PDF/20260707093232358.pdf", + "del": 0, + "sortNo": 0 + }, + { + "id": 8, + "deviceType": "SUNT-4.0kW-SH V1.1", + "userDesc": "https://rsdown.suntcn.com/dark/PDF/20260707094404116.pdf", + "del": 0, + "sortNo": 0 + }, + { + "id": 9, + "deviceType": "SUNT-6.0kW-SH V1.3", + "userDesc": "https://rsdown.suntcn.com/dark/PDF/20260707094435489.pdf", + "del": 0, + "sortNo": 0 + }, + { + "id": 10, + "deviceType": "SUNT-6.0kW-S V1.1", + "userDesc": "https://rsdown.suntcn.com/dark/PDF/20260707100155873.pdf", + "del": 0, + "sortNo": 0 + }, + { + "id": 11, + "deviceType": "SUNT-8.0kW-S V1.0", + "userDesc": "https://rsdown.suntcn.com/dark/PDF/20260707100302276.pdf", + "del": 0, + "sortNo": 0 + } + ] + } + }, + "success": true + }, + { + "label": "type_setting", + "method": "GET", + "endpoint": "/lesvr/deviceTypeSettingList", + "params": { + "blueTooth": "0", + "version": "4" + }, + "http_status": 200, + "return_value": 1, + "shape": "dict keys=['deviceTypeSettings', 'total']", + "raw_response": { + "returnValue": 1, + "data": { + "deviceTypeSettings": [], + "total": 0 + } + }, + "success": true + } + ], + "auth": { + "returnValue": 1, + "data": { + "uid": "", + "svrPermission": "", + "nickname": "", + "userType": 1, + "expiredTime": "2027-11-28 16:26", + "token": "" + } + }, + "summary": { + "total": 19, + "working": [ + "app_param", + "day_all", + "device_info", + "device_type_list", + "get_device", + "get_version", + "history_year", + "month", + "online_status", + "type_setting", + "user_device", + "user_device_list", + "year" + ], + "not_working": [ + "check_update_get", + "day_all_v2", + "get_dt", + "master_slave", + "user_info", + "version_info" + ] + } +} \ No newline at end of file diff --git a/docs/probe_results_tier1_corrected.json b/docs/probe_results_tier1_corrected.json new file mode 100644 index 0000000..2d5f29f --- /dev/null +++ b/docs/probe_results_tier1_corrected.json @@ -0,0 +1,621 @@ +{ + "tool_version": "probe_tier1_corrected/1.0", + "timestamp": "2026-09-12T03:14:11.131243+00:00", + "purpose": "re-probe with the parameter names core/api_client.py actually uses", + "safety": "GET-only, 1 req/endpoint, no retry, cap 8", + "probes": [ + { + "label": "month_control", + "method": "GET", + "endpoint": "/lesvr/getMonthData", + "params": { + "deviceId": "H240909079", + "year": "2026", + "month": "9" + }, + "http_status": 200, + "return_value": 1, + "shape": "dict keys=['bat', 'homeload', 'pv', 'grid', 'essentialLoad', 'batF']", + "raw_response": { + "returnValue": 1, + "data": { + "bat": { + "tableKey": "bat", + "tableName": "电池充电电量", + "tableValueInfo": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + "homeload": { + "tableKey": "homeload", + "tableName": "家庭负载耗电量", + "tableValueInfo": [ + 83, + 153, + 244, + 223, + 176, + 260, + 193, + 205, + 205, + 203, + 170, + 45, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + "pv": { + "tableKey": "pv", + "tableName": "PV发电量", + "tableValueInfo": [ + 28, + 70, + 77, + 59, + 38, + 53, + 50, + 55, + 61, + 45, + 60, + 12, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + "grid": { + "tableKey": "grid", + "tableName": "电网输入电量", + "tableValueInfo": [ + 59, + 89, + 174, + 170, + 142, + 213, + 148, + 156, + 150, + 164, + 116, + 35, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + "essentialLoad": { + "tableKey": "essentialLoad", + "tableName": "不断电负载耗电量", + "tableValueInfo": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + "batF": { + "tableKey": "batF", + "tableName": "电池放电电量", + "tableValueInfo": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + } + } + }, + "success": true + }, + { + "label": "year_control", + "method": "GET", + "endpoint": "/lesvr/getYearData", + "params": { + "deviceId": "H240909079", + "year": "2026" + }, + "http_status": 200, + "return_value": 1, + "shape": "dict keys=['bat', 'homeload', 'pv', 'grid', 'essentialLoad', 'batF']", + "raw_response": { + "returnValue": 1, + "data": { + "bat": { + "tableKey": "bat", + "tableName": "电池充电电量", + "tableValueInfo": [ + 1242, + 790, + 1234, + 1219, + 1690, + 1573, + 1698, + 1851, + 1820, + 1485, + 1133, + 1118 + ] + }, + "homeload": { + "tableKey": "homeload", + "tableName": "家庭负载耗电量", + "tableValueInfo": [ + 2787, + 2572, + 3311, + 3184, + 3699, + 2940, + 2339, + 2066, + 1598, + 1588, + 1618, + 1618 + ] + }, + "pv": { + "tableKey": "pv", + "tableName": "PV发电量", + "tableValueInfo": [ + 2217, + 1423, + 2376, + 2357, + 3117, + 3156, + 3495, + 3503, + 3602, + 3088, + 2851, + 2770 + ] + }, + "grid": { + "tableKey": "grid", + "tableName": "电网输入电量", + "tableValueInfo": [ + 1228, + 1609, + 1516, + 1372, + 1212, + 1648, + 1314, + 716, + 802, + 1418, + 2046, + 1728 + ] + }, + "essentialLoad": { + "tableKey": "essentialLoad", + "tableName": "不断电负载耗电量", + "tableValueInfo": [ + 0, + 0, + 0, + 0, + 0, + 1078, + 1637, + 1303, + 1967, + 2112, + 2540, + 2148 + ] + }, + "batF": { + "tableKey": "batF", + "tableName": "电池放电电量", + "tableValueInfo": [ + 1077, + 669, + 1102, + 1098, + 1499, + 1422, + 1535, + 1685, + 1705, + 1376, + 1067, + 1047 + ] + } + } + }, + "success": true + }, + { + "label": "history_year", + "method": "GET", + "endpoint": "/lesvr/getHistoryYearData", + "params": { + "deviceId": "H240909079", + "year": "2026" + }, + "http_status": 200, + "return_value": 1, + "shape": "dict keys=['firstYear', 'bat', 'homeload', 'pv', 'grid', 'essentialLoad', 'batF']", + "raw_response": { + "returnValue": 1, + "data": { + "firstYear": "2024", + "bat": { + "tableKey": "bat", + "tableName": "电池充电电量", + "tableValueInfo": [ + 12, + 16853, + 5738 + ] + }, + "homeload": { + "tableKey": "homeload", + "tableName": "家庭负载耗电量", + "tableValueInfo": [ + 3787, + 29320, + 18995 + ] + }, + "pv": { + "tableKey": "pv", + "tableName": "PV发电量", + "tableValueInfo": [ + 997, + 33955, + 18653 + ] + }, + "grid": { + "tableKey": "grid", + "tableName": "电网输入电量", + "tableValueInfo": [ + 3062, + 16609, + 22880 + ] + }, + "essentialLoad": { + "tableKey": "essentialLoad", + "tableName": "不断电负载耗电量", + "tableValueInfo": [ + 0, + 12785, + 18378 + ] + }, + "batF": { + "tableKey": "batF", + "tableName": "电池放电电量", + "tableValueInfo": [ + 56, + 15282, + 5284 + ] + } + } + }, + "success": true + }, + { + "label": "history_year_q", + "method": "GET", + "endpoint": "/lesvr/getHistoryYearData", + "params": { + "deviceId": "H240909079", + "queryYear": "2026" + }, + "http_status": 200, + "return_value": 1, + "shape": "dict keys=['firstYear', 'bat', 'homeload', 'pv', 'grid', 'essentialLoad', 'batF']", + "raw_response": { + "returnValue": 1, + "data": { + "firstYear": "2024", + "bat": { + "tableKey": "bat", + "tableName": "电池充电电量", + "tableValueInfo": [ + 12, + 16853, + 5738 + ] + }, + "homeload": { + "tableKey": "homeload", + "tableName": "家庭负载耗电量", + "tableValueInfo": [ + 3787, + 29320, + 18995 + ] + }, + "pv": { + "tableKey": "pv", + "tableName": "PV发电量", + "tableValueInfo": [ + 997, + 33955, + 18653 + ] + }, + "grid": { + "tableKey": "grid", + "tableName": "电网输入电量", + "tableValueInfo": [ + 3062, + 16609, + 22880 + ] + }, + "essentialLoad": { + "tableKey": "essentialLoad", + "tableName": "不断电负载耗电量", + "tableValueInfo": [ + 0, + 12785, + 18378 + ] + }, + "batF": { + "tableKey": "batF", + "tableName": "电池放电电量", + "tableValueInfo": [ + 56, + 15282, + 5284 + ] + } + } + }, + "success": true + }, + { + "label": "device_info", + "method": "GET", + "endpoint": "/lesvr/deviceInfo", + "params": { + "deviceId": "H240909079" + }, + "http_status": 200, + "return_value": 1, + "shape": "dict keys=['masterStatus']", + "raw_response": { + "returnValue": 1, + "data": { + "masterStatus": 0 + } + }, + "success": true + }, + { + "label": "online_status", + "method": "GET", + "endpoint": "/lesvr/getOnlineStatus", + "params": { + "deviceId": "H240909079" + }, + "http_status": 200, + "return_value": 1, + "shape": "dict keys=['onlineStatus']", + "raw_response": { + "returnValue": 1, + "data": { + "onlineStatus": 0 + } + }, + "success": true + }, + { + "label": "get_version", + "method": "GET", + "endpoint": "/lesvr/getVersion", + "params": {}, + "http_status": 200, + "return_value": 1, + "shape": "dict keys=['controller', 'controller2', 'screen', 'liquidCrystal']", + "raw_response": { + "returnValue": 1, + "data": { + "controller": { + "firmwareType": "2", + "firmwareName": "控制固件列表", + "firmwareEnName": "Control Firmware Listt", + "versions": [] + }, + "controller2": { + "firmwareType": "0", + "firmwareName": "副控制板", + "firmwareEnName": "controller2", + "versions": [] + }, + "screen": { + "firmwareType": "3", + "firmwareName": "屏幕", + "firmwareEnName": "screen", + "versions": [] + }, + "liquidCrystal": { + "firmwareType": "1", + "firmwareName": "液晶屏固件列表", + "firmwareEnName": "LCD screen firmware list", + "versions": [] + } + } + }, + "success": true + }, + { + "label": "check_update", + "method": "GET", + "endpoint": "/lesvr/checkUpdate", + "params": {}, + "http_status": 200, + "return_value": 2, + "shape": "NoneType None", + "raw_response": { + "returnValue": 2, + "msg": "服务器繁忙,请稍后重试" + }, + "success": false + } + ], + "auth": { + "returnValue": 1, + "data": { + "uid": "", + "svrPermission": "", + "nickname": "", + "userType": 1, + "expiredTime": "2027-11-28 16:26", + "token": "" + } + } +} \ No newline at end of file From 04e49625a4b86b6571ef3769e3dcc48b12c04e1b Mon Sep 17 00:00:00 2001 From: Vokupt Date: Sat, 12 Sep 2026 23:59:32 +0700 Subject: [PATCH 04/24] docs: record getAllDayData as the primary daily path API_PROTOCOL.md was corrected on main to drop its "Get All Day Data" section entirely -- correct at the time, since the client did not call the endpoint. The previous commit wired it up, so the document is stale in the other direction now: it still says the integration uses only the three per-metric endpoints, and it no longer documents the endpoint that is actually primary. Restores the section against the verified facts rather than the earlier version's wording: the path is /lesvr/getAllDayData (no v2/ prefix on this host), batF is omitted rather than zeroed when a day had no discharge, and the three per-metric endpoints are now the fallback rather than the only path. The v2/ confusion is called out so it is not reintroduced. --- docs/api/API_PROTOCOL.md | 33 ++++++++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/docs/api/API_PROTOCOL.md b/docs/api/API_PROTOCOL.md index 2fc34c8..3a0ebf7 100644 --- a/docs/api/API_PROTOCOL.md +++ b/docs/api/API_PROTOCOL.md @@ -33,12 +33,35 @@ ## API Endpoints -> **Endpoint status:** the integration uses the three daily endpoints documented below. -> See the [endpoint survey](API_ENDPOINTS_DISCOVERED.md#12-bốn-endpoint-integration-đang-dùng-là-alias-legacy) for their legacy status and the vendor app's combined endpoint. +> **Endpoint status:** `getAllDayData` is the integration's primary daily path — one +> request returns PV, battery, load and grid for a day. The three per-metric +> endpoints below are its **fallback** and still answer identically. See the +> [endpoint survey](API_ENDPOINTS_DISCOVERED.md) for their legacy status. ### Daily Data APIs -#### Get PV Day Data +#### Get All Day Data (primary) +- **Endpoint**: `/lesvr/getAllDayData` +- **Method**: `GET` +- **Auth**: Required (`Authorization` header — see the note on 998 vs 1000 below) +- **Params**: `deviceId`, `queryDate` (`yyyy-MM-dd`) +- **Response**: one `data` object carrying every metric — `pv`, `bat` (charge), + `batF` (discharge), `homeload`, `essentialLoad`, `grid` — each with its own + `tableValue` / `tableValueInfo` (288 points), plus a `titleParams` array + listing the same metrics with their display names. +- **Caveat**: `batF` is **omitted entirely** when there was no discharge, where + `getBatDayData` returns an explicit zero. The client normalises this (charge + present + `batF` absent ⇒ 0 kWh discharge) so the two sources stay + interchangeable for callers. +- **Path note**: the APK contains `lesvr/v2/getAllDayData` because app 3.2.4 + targets a different host (`lesvrjm.suntcn.com`), where `v2/` is correct. + Against `lesvr.suntcn.com` the `v2/` form answers `998` (does not exist) and + this un-prefixed form answers `1`. An earlier version of this document had + that backwards. +- **Used by**: [core/api_client.py](../../core/api_client.py) `get_all_day_data()`, + which `get_daily_stats()` calls first. + +#### Get PV Day Data (fallback) - **Endpoint**: `/lesvr/getPVDayData` - **Method**: `GET` - **Auth**: Required (Authorization header) @@ -49,7 +72,7 @@ - `tableValue`: Total daily value (in 0.1 kWh units) - `tableValueInfo`: Array of 288 values (5-minute intervals, 24h × 12 points/hour) -#### Get Battery Day Data +#### Get Battery Day Data (fallback) - **Endpoint**: `/lesvr/getBatDayData` - **Method**: `GET` - **Auth**: Required @@ -88,7 +111,7 @@ - `bats[1]` = Discharge total - `tableValueInfo`: Signed power series — see the sign convention note in the sample response above -#### Get Other Day Data +#### Get Other Day Data (fallback) - **Endpoint**: `/lesvr/getOtherDayData` - **Method**: `GET` - **Auth**: Required From 99de25c31953b9b3af985dd744e039c6e8e7ef39 Mon Sep 17 00:00:00 2001 From: Vokupt Date: Sun, 13 Sep 2026 00:00:06 +0700 Subject: [PATCH 05/24] docs: record the traps this repo sets, and what was measured about each Every harder-than-it-looks part of working here was rediscovered from scratch at least once. This collects them so the next person does not pay for it again. The entries that cost the most time: content_in_root hides the integration from HA's loader and makes hassfest's "Domain does not match dir name" unsatisfiable forever; seven entities ship disabled-by-default and therefore have no state, which reads as a broken integration in any test that asserts otherwise; mqtt_discovery.json's 4,350 captured frames are all unusable because its non-ASCII bytes were flattened to U+FFFD; asyncio.create_task is invisible to hass.async_block_till_done, so the MQTT client's 100 ms coalescer needs an explicit sleep; and on Windows both the event loop policy setter and pytest-socket's guard have to be worked around before a single test can run. Also records the git-history hazard that made the Task B port necessary: local main sat 14 behind and 3 "ahead" while all three commits had already landed upstream under different hashes. Includes the secret-handling rules, including why the two older credential values in already-public commits were left alone rather than rewritten out. --- docs/DEVELOPMENT_NOTES.md | 159 ++++++++++++++++++++++++++++++++++++++ docs/README.md | 6 +- 2 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 docs/DEVELOPMENT_NOTES.md diff --git a/docs/DEVELOPMENT_NOTES.md b/docs/DEVELOPMENT_NOTES.md new file mode 100644 index 0000000..8997e8a --- /dev/null +++ b/docs/DEVELOPMENT_NOTES.md @@ -0,0 +1,159 @@ +# Development Notes + +Traps this repository sets, and what was measured about each. Every entry here +cost real debugging time to find; none of it is guesswork. Read this before +changing the test harness, the layout, or anything that touches the vendor dump. + +## Running the tests + +There are two environments, and they collect different numbers: + +```bash +# CI's environment: pytest + aiohttp + paho + crcmod only. +# Home Assistant is absent, so the end-to-end test skips. +python -m pytest tests/ -q # 64 passed, 6 skipped + +# The real end-to-end harness: Home Assistant installed. +"D:/code/_e2e_lumentree/.venv/Scripts/python.exe" -m pytest tests/ -q # 70 passed +``` + +The end-to-end test (`tests/test_e2e_mqtt_to_entity.py`) **skips**, it does not +fail, when Home Assistant is missing. That is deliberate: it means `pytest +tests/` on a bare interpreter still passes, and CI stays green without carrying +a full Home Assistant install. + +## `content_in_root: true` breaks Home Assistant's loader + +`hacs.json` sets `content_in_root: true`, so the repository root *is* the +`lumentree` package and there is no `custom_components/lumentree/` directory. +HA's `Integration.resolve_from_root` looks for +`custom_components/lumentree/manifest.json`, finds nothing, and the integration +is invisible to it. + +The end-to-end test works around this by building +`homeassistant.loader.Integration` directly from the real `manifest.json` and +registering it in `hass.data[loader.DATA_INTEGRATIONS]["lumentree"]`. + +The same layout is why hassfest reports `[MANIFEST] Domain does not match dir +name` and always will: hassfest compares `manifest["domain"]` against the +directory name it discovered, and for a root layout that name is the checkout +directory. There is no `content_in_root` exemption in hassfest +(home-assistant/core#152942 was closed `not_planned`). CI works around it by +checking out into `custom_components/lumentree` — see `.github/workflows/ci.yml`. + +## Things that look like bugs but are not + +**Entities with no state.** Seven entities ship `entity_registry_enabled_default += False`. Home Assistant registers those but deliberately creates **no state** +until a user enables them, so `hass.states.get(entity_id)` returns `None`. +A test that asserts every registered entity has state will fail on a healthy +integration. Iterate `entity_registry`, skip entries whose `disabled_by` is not +`None`, and keep their keys in a separate set if a drift check needs to count +them. + +**A corrupted vendor dump.** `mqtt_discovery.json` holds ~4,350 captured MQTT +payloads and looks like a ready-made frame fixture. It is not usable: its +non-ASCII bytes have been flattened to U+FFFD, so the frames cannot be +re-encoded and **zero** of them pass CRC. Build frames with the `_crc16` / +`build_frame` helpers in `tests/test_realtime_parser.py` instead, and say so in +the test — a comment claiming a frame was "captured" when it was constructed is +worse than no comment. + +## Home Assistant test harness gotchas + +**`asyncio.create_task` is invisible to `async_block_till_done()`.** The MQTT +client's `_queue_update` coalesces frames into a 100 ms `asyncio.create_task` +that HA never tracked, so `await hass.async_block_till_done()` returns before it +runs. A test that delivers a frame and immediately asserts on entity state will +see `unknown`. Sleep past the coalescing window first, then block: + +```python +await asyncio.sleep(0.15) +await hass.async_block_till_done() +``` + +**`verify_cleanup` fails on lingering timers.** HA's autouse fixture fails the +test if any `async_track_time_interval` or `async_call_later` job is still +pending at teardown. Setup arms a 5 s poll timer and a nightly timer, so the +config-entry fixture must unload the entry — and the fixture must be **async**, +because `hass.config_entries.async_unload` returns a coroutine. A sync fixture +calling it without `await` silently does nothing and the test still fails. + +The same hazard runs the other way: if a test stubs `disconnect` to a no-op, it +also blocks the `_cancel_offline_timer()` call inside it and leaks a 12.5 s +`async_call_later`. Stub only `connect`, and let the real `disconnect` run — with +no `_mqtt_client` behind it, its body is just the cancel calls. + +**`aioclient_mock`'s responses have no `.ok`.** `aiohttp.ClientResponse.ok` is +what `api_client._request` reads, and HA's mocker produces a response object +with `.status` but not `.ok`. Wrap `aioclient_mock.match_request` to derive it, +keeping the shim at the mock boundary instead of changing shipping code to +accommodate a test double. Note also that `respx` does **not** intercept HA's +session — only `aioclient_mock` does. + +**Windows needs a `SelectorEventLoop`, and the policy setter is disabled.** +`aiodns` raises outright on a `ProactorEventLoop`, which is the Windows +default, so any test that lets the integration build its own session dies +inside setup. The policy cannot simply be replaced: +`pytest_homeassistant_custom_component` installs `HassEventLoopPolicy` at plugin +import and then hard-disables `asyncio.set_event_loop_policy` with a lambda. +Patch `_loop_factory` on the existing policy instead — see the top of +`tests/conftest.py`. Linux and CI are unaffected. + +**pytest-socket blocks event-loop construction on Windows.** HA's autouse +fixtures call `pytest_socket.disable_socket(allow_unix_socket=True)`, which +replaces `socket.socket` with a guard. Every Windows event loop builds its +self-pipe through `socket.socketpair()`, and the fallback implementation uses +the module-level `socket.socket` name — so the network ban blocks *loop +construction* and every test errors during fixture setup. `allow_unix_socket` +cannot help; Windows has no `AF_UNIX`. `tests/conftest.py` re-exposes the real +constructor for that one stdlib-internal call site. + +## Landmines in the build + +**`_STAGGER_DELAY_SECONDS`.** `__init__.py` waits ten seconds between warming +the daily coordinator and the stats coordinators, so a backfill can land. It is +a module-level constant so the test can shrink it to 0.01; leaving it inline as +a literal `asyncio.sleep(10)` made the end-to-end suite take 61 seconds instead +of 1.3. + +**`asyncio.timeout` requires Python 3.11+**, which is why the integration +declares that floor. + +**The effective Home Assistant floor is 2024.4, not the `2023.1.0` in +`hacs.json`.** `config_flow.py` does a runtime `from +homeassistant.config_entries import ConfigFlowResult`, and that symbol only +exists from 2024.4. On 2023.1–2024.3 the integration installs and then fails at +setup. + +## Git and history + +`main` on GitHub has been rewritten more than once during this project's +cleaning passes. Before treating any local commit as "unmerged", check whether +its *content* already landed via a different commit — a stale local `main` can +sit 14 behind and 3 "ahead" while every one of those three commits is already +present upstream under another hash. + +## Secret handling + +The vendor MQTT credentials (`MQTT_USERNAME` / `MQTT_PASSWORD` in `const.py`) +are masked rather than removed. They ship in plaintext inside the vendor's own +Android app, so they are public by construction and no client-side scheme can +keep them secret — the masking only stops them turning up in a text search of +this repository. See the `_unmask` docstring, which says exactly that. + +Two older credential values from the discovery work (a request-signing salt and +a suspected AES key) are absent from the current tree, but they do remain in +older commits that are already public on GitHub. They were left alone +deliberately: rewriting public history to remove a value that is already +mirrored and cached causes more damage than it prevents. Do not reintroduce +them into new files. + +Rules for anything placed under `docs/`: + +- `token`, `uid`, `nickname`, `phone` are redacted to ``. +- Probe records must not carry a live token. The ones committed here were + scanned for JWTs, `uid`/`nickname`/`phone` fields, and IP addresses; all + clean. +- `tools/` is gitignored and contains `emqx_clients.json` (128 MB of vendor + MQTT client records plus EMQX admin credentials). It must never be committed. diff --git a/docs/README.md b/docs/README.md index 19be817..7dd44d7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,6 +7,7 @@ This directory contains documentation, guides, and example code for the Lumentre ``` docs/ ├── README.md # This file +├── DEVELOPMENT_NOTES.md # Traps in the test harness, the layout, and the build ├── api/ # API documentation │ ├── API_PROTOCOL.md # API endpoints and authentication │ ├── API_ENDPOINTS_DISCOVERED.md # Full endpoint survey and corrections to API_PROTOCOL.md @@ -23,11 +24,14 @@ docs/ │ └── dashboard_daily_stats.yaml # Daily statistics dashboard ├── examples/ # Code examples │ └── backfill_2024.yaml # Example automation for backfilling historical data -└── probe_results_tier1.json # Read-only probe output backing API_ENDPOINTS_DISCOVERED.md +└── probe_*.json # Read-only probe output backing API_ENDPOINTS_DISCOVERED.md ``` ## 📚 Documentation +### Development +- **[Development Notes](DEVELOPMENT_NOTES.md)**: Traps in the test harness, the `content_in_root` layout, and the build — and what was measured about each + ### API Documentation - **[API Protocol](api/API_PROTOCOL.md)**: Complete API reference including authentication, endpoints, and request/response formats - **[API Endpoints Discovered](api/API_ENDPOINTS_DISCOVERED.md)**: Endpoint survey, legacy-alias findings, and the corrections to API_PROTOCOL.md From 83632366dd2fa26ff8370c0ad106dce251ac8676 Mon Sep 17 00:00:00 2001 From: Vokupt Date: Sun, 13 Sep 2026 00:37:41 +0700 Subject: [PATCH 06/24] no-mistakes(review): Fix daily-stats fallback gate and stale doc pointers --- core/api_client.py | 42 +++++--- docs/api/API_ENDPOINTS_DISCOVERED.md | 3 +- docs/api/API_PROTOCOL.md | 2 +- tests/test_day_stats_mapping.py | 147 +++++++++++++++++++++++++++ 4 files changed, 178 insertions(+), 16 deletions(-) diff --git a/core/api_client.py b/core/api_client.py index 23ad827..abb27e3 100644 --- a/core/api_client.py +++ b/core/api_client.py @@ -600,9 +600,12 @@ async def get_daily_stats(self, device_identifier: str, query_date: str) -> dict Tries GET /lesvr/getAllDayData first: one request instead of three, and one round of server-side work instead of three. Falls back to the three - per-metric legacy endpoints when that returns nothing usable -- they - were the only path for a long time and still answer identically, so a - failure of the combined endpoint degrades speed rather than function. + per-metric legacy endpoints when that comes back empty -- a transport + error, or a response whose every metric was unusable -- they were the + only path for a long time and still answer identically, so a failure + of the combined endpoint degrades speed rather than function. An empty + result means exactly that for both sources, so this test is the + endpoint's own "no data" answer either way. Args: device_identifier: Device ID or serial number @@ -823,10 +826,27 @@ async def _fetch_other_data(self, base_params: dict[str, str]) -> dict[str, Any] _LOGGER.exception("Unexpected other stats error") return {"grid_in_today": None, "load_today": None} + @staticmethod + def _drop_none_scalars(stats: dict[str, Any]) -> dict[str, Any]: + """Keep series data and real readings; drop metrics that came back None. + + A None scalar means the metric was reported unusable, and the two + sources of a day's statistics both have to answer that the same way. + """ + return { + key: value + for key, value in stats.items() + if isinstance(value, list) or value is not None + } + @classmethod def _merge_all_day_payload(cls, payload: Any) -> dict[str, Any]: """Turn a getAllDayData `data` object into the standard stats dict. + Returns an empty dict when the payload carried no usable metric at all, + the same as the three per-metric endpoints do, so a caller can treat + "empty" as "no data" regardless of which source answered. + Split out from the request method so the mapping can be tested against captured payloads without a live session -- the sign convention and the absent-`batF` case are the parts worth pinning down, and neither needs @@ -864,7 +884,7 @@ def _merge_all_day_payload(cls, payload: Any) -> dict[str, Any]: discharge_today = 0.0 result.update(cls._build_battery_result(signed_series, charge_today, discharge_today)) - return result + return cls._drop_none_scalars(result) async def get_all_day_data(self, device_identifier: str, query_date: str) -> dict[str, Any]: """Fetch a whole day's statistics in one request. @@ -887,7 +907,9 @@ async def get_all_day_data(self, device_identifier: str, query_date: str) -> dic Returns: Dictionary with the same keys the legacy three-call path produces, - so callers cannot tell which endpoint served them. + so callers cannot tell which endpoint served them. Empty when the + call failed or every metric was unusable -- the same "no data" + answer the legacy path gives, so a caller can fall back on it. """ if _LOGGER.isEnabledFor(logging.DEBUG): _LOGGER.debug("Fetching all-day data for %s @ %s", device_identifier, query_date) @@ -929,12 +951,4 @@ def _merge_stats_results(self, results: Iterable[Any]) -> dict[str, Any]: if _LOGGER.isEnabledFor(logging.DEBUG): _LOGGER.debug("Merged daily stats: %s", merged) - # Filter out None values but keep lists (series data) and other valid values - # This preserves series data even if tableValue is None - filtered = {} - for k, v in merged.items(): - # Keep lists (series data) and non-None values, skip None scalar values - if isinstance(v, list) or v is not None: - filtered[k] = v - - return filtered + return self._drop_none_scalars(merged) diff --git a/docs/api/API_ENDPOINTS_DISCOVERED.md b/docs/api/API_ENDPOINTS_DISCOVERED.md index 9e33b24..b6d29ba 100644 --- a/docs/api/API_ENDPOINTS_DISCOVERED.md +++ b/docs/api/API_ENDPOINTS_DISCOVERED.md @@ -1,6 +1,7 @@ # API Discovery — LightEarth 3.2.4 vs. lumentreeHA 5.2.0 > **Trạng thái:** khảo sát (survey) + đính chính lần hai. Chưa implement sensor nào. +> **Phiên bản:** tài liệu này thuộc đợt 5.2.0; `manifest.json` vẫn ghi `5.1.3` cho tới khi release. > **Nguồn:** `blutter` trên `libapp.so` (Dart 3.9.2, `light_earth` 3.2.4), `web_app.js` (admin console), EMQX exports, và probe read-only có kiểm soát. > **Ngày:** 2026-09-12 (đính chính lần hai cùng ngày, xem mục 0) @@ -342,7 +343,7 @@ Sau ~30 request nhanh, host cũ bắt đầu ngắt kết nối (`RemoteDisconne ## 9. Ranh giới an toàn (bắt buộc, không thương lượng) -- **Chỉ read-only.** Không endpoint Tier 3/4 nào bị gọi, kể cả để test. +- **Chỉ read-only.** Integration không gọi endpoint Tier 3/4 nào. Ngoại lệ duy nhất từng chạy là **một** probe read-only vào route admin `/manage/lesvr/getErrorLog` (mục 6) — POST body rỗng, không credential, không retry, chỉ để trả lời câu hỏi `getErrorLog`; route đó không đi vào code chạy thật. - **Không ghi vào hạ tầng vendor**: không register, không bind device, không `upSnAddress`, không `device/sendMsg`. - `tools/` (gồm `emqx_clients.json` 128 MB có credential admin EMQX) **đã được `.gitignore` chặn** ở Phase 0 — không bao giờ commit. - **Không** đưa token/uid/nickname/phone thô vào bất kỳ file nào được track. Trong tài liệu này chúng là ``. diff --git a/docs/api/API_PROTOCOL.md b/docs/api/API_PROTOCOL.md index 3a0ebf7..1210a3f 100644 --- a/docs/api/API_PROTOCOL.md +++ b/docs/api/API_PROTOCOL.md @@ -43,7 +43,7 @@ #### Get All Day Data (primary) - **Endpoint**: `/lesvr/getAllDayData` - **Method**: `GET` -- **Auth**: Required (`Authorization` header — see the note on 998 vs 1000 below) +- **Auth**: Required (`Authorization` header — see the note on 998 below) - **Params**: `deviceId`, `queryDate` (`yyyy-MM-dd`) - **Response**: one `data` object carrying every metric — `pv`, `bat` (charge), `batF` (discharge), `homeload`, `essentialLoad`, `grid` — each with its own diff --git a/tests/test_day_stats_mapping.py b/tests/test_day_stats_mapping.py index 6d247e4..9089317 100644 --- a/tests/test_day_stats_mapping.py +++ b/tests/test_day_stats_mapping.py @@ -21,6 +21,8 @@ from __future__ import annotations +import asyncio + import pytest # Shape captured from GET /lesvr/getAllDayData on a real device (abridged). @@ -163,6 +165,151 @@ def test_empty_payload_does_not_raise(self, lumentree_api_client) -> None: """A response with no metrics at all yields no data rather than an error.""" assert self._merged(lumentree_api_client, {}) == {} + def test_a_payload_with_no_usable_metric_is_indistinguishable_from_empty( + self, lumentree_api_client + ) -> None: + """A response whose every metric is unusable must read as "no data". + + This is the same answer the three legacy endpoints give, and the answer + ``get_daily_stats`` tests before deciding to fall back. A dict that is + merely non-empty here -- carrying six keys whose values are all None -- + would suppress the fallback and let the coordinator cache a day of + zeros, so emptiness has to mean "nothing usable", not "some keys". + """ + assert self._merged(lumentree_api_client, {"pv": None, "grid": "nonsense"}) == {} + + +def _combined_payload() -> dict: + """A combined payload with one usable metric, for the fallback tests.""" + return {"pv": {"tableValue": 60, "tableValueInfo": [0, 0, 120, 240]}} + + +async def _async(value): + """Wrap a plain value in a coroutine, for stubbing an async fetcher.""" + return value + + +def _make_stub_session(responses: dict): + """A recording aiohttp-session stub answering by endpoint path. + + ``responses`` maps an endpoint path to the JSON body to answer it with, so + the client's real ``_request`` path runs and the calls it makes are + observable in ``session.calls``. + """ + + class _Response: + def __init__(self, payload: dict) -> None: + self._payload = payload + self.status = 200 + self.ok = True + + async def text(self) -> str: + return str(self._payload) + + async def json(self, content_type=None) -> dict: + return self._payload + + def raise_for_status(self) -> None: + return None + + class _Request: + def __init__(self, recorder, path, headers) -> None: + self._recorder, self._path, self._headers = recorder, path, headers + + async def __aenter__(self): + self._recorder.append({"path": self._path, "headers": dict(self._headers)}) + return _Response(responses[self._path]) + + async def __aexit__(self, *exc_info) -> bool: + return False + + class _Session: + def __init__(self) -> None: + self.calls: list[dict] = [] + + def request(self, method, url, **kwargs): + path = url.split("suntcn.com", 1)[-1].split("?")[0] + return _Request(self.calls, path, kwargs.get("headers", {})) + + return _Session() + + +class TestDailyStatsFallback: + """A combined endpoint that reports nothing usable must not mask the day. + + ``get_daily_stats`` prefers the combined endpoint and falls back to the + three per-metric endpoints when it reports no data. Both sources now + answer "no data" with an empty dict, so the gate reads the endpoint's own + answer rather than the arity of a partly-populated dict. + """ + + _LEGACY = { + "/lesvr/getPVDayData": { + "returnValue": 1, + "data": {"pv": {"tableValue": 60, "tableValueInfo": [0, 0]}}, + }, + "/lesvr/getBatDayData": { + "returnValue": 1, + "data": {"bats": [{"tableValue": 30}, {"tableValue": 12}], "tableValueInfo": []}, + }, + "/lesvr/getOtherDayData": { + "returnValue": 1, + "data": { + "grid": {"tableValue": 116, "tableValueInfo": []}, + "homeload": {"tableValue": 170, "tableValueInfo": []}, + "essentialLoad": {"tableValue": 0, "tableValueInfo": []}, + }, + }, + } + + def _client(self, lumentree_api_client, responses): + session = _make_stub_session(responses) + client = lumentree_api_client.LumentreeHttpApiClient(session=session) + client.set_token("stub-token") + return client, session + + def test_an_unusable_combined_response_falls_back_and_keeps_serving( + self, lumentree_api_client + ) -> None: + """The day must still be reported through the legacy path. + + Regression: an unusable combined response used to come back as a + non-empty dict of None values, which is truthy, so the fallback never + ran and the coordinator cached the day with grid and load as zero. + """ + responses = dict(self._LEGACY) + responses["/lesvr/getAllDayData"] = { + "returnValue": 1, + "data": {"pv": None, "grid": "nonsense"}, + } + client, session = self._client(lumentree_api_client, responses) + + result = asyncio.run(client.get_daily_stats("H240909079", "2026-09-11")) + + assert result["total_load_today"] == 17.0 + assert result["grid_in_today"] == 11.6 + assert result["pv_today"] == 6.0 + assert len(session.calls) == 4, f"combined + 3 legacy expected, saw {session.calls}" + assert {c["path"] for c in session.calls} == { + "/lesvr/getAllDayData", + "/lesvr/getPVDayData", + "/lesvr/getBatDayData", + "/lesvr/getOtherDayData", + } + + def test_a_usable_combined_response_is_served_without_the_legacy_calls( + self, lumentree_api_client + ) -> None: + """The one-request path stays the one-request path.""" + responses = dict(self._LEGACY) + responses["/lesvr/getAllDayData"] = {"returnValue": 1, "data": _combined_payload()} + client, session = self._client(lumentree_api_client, responses) + + result = asyncio.run(client.get_daily_stats("H240909079", "2026-09-11")) + + assert result["pv_today"] == 6.0 + assert [c["path"] for c in session.calls] == ["/lesvr/getAllDayData"] + class TestPayloadShapeGuards: """Malformed responses must degrade, not crash the coordinator.""" From df173d84963e29651e67f4f9dac809db5ff2554e Mon Sep 17 00:00:00 2001 From: Vokupt Date: Sun, 13 Sep 2026 00:50:32 +0700 Subject: [PATCH 07/24] no-mistakes(review): Scope overstated doc claim, drop dead test helper, refresh counts --- docs/DEVELOPMENT_NOTES.md | 4 ++-- docs/api/API_ENDPOINTS_DISCOVERED.md | 2 +- tests/test_day_stats_mapping.py | 5 ----- 3 files changed, 3 insertions(+), 8 deletions(-) diff --git a/docs/DEVELOPMENT_NOTES.md b/docs/DEVELOPMENT_NOTES.md index 8997e8a..c25e919 100644 --- a/docs/DEVELOPMENT_NOTES.md +++ b/docs/DEVELOPMENT_NOTES.md @@ -11,10 +11,10 @@ There are two environments, and they collect different numbers: ```bash # CI's environment: pytest + aiohttp + paho + crcmod only. # Home Assistant is absent, so the end-to-end test skips. -python -m pytest tests/ -q # 64 passed, 6 skipped +python -m pytest tests/ -q # 67 passed, 6 skipped # The real end-to-end harness: Home Assistant installed. -"D:/code/_e2e_lumentree/.venv/Scripts/python.exe" -m pytest tests/ -q # 70 passed +"D:/code/_e2e_lumentree/.venv/Scripts/python.exe" -m pytest tests/ -q # 73 passed ``` The end-to-end test (`tests/test_e2e_mqtt_to_entity.py`) **skips**, it does not diff --git a/docs/api/API_ENDPOINTS_DISCOVERED.md b/docs/api/API_ENDPOINTS_DISCOVERED.md index b6d29ba..f1a4141 100644 --- a/docs/api/API_ENDPOINTS_DISCOVERED.md +++ b/docs/api/API_ENDPOINTS_DISCOVERED.md @@ -166,7 +166,7 @@ Cộng thêm `_decrypt` dùng một hằng số AES (`svr_aes`) và một key đ | `lesvr/v2/getAllDayData` | GET | ❌ `998` | **Sai path** — đúng là `/lesvr/getAllDayData` | | `lesvr/getUserInfo` | GET | ❌ `203` | Tài khoản guest không đủ quyền (không phải lỗi endpoint) | -**Giá trị cao nhất:** `/lesvr/getAllDayData` — gộp PV + battery + load + grid vào **một call duy nhất**, thay cho 3 call riêng (`getPVDayData` + `getBatDayData` + `getOtherDayData`) mà integration đang dùng. Đã đo là cho **cùng giá trị** — xem mục 7.1. +**Giá trị cao nhất:** `/lesvr/getAllDayData` — gộp PV + battery + load + grid vào **một call duy nhất**, thay cho 3 call riêng (`getPVDayData` + `getBatDayData` + `getOtherDayData`) mà integration đang dùng. Phép đối chiếu ở mục 7.2 đo được `pv`, `grid`, `homeload`, `essentialLoad` **khớp nhau** trên cả hai đường; phần **pin thì chưa kiểm chứng được** (thiết bị test báo không có pin nên cả hai đường đều ra 0). ### Tier 2 — Cần đánh giá thêm diff --git a/tests/test_day_stats_mapping.py b/tests/test_day_stats_mapping.py index 9089317..2ae5302 100644 --- a/tests/test_day_stats_mapping.py +++ b/tests/test_day_stats_mapping.py @@ -184,11 +184,6 @@ def _combined_payload() -> dict: return {"pv": {"tableValue": 60, "tableValueInfo": [0, 0, 120, 240]}} -async def _async(value): - """Wrap a plain value in a coroutine, for stubbing an async fetcher.""" - return value - - def _make_stub_session(responses: dict): """A recording aiohttp-session stub answering by endpoint path. From b3fd678bb34ed1671489f80eb742664b96a505a4 Mon Sep 17 00:00:00 2001 From: Vokupt Date: Sun, 13 Sep 2026 01:17:19 +0700 Subject: [PATCH 08/24] no-mistakes(review): Scope overstated doc claims, pin online-status assertion, ground batF comment --- core/api_client.py | 16 +++++++++------- docs/api/API_ENDPOINTS_DISCOVERED.md | 2 +- tests/test_day_stats_mapping.py | 13 ++++++++----- tests/test_e2e_mqtt_to_entity.py | 2 +- 4 files changed, 19 insertions(+), 14 deletions(-) diff --git a/core/api_client.py b/core/api_client.py index abb27e3..5e90820 100644 --- a/core/api_client.py +++ b/core/api_client.py @@ -873,13 +873,15 @@ def _merge_all_day_payload(cls, payload: Any) -> dict[str, Any]: charge_today = cls._metric_total_kwh(data.get("bat")) discharge_today = cls._metric_total_kwh(data.get("batF")) - # The combined endpoint omits batF when the day had no discharge, where - # the legacy endpoint it replaces returned an explicit zero. Left as - # None that difference would leak to callers as `unknown` instead of - # `0 kWh`, so the two sources would not actually be interchangeable. - # A battery that reported charge is a battery that was present, so a - # missing batF alongside a present bat means "no discharge", not - # "no data". + # The observed shape is that the combined endpoint reports batF in + # titleParams at zero and omits it from `data`, while a day that + # discharged carries it there; that is consistent with "no discharge", + # so absence alongside a present bat is normalised to 0.0 kWh. The + # legacy endpoint it replaces returned an explicit zero, and leaving + # None would leak to callers as `unknown` instead of `0 kWh`, so the + # two sources would not be interchangeable. Counterfactual: a day that + # discharged while omitting batF would be reported as 0 kWh rather than + # unknown, and no payload in this repo can rule that out. if charge_today is not None and discharge_today is None: discharge_today = 0.0 diff --git a/docs/api/API_ENDPOINTS_DISCOVERED.md b/docs/api/API_ENDPOINTS_DISCOVERED.md index f1a4141..c043568 100644 --- a/docs/api/API_ENDPOINTS_DISCOVERED.md +++ b/docs/api/API_ENDPOINTS_DISCOVERED.md @@ -90,7 +90,7 @@ Hai kết luận của bản đầu, cả hai đều cần chỉnh: Tiền tố `v2/` xuất hiện trong `libapp.so` vì app 3.2.4 trỏ vào host **khác** (`lesvrjm.suntcn.com`). Trên host integration đang dùng, đường dẫn không có `v2/`. Bản đầu kết luận "muốn dùng endpoint gộp thì phải chuyển host" — **kết luận đó sai**; không cần chuyển host. -Các endpoint ngày khác (`getPVDayData`, `getBatDayData`, `getOtherDayData`) vẫn chạy, và **cho cùng giá trị** với `getAllDayData` trên cùng một ngày — xem mục 7. +Các endpoint ngày khác (`getPVDayData`, `getBatDayData`, `getOtherDayData`) vẫn chạy. Phép đối chiếu ở mục 7.2 đo được `pv`, `grid`, `homeload`, `essentialLoad` **khớp nhau** trên cả hai đường; phần **pin thì chưa kiểm chứng được** (thiết bị test báo không có pin nên cả hai đường đều ra 0). ### 1.3. `iot_class: cloud_polling` ⇒ chỉ Tier 1 diff --git a/tests/test_day_stats_mapping.py b/tests/test_day_stats_mapping.py index 2ae5302..f16b7e3 100644 --- a/tests/test_day_stats_mapping.py +++ b/tests/test_day_stats_mapping.py @@ -15,8 +15,9 @@ * ``batF`` absence. With no discharge, the combined endpoint omits ``batF`` entirely rather than returning zeros. Code that indexes it raises. -These tests use captured payloads from the real device, so a change in the -server's shape shows up as a test failure rather than as wrong kWh. +The payload literals below are modelled on a capture from the real device +rather than invented, so the builders are pinned to a real shape; they do not +track the live server, and a shape change there will not fail these tests. """ from __future__ import annotations @@ -25,9 +26,11 @@ import pytest -# Shape captured from GET /lesvr/getAllDayData on a real device (abridged). -# Two things to preserve when editing: bat/batF are separate unsigned series, -# and batF is missing rather than zero when the day had no discharge. +# Shape modelled on GET /lesvr/getAllDayData on a real device (abridged). The +# pv/grid/homeload/essentialLoad values are from that capture; the bat/batF +# values are constructed and unverified, because the captured device reports no +# battery. Two things to preserve when editing: bat/batF are separate unsigned +# series, and batF is missing rather than zero when the day had no discharge. ALL_DAY_WITH_DISCHARGE = { "pv": {"tableValue": 60, "tableValueInfo": [0, 0, 120, 240]}, "grid": {"tableValue": 116, "tableValueInfo": [485, 485, 0, 400]}, diff --git a/tests/test_e2e_mqtt_to_entity.py b/tests/test_e2e_mqtt_to_entity.py index c150fb2..9b805e2 100644 --- a/tests/test_e2e_mqtt_to_entity.py +++ b/tests/test_e2e_mqtt_to_entity.py @@ -417,6 +417,6 @@ async def test_frame_marks_the_device_online(hass: HomeAssistant, entry, vendor_ await _settle(hass, ctx["client"]) after = _state(hass, ctx["entities"]["online_status"]).state - assert after != before or after == "on", ( + assert after == "on", ( f"online_status did not react to a frame (before={before!r}, after={after!r})" ) From 6e024f708812b8bbb95f234c41a0bb4a569fa6f4 Mon Sep 17 00:00:00 2001 From: Vokupt Date: Sun, 13 Sep 2026 01:34:10 +0700 Subject: [PATCH 09/24] no-mistakes(review): Scope fixture comment to totals actually captured, not series values --- tests/test_day_stats_mapping.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/test_day_stats_mapping.py b/tests/test_day_stats_mapping.py index f16b7e3..0c000d5 100644 --- a/tests/test_day_stats_mapping.py +++ b/tests/test_day_stats_mapping.py @@ -27,10 +27,14 @@ import pytest # Shape modelled on GET /lesvr/getAllDayData on a real device (abridged). The -# pv/grid/homeload/essentialLoad values are from that capture; the bat/batF -# values are constructed and unverified, because the captured device reports no -# battery. Two things to preserve when editing: bat/batF are separate unsigned -# series, and batF is missing rather than zero when the day had no discharge. +# tableValue totals are taken from that capture (pv 60, grid 116, homeload 170, +# essentialLoad 0). The tableValueInfo arrays are four-point stand-ins, not +# excerpts -- a real day carries 288 points, so a four-element array cannot be +# one. Only homeload's opening values come from the capture; the rest are +# chosen so the arithmetic stays legible. bat/batF are constructed and +# unverified, because the captured device reports no battery. Two things to +# preserve when editing: bat/batF are separate unsigned series, and batF is +# missing rather than zero when the day had no discharge. ALL_DAY_WITH_DISCHARGE = { "pv": {"tableValue": 60, "tableValueInfo": [0, 0, 120, 240]}, "grid": {"tableValue": 116, "tableValueInfo": [485, 485, 0, 400]}, From b3b1557fc0d6134da7c9c1886d7d6ccc41e2809f Mon Sep 17 00:00:00 2001 From: Vokupt Date: Sun, 13 Sep 2026 02:10:09 +0700 Subject: [PATCH 10/24] no-mistakes(review): Scope totals-test docstring: battery values unverified, not measured --- tests/test_day_stats_mapping.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/test_day_stats_mapping.py b/tests/test_day_stats_mapping.py index 0c000d5..9039342 100644 --- a/tests/test_day_stats_mapping.py +++ b/tests/test_day_stats_mapping.py @@ -136,7 +136,14 @@ def _merged(self, api_client, payload): def test_totals_match_the_legacy_per_metric_endpoints(self, lumentree_api_client) -> None: """The combined endpoint must report the same totals, not merely similar ones. - Values checked against a live comparison between the two paths. + The PV, grid, load and essential totals were checked against the live + comparison recorded in docs/probe_compare_day_endpoints.json, which + marks those four ``match: true``. The battery totals come from the + constructed ``bat``/``batF`` literals in ``ALL_DAY_WITH_DISCHARGE`` and + are **not** verified: that same file records ``bat.discharge`` as + ``match: false`` with ``all_match: false``, and its caveat says the test + device reports no battery, so the comparison cannot confirm the battery + mapping. """ merged = self._merged(lumentree_api_client, ALL_DAY_WITH_DISCHARGE) assert merged["pv_today"] == 6.0 From aa0f19991d0c8cc87d4333fc0aa89c94bb24200e Mon Sep 17 00:00:00 2001 From: Vokupt Date: Sun, 13 Sep 2026 02:28:00 +0700 Subject: [PATCH 11/24] no-mistakes(review): Rename and reword e2e frame claims from captured to constructed --- tests/test_e2e_mqtt_to_entity.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_e2e_mqtt_to_entity.py b/tests/test_e2e_mqtt_to_entity.py index 9b805e2..0b4abac 100644 --- a/tests/test_e2e_mqtt_to_entity.py +++ b/tests/test_e2e_mqtt_to_entity.py @@ -287,7 +287,7 @@ async def noop(self, *args, **kwargs) -> None: def _deliver(client, payload_hex: str, topic: str | None = None) -> None: - """Invoke the client's paho message handler with a captured frame. + """Invoke the client's paho message handler with a constructed frame. The first two arguments are paho's client and userdata, which ``_on_message`` never reads; only ``msg.topic`` and ``msg.payload`` matter. @@ -316,8 +316,8 @@ def _state(hass: HomeAssistant, entity_id: str): return state -async def test_captured_frame_reaches_entity_state(hass: HomeAssistant, entry, vendor_http, monkeypatch): - """A captured frame ends up as decoded numbers on the entity states.""" +async def test_constructed_frame_reaches_entity_state(hass: HomeAssistant, entry, vendor_http, monkeypatch): + """A constructed frame ends up as decoded numbers on the entity states.""" ctx = await _setup(hass, entry, FakeTransport(), monkeypatch) _deliver(ctx["client"], FRAME_FULL) @@ -326,7 +326,7 @@ async def test_captured_frame_reaches_entity_state(hass: HomeAssistant, entry, v from custom_components.lumentree.core.realtime_parser import parse_mqtt_payload expected = parse_mqtt_payload(FRAME_FULL) - assert expected, "the captured frame did not parse; fixture is stale" + assert expected, "the constructed frame did not parse; the builder or the parser changed" soc = _state(hass, ctx["entities"]["battery_soc"]) assert soc.state not in ("unknown", "unavailable"), ( From 315504ec4cfb8058e6266e962370b9cb6bc4ff3c Mon Sep 17 00:00:00 2001 From: Vokupt Date: Sun, 13 Sep 2026 02:55:39 +0700 Subject: [PATCH 12/24] no-mistakes(review): Scope endpoint-equivalence claims, note backfill scope and total-vs-series caveat --- core/api_client.py | 12 ++++++++---- docs/api/API_PROTOCOL.md | 18 ++++++++++++++++-- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/core/api_client.py b/core/api_client.py index 5e90820..f8fff5d 100644 --- a/core/api_client.py +++ b/core/api_client.py @@ -602,10 +602,14 @@ async def get_daily_stats(self, device_identifier: str, query_date: str) -> dict one round of server-side work instead of three. Falls back to the three per-metric legacy endpoints when that comes back empty -- a transport error, or a response whose every metric was unusable -- they were the - only path for a long time and still answer identically, so a failure - of the combined endpoint degrades speed rather than function. An empty - result means exactly that for both sources, so this test is the - endpoint's own "no data" answer either way. + only path for a long time and still answer identically for PV, grid, + load and essential load, which the recorded comparison measured equal. + The battery discharge representation differs (an explicit zero there, + an absent `batF` here) and the client normalises it, though the battery + mapping itself is unverified because the captured device has no + battery. A failure of the combined endpoint therefore degrades speed + rather than function. An empty result means exactly that for both + sources, so this test is the endpoint's own "no data" answer either way. Args: device_identifier: Device ID or serial number diff --git a/docs/api/API_PROTOCOL.md b/docs/api/API_PROTOCOL.md index 1210a3f..86c568f 100644 --- a/docs/api/API_PROTOCOL.md +++ b/docs/api/API_PROTOCOL.md @@ -35,8 +35,16 @@ > **Endpoint status:** `getAllDayData` is the integration's primary daily path — one > request returns PV, battery, load and grid for a day. The three per-metric -> endpoints below are its **fallback** and still answer identically. See the -> [endpoint survey](API_ENDPOINTS_DISCOVERED.md) for their legacy status. +> endpoints below are its **fallback**: they answer identically for PV, grid, +> load and essential load, which the recorded comparison measured equal on both +> paths ([probe_compare_day_endpoints.json](../probe_compare_day_endpoints.json)). The battery discharge +> representation differs — the legacy endpoint returns an explicit zero, the +> combined endpoint omits `batF` — and the client normalises it, but the battery +> mapping itself is **unverified**, because the captured device reports no +> battery. See the [endpoint survey](API_ENDPOINTS_DISCOVERED.md) for their +> legacy status. The combined endpoint is used on the coordinator path only: +> `services/aggregator.py` backfill still issues the three legacy calls per day, +> and adopting it there is deliberately deferred. ### Daily Data APIs @@ -53,6 +61,12 @@ `getBatDayData` returns an explicit zero. The client normalises this (charge present + `batF` absent ⇒ 0 kWh discharge) so the two sources stay interchangeable for callers. +- **Caveat**: a metric's `tableValue` total and its `tableValueInfo` series can + disagree — on the captured device `bat.tableValue` is 0 while its 288-point + series sums to 157 (0.157 kWh). The daily sensor reports the total, so it can + read 0 while its own series attribute is non-zero. This is a vendor data + property, not a mapping bug; `getBatDayData` read `bats[0].tableValue` the + same way, so it predates the combined endpoint. - **Path note**: the APK contains `lesvr/v2/getAllDayData` because app 3.2.4 targets a different host (`lesvrjm.suntcn.com`), where `v2/` is correct. Against `lesvr.suntcn.com` the `v2/` form answers `998` (does not exist) and From 415c76621379481134c8e02c0702edd8947da97e Mon Sep 17 00:00:00 2001 From: Vokupt Date: Sun, 13 Sep 2026 09:40:52 +0700 Subject: [PATCH 13/24] no-mistakes(review): Record admin probe as out-of-policy, de-localize venv path note --- docs/DEVELOPMENT_NOTES.md | 6 ++++-- docs/api/API_ENDPOINTS_DISCOVERED.md | 2 +- docs/probe_results_errorlog.json | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/DEVELOPMENT_NOTES.md b/docs/DEVELOPMENT_NOTES.md index c25e919..79a5e1d 100644 --- a/docs/DEVELOPMENT_NOTES.md +++ b/docs/DEVELOPMENT_NOTES.md @@ -13,8 +13,10 @@ There are two environments, and they collect different numbers: # Home Assistant is absent, so the end-to-end test skips. python -m pytest tests/ -q # 67 passed, 6 skipped -# The real end-to-end harness: Home Assistant installed. -"D:/code/_e2e_lumentree/.venv/Scripts/python.exe" -m pytest tests/ -q # 73 passed +# The real end-to-end harness: a virtualenv with Home Assistant and +# pytest-homeassistant-custom-component installed. The interpreter path is +# local to the author's machine -- point this at your own environment. +"/Scripts/python.exe" -m pytest tests/ -q # 73 passed ``` The end-to-end test (`tests/test_e2e_mqtt_to_entity.py`) **skips**, it does not diff --git a/docs/api/API_ENDPOINTS_DISCOVERED.md b/docs/api/API_ENDPOINTS_DISCOVERED.md index c043568..38c1388 100644 --- a/docs/api/API_ENDPOINTS_DISCOVERED.md +++ b/docs/api/API_ENDPOINTS_DISCOVERED.md @@ -343,7 +343,7 @@ Sau ~30 request nhanh, host cũ bắt đầu ngắt kết nối (`RemoteDisconne ## 9. Ranh giới an toàn (bắt buộc, không thương lượng) -- **Chỉ read-only.** Integration không gọi endpoint Tier 3/4 nào. Ngoại lệ duy nhất từng chạy là **một** probe read-only vào route admin `/manage/lesvr/getErrorLog` (mục 6) — POST body rỗng, không credential, không retry, chỉ để trả lời câu hỏi `getErrorLog`; route đó không đi vào code chạy thật. +- **Chỉ read-only.** Integration không gọi endpoint Tier 3/4 nào. **Ghi chú trung thực:** trong quá trình khảo sát, **một** probe read-only vào route admin `/manage/lesvr/getErrorLog` (mục 6) đã được chạy — việc này **vượt ranh giới** "không gọi Tier 3/4, kể cả để test" mà plan đặt ra, **không phải** một ngoại lệ được phép. Probe dùng POST body rỗng, không credential, không retry, không ghi gì; route trả `1000` (cần đăng nhập) nên không xác thực và không tiết lộ gì. Bản ghi được giữ lại làm bằng chứng rằng việc này đã xảy ra; **không lặp lại**. - **Không ghi vào hạ tầng vendor**: không register, không bind device, không `upSnAddress`, không `device/sendMsg`. - `tools/` (gồm `emqx_clients.json` 128 MB có credential admin EMQX) **đã được `.gitignore` chặn** ở Phase 0 — không bao giờ commit. - **Không** đưa token/uid/nickname/phone thô vào bất kỳ file nào được track. Trong tài liệu này chúng là ``. diff --git a/docs/probe_results_errorlog.json b/docs/probe_results_errorlog.json index f46b0f8..0228b16 100644 --- a/docs/probe_results_errorlog.json +++ b/docs/probe_results_errorlog.json @@ -11,6 +11,6 @@ } ], "purpose": "Task D: determine whether /manage/lesvr/getErrorLog is usable by this integration.", - "safety": "Single POST with an EMPTY body, timeout 10, no retry, no credentials sent. The route is named 'get' and the empty body contains no verb, so nothing can be written.", + "safety": "Single POST with an EMPTY body, timeout 10, no retry, no credentials sent. This call was OUTSIDE the agreed boundary that no Tier 3/4 route is contacted even for testing; it is recorded here as what happened, not as a permitted exception, and it must not be repeated.", "conclusion": "Route exists (rv=1000 'login required', not 998). Not usable: the admin console authenticates with a session cookie from POST /security/login, while this integration only holds an app token for the Authorization header on lesvr.suntcn.com. The vendor app itself exposes no error/fault endpoint at all (38 lesvr/* strings scanned)." } \ No newline at end of file From bafee9c888b620f01cee54c405f3a520f39ab028 Mon Sep 17 00:00:00 2001 From: Vokupt Date: Sun, 13 Sep 2026 10:07:55 +0700 Subject: [PATCH 14/24] no-mistakes(review): Sign battery series by 5-minute slot, not array position An unreadable entry in one battery series was dropped by the float filter, so every later sample slid one slot against its sibling: a 300 W charge step was booked as discharge and a real discharge step vanished. Samples now carry the slot they were reported for, an unreported slot stays None instead of becoming 0 W, and both the battery and total-load merges share the frame. Also keeps the charge-presence discharge gate, rewording its comment to say it is a chosen trade, and notes in API_PROTOCOL.md that the recorded comparison covered day totals rather than the series lists. --- core/api_client.py | 211 +++++++++++++++++++++++--------- docs/api/API_PROTOCOL.md | 4 +- tests/test_day_stats_mapping.py | 154 ++++++++++++++++++++++- 3 files changed, 309 insertions(+), 60 deletions(-) diff --git a/core/api_client.py b/core/api_client.py index f8fff5d..9e26734 100644 --- a/core/api_client.py +++ b/core/api_client.py @@ -94,6 +94,18 @@ def _series_hour_kwh(series_kwh5: list[float]) -> list[float]: hours.append(sum(series_kwh5[start:end])) # Keep full precision return hours + @staticmethod + def _reported_values(frame: list[tuple[int, float | None]]) -> list[float]: + """Fold a frame back into the flat list of readings callers read. + + A slot nobody reported is dropped rather than written as 0 W: a hole + is not a reading, and stretching a series that stopped early out to the + width of its sibling would put invented readings on the chart. Losing + the width costs nothing -- the consumers that fold this list into hours + read a day shorter than 288 slots as zeros for the rest. + """ + return [value for _, value in frame if value is not None] + @staticmethod def _sum(series: list[float]) -> float: # Keep full precision @@ -126,11 +138,94 @@ def _metric_total_kwh(metric: Any) -> float | None: return None @staticmethod - def _metric_series_w(metric: Any) -> list[float]: - """Return a metric's 5-minute series in W (empty when absent).""" + def _readings(vals: Any) -> list[float | None]: + """Float a raw series, marking entries that cannot be read as None. + + Unlike a filter, this keeps the list exactly as wide as the wire, so a + sample's index is still the 5-minute slot it was reported for. A hole + is not a reading, so it survives as None rather than as 0 W and does + not slide the samples after it onto the wrong slot. + """ + if not isinstance(vals, list): + return [] + readings: list[float | None] = [] + for value in vals: + try: + readings.append(float(value)) + except (TypeError, ValueError): + readings.append(None) + return readings + + @staticmethod + def _metric_readings(metric: Any) -> list[float | None]: + """Return a metric's 5-minute series positionally (empty when absent).""" if not isinstance(metric, dict): return [] - return LumentreeHttpApiClient._to_float_list(metric.get("tableValueInfo")) + return LumentreeHttpApiClient._readings(metric.get("tableValueInfo")) + + @classmethod + def _metric_series_w(cls, metric: Any) -> list[float]: + """Return a metric's reported 5-minute values in W (empty when absent).""" + return [value for value in cls._metric_readings(metric) if value is not None] + + @staticmethod + def _frame(values: list[float | None]) -> list[tuple[int, float | None]]: + """Pair each sample with the 5-minute slot it was reported for. + + The frame is as wide as the series itself -- a short series contributes + nothing to the slots it never sent rather than zeros, and no payload in + this repo reports a full day of battery discharge to widen one against. + Consumers that fold the list into hours read a missing slot as 0 kWh. + """ + return [(slot, value) for slot, value in enumerate(values)] + + @staticmethod + def _slot_sum( + left: list[tuple[int, float | None]], + right: list[tuple[int, float | None]], + ) -> list[tuple[int, float | None]]: + """Add two frames slot by slot; an unreported slot contributes nothing. + + Used for the total-load series, where the two inputs are magnitudes + that simply add. A slot neither input reported stays unreported. + """ + a = dict(left) + b = dict(right) + summed: list[tuple[int, float | None]] = [] + for slot in range(max(len(a), len(b))): + x = a.get(slot) + y = b.get(slot) + summed.append((slot, None if x is None and y is None else float(x or 0.0) + float(y or 0.0))) + return summed + + @staticmethod + def _slot_difference( + charge_slots: list[tuple[int, float | None]], + discharge_slots: list[tuple[int, float | None]], + ) -> list[tuple[int, float | None]]: + """Sign the two unsigned battery frames against each other, by slot. + + Charge reported and no discharge that slot means +charge; discharge + reported with no charge means -discharge; both reported means the + difference. A slot neither series reported stays None, so a hole in + one frame cannot book a phantom reading derived from the other. + """ + charge = dict(charge_slots) + discharge = dict(discharge_slots) + width = max(len(charge), len(discharge)) + signed: list[tuple[int, float | None]] = [] + for slot in range(width): + c = charge.get(slot) + d = discharge.get(slot) + if c is None and d is None: + signed.append((slot, None)) + elif d is None: + signed.append((slot, c)) + elif c is None: + signed.append((slot, -d)) + else: + signed.append((slot, c - d)) + return signed @classmethod def _build_pv_result(cls, metric: Any) -> dict[str, Any]: @@ -178,8 +273,10 @@ def _build_load_result(cls, load_metric: Any, essential_metric: Any) -> dict[str if load_total is not None or essential_total is not None: result["total_load_today"] = float(load_total or 0.0) + float(essential_total or 0.0) - load_series_w = cls._metric_series_w(load_metric) - essential_series_w = cls._metric_series_w(essential_metric) + load_readings = cls._metric_readings(load_metric) + essential_readings = cls._metric_readings(essential_metric) + load_series_w = [value for value in load_readings if value is not None] + essential_series_w = [value for value in essential_readings if value is not None] if load_series_w: l5 = cls._series_5min_kwh(load_series_w) @@ -196,21 +293,15 @@ def _build_load_result(cls, load_metric: Any, essential_metric: Any) -> dict[str "essential_series_hour_kwh": cls._series_hour_kwh(e5), }) - if load_series_w and essential_series_w: - # The two series can differ in length; pad rather than truncate so a - # short series contributes zeros instead of silently dropping the - # tail of the longer one. - length = max(len(load_series_w), len(essential_series_w)) - load_padded = list(load_series_w) + [0.0] * (length - len(load_series_w)) - essential_padded = list(essential_series_w) + [0.0] * (length - len(essential_series_w)) - total_load_w = [float(a or 0.0) + float(b or 0.0) for a, b in zip(load_padded, essential_padded, strict=False)] - - load_kwh5 = result.get("load_series_5min_kwh", []) - essential_kwh5 = result.get("essential_series_5min_kwh", []) - kwh_length = max(len(load_kwh5), len(essential_kwh5)) - load_kwh_padded = list(load_kwh5) + [0.0] * (kwh_length - len(load_kwh5)) - essential_kwh_padded = list(essential_kwh5) + [0.0] * (kwh_length - len(essential_kwh5)) - total_load_kwh5 = [float(a or 0.0) + float(b or 0.0) for a, b in zip(load_kwh_padded, essential_kwh_padded, strict=False)] + if load_readings and essential_readings: + # Slots, not array positions: a short series contributes nothing to + # the slots it never reported instead of silently adding its zeros + # to the tail of the longer one. + total_load_w = cls._reported_values(cls._slot_sum( + cls._frame(load_readings), + cls._frame(essential_readings), + )) + total_load_kwh5 = cls._series_5min_kwh(total_load_w) result.update({ "total_load_series_5min_w": total_load_w, @@ -226,32 +317,41 @@ def _build_load_result(cls, load_metric: Any, essential_metric: Any) -> dict[str @classmethod def _build_battery_result( cls, - series_positive_charge: list[float], + series_slots: list[tuple[int, float | None]], charge_today: float | None, discharge_today: float | None, ) -> dict[str, Any]: - """Build battery metrics from a series where positive means charge. + """Build battery metrics from a signed frame where positive means charge. - The two day endpoints disagree on representation, so each converts to - this one convention first: + The two day endpoints disagree on representation, so each produces one + of these frames first: * getBatDayData ships a single *signed* series with positive meaning - discharge, which the caller negates before calling in. + discharge, which the caller negates into this frame. * getAllDayData ships separate unsigned `bat` (charge) and `batF` - (discharge) series, which the caller subtracts. + (discharge) series, which the caller signs against each other. Downstream consumers (entities/sensor.py) expect positive = charge. """ result: dict[str, Any] = { "charge_today": charge_today, "discharge_today": discharge_today, } - if series_positive_charge: - charge_kwh5 = cls._series_5min_kwh([w if w > 0 else 0.0 for w in series_positive_charge]) - discharge_kwh5 = cls._series_5min_kwh([abs(w) if w < 0 else 0.0 for w in series_positive_charge]) - result.update({ - "battery_series_5min_w": series_positive_charge, - "battery_charge_series_hour_kwh": cls._series_hour_kwh(charge_kwh5), - "battery_discharge_series_hour_kwh": cls._series_hour_kwh(discharge_kwh5), - }) + reported = cls._reported_values(series_slots) + if not reported: + return result + # The hourly rollups keep one slot each, so a short day still lands in + # the right hours. An unreported slot contributes no energy to either + # side instead of being booked as an idle step. + factor = (5.0 / 60.0) / 1000.0 + charge_kwh5: list[float] = [] + discharge_kwh5: list[float] = [] + for value in reported: + charge_kwh5.append(value * factor if value > 0 else 0.0) + discharge_kwh5.append(abs(value) * factor if value < 0 else 0.0) + result.update({ + "battery_series_5min_w": reported, + "battery_charge_series_hour_kwh": cls._series_hour_kwh(charge_kwh5), + "battery_discharge_series_hour_kwh": cls._series_hour_kwh(discharge_kwh5), + }) return result def set_token(self, token: str | None) -> None: @@ -797,10 +897,11 @@ async def _fetch_battery_data(self, base_params: dict[str, str]) -> dict[str, An # This endpoint's series is signed with positive meaning DISCHARGE # (which contradicts the old API_PROTOCOL.md; the device is the # authority). Negate so the shared builder sees positive = charge. - series_w = self._to_float_list(data.get("tableValueInfo")) - return self._build_battery_result( - [-w for w in series_w], charge_today, discharge_today + readings = self._readings(data.get("tableValueInfo")) + series_slots = self._frame( + [None if w is None else -w for w in readings] ) + return self._build_battery_result(series_slots, charge_today, discharge_today) except (ApiException, AuthException) as exc: _LOGGER.warning(f"Failed battery stats ({type(exc).__name__}): {exc}") return {"charge_today": None, "discharge_today": None} @@ -864,32 +965,30 @@ def _merge_all_day_payload(cls, payload: Any) -> dict[str, Any]: result.update(cls._build_grid_result(data.get("grid"))) result.update(cls._build_load_result(data.get("homeload"), data.get("essentialLoad"))) - charge_series = cls._metric_series_w(data.get("bat")) - discharge_series = cls._metric_series_w(data.get("batF")) # Two unsigned series -> one signed series, positive meaning charge. - # Pad rather than truncate: a missing or short discharge series is the - # normal case (no discharge omits batF entirely rather than zero-filling - # it), and truncating would silently drop the charge series' tail. - length = max(len(charge_series), len(discharge_series)) - charge_padded = list(charge_series) + [0.0] * (length - len(charge_series)) - discharge_padded = list(discharge_series) + [0.0] * (length - len(discharge_series)) - signed_series = [c - d for c, d in zip(charge_padded, discharge_padded, strict=False)] + # Signing is by slot, so an entry that cannot be read in one series + # cannot shift the other's samples onto the wrong 5-minute step. + signed_slots = cls._slot_difference( + cls._frame(cls._metric_readings(data.get("bat"))), + cls._frame(cls._metric_readings(data.get("batF"))), + ) charge_today = cls._metric_total_kwh(data.get("bat")) discharge_today = cls._metric_total_kwh(data.get("batF")) - # The observed shape is that the combined endpoint reports batF in - # titleParams at zero and omits it from `data`, while a day that - # discharged carries it there; that is consistent with "no discharge", - # so absence alongside a present bat is normalised to 0.0 kWh. The - # legacy endpoint it replaces returned an explicit zero, and leaving - # None would leak to callers as `unknown` instead of `0 kWh`, so the - # two sources would not be interchangeable. Counterfactual: a day that - # discharged while omitting batF would be reported as 0 kWh rather than - # unknown, and no payload in this repo can rule that out. + # An absent batF is normalised to 0.0 kWh whenever the charge metric + # for the same day is present. The gate is charge presence because + # presence is the only proxy the payload offers for "this device has a + # battery and reported on this day": the slot-level `batF` omission is + # an output detail the legacy endpoint never shared. This is a chosen + # trade, not a proven equivalence -- it is what keeps the combined + # source answering the same as getBatDayData, whose bats[1] reads back + # as an explicit 0 on a day with no discharge. Counterfactual: a day + # that discharged while omitting batF is reported as 0 kWh rather + # than unknown, and no payload in this repo can rule that out. if charge_today is not None and discharge_today is None: discharge_today = 0.0 - result.update(cls._build_battery_result(signed_series, charge_today, discharge_today)) + result.update(cls._build_battery_result(signed_slots, charge_today, discharge_today)) return cls._drop_none_scalars(result) async def get_all_day_data(self, device_identifier: str, query_date: str) -> dict[str, Any]: diff --git a/docs/api/API_PROTOCOL.md b/docs/api/API_PROTOCOL.md index 86c568f..952d27c 100644 --- a/docs/api/API_PROTOCOL.md +++ b/docs/api/API_PROTOCOL.md @@ -60,7 +60,9 @@ - **Caveat**: `batF` is **omitted entirely** when there was no discharge, where `getBatDayData` returns an explicit zero. The client normalises this (charge present + `batF` absent ⇒ 0 kWh discharge) so the two sources stay - interchangeable for callers. + interchangeable for callers. The recorded comparison behind that claim covers + the day **totals** only — the per-metric series lists were not compared, so + series-level equivalence between the two sources is **not** established. - **Caveat**: a metric's `tableValue` total and its `tableValueInfo` series can disagree — on the captured device `bat.tableValue` is 0 while its 288-point series sums to 157 (0.157 kWh). The daily sensor reports the total, so it can diff --git a/tests/test_day_stats_mapping.py b/tests/test_day_stats_mapping.py index 9039342..77093d7 100644 --- a/tests/test_day_stats_mapping.py +++ b/tests/test_day_stats_mapping.py @@ -88,7 +88,7 @@ def test_positive_charge_series_splits_into_charge_and_discharge( inverted these, the charge and discharge sensors would swap. """ built = lumentree_api_client.LumentreeHttpApiClient._build_battery_result( - [500.0, -300.0, 0.0], 30.0, 12.0 + [(0, 500.0), (1, -300.0), (2, 0.0)], 30.0, 12.0 ) # 500 W over a 5-minute step, positive => charge assert built["battery_charge_series_hour_kwh"][0] == pytest.approx(500 * (5 / 60) / 1000) @@ -102,7 +102,7 @@ def test_a_five_minute_step_feeds_only_one_side(self, lumentree_api_client) -> N there -- so the exclusive property belongs at the 5-minute level. """ built = lumentree_api_client.LumentreeHttpApiClient._build_battery_result( - [100.0, -100.0], None, None + [(0, 100.0), (1, -100.0)], None, None ) charge_5min = built["battery_charge_series_hour_kwh"] discharge_5min = built["battery_discharge_series_hour_kwh"] @@ -122,11 +122,33 @@ def test_hourly_rollup_may_contain_both_charge_and_discharge(self, lumentree_api hourly series -- asserting they are mutually exclusive would be wrong. """ built = lumentree_api_client.LumentreeHttpApiClient._build_battery_result( - [100.0, -100.0], None, None + [(0, 100.0), (1, -100.0)], None, None ) assert built["battery_charge_series_hour_kwh"][0] > 0 assert built["battery_discharge_series_hour_kwh"][0] > 0 + def test_unreported_slots_are_dropped_rather_than_written_as_zero( + self, lumentree_api_client + ) -> None: + """A slot nobody reported is not a reading, and must not become a 0 W one. + + Downstream (entities/sensor.py) publishes this list and turns it into + kWh, so a hole written as 0.0 W is a chart point that claims the + battery was idle at a moment the device said nothing about. + """ + built = lumentree_api_client.LumentreeHttpApiClient._build_battery_result( + [(0, 500.0), (1, None), (2, -300.0)], None, None + ) + assert built["battery_series_5min_w"] == [500.0, -300.0] + + def test_a_frame_with_no_reading_at_all_yields_no_series(self, lumentree_api_client) -> None: + """An empty or all-unreported frame must not publish an empty series.""" + client = lumentree_api_client.LumentreeHttpApiClient + assert "battery_series_5min_w" not in client._build_battery_result([], None, None) + assert "battery_series_5min_w" not in client._build_battery_result( + [(0, None), (1, None)], None, None + ) + class TestAllDayDataMapping: def _merged(self, api_client, payload): @@ -169,12 +191,81 @@ def test_absent_batf_does_not_truncate_the_charge_series(self, lumentree_api_cli merged = self._merged(lumentree_api_client, ALL_DAY_NO_DISCHARGE) assert len(merged["battery_series_5min_w"]) == 4 + def test_battery_series_is_signed_charge_minus_discharge(self, lumentree_api_client) -> None: """Two unsigned series become one signed series, positive = charge.""" merged = self._merged(lumentree_api_client, ALL_DAY_WITH_DISCHARGE) # bat=[0,500,0,0], batF=[0,0,300,0] => [0, +500, -300, 0] assert merged["battery_series_5min_w"] == [0.0, 500.0, -300.0, 0.0] + def test_a_hole_in_one_series_does_not_shift_the_other_series( + self, lumentree_api_client + ) -> None: + """The regression this mapping was rewritten for. + + An entry that cannot be read in `bat` used to shorten it by one, so + every later charge sample slid one slot against `batF`. The reported + result was [100, 250, 0, 0]: the 300 W charge step was booked as + discharge and the real 50 W discharge step disappeared. Slot-signing + gives [100, -50, 300]: the hole is the only thing dropped. + """ + merged = self._merged(lumentree_api_client, { + "bat": {"tableValue": 30, "tableValueInfo": [100, None, 300, 0]}, + "batF": {"tableValue": 12, "tableValueInfo": [0, 50, 0, 0]}, + }) + assert merged["battery_series_5min_w"] == [100.0, -50.0, 300.0, 0.0] + + def test_a_hole_in_the_discharge_series_keeps_the_charge_series_aligned( + self, lumentree_api_client + ) -> None: + merged = self._merged(lumentree_api_client, { + "bat": {"tableValue": 30, "tableValueInfo": [100, 300, 0, 200]}, + "batF": {"tableValue": 12, "tableValueInfo": [0, None, 50, 0]}, + }) + assert merged["battery_series_5min_w"] == [100.0, 300.0, -50.0, 200.0] + + def test_a_short_series_publishes_only_the_slots_it_reported( + self, lumentree_api_client + ) -> None: + """A device that stopped publishing has not reported the rest of the day. + + Nothing is invented for the slots the series never reached, so a + battery that went quiet at slot 2 does not draw a flat line at 0 W + across the remainder of the chart. + """ + merged = self._merged(lumentree_api_client, { + "bat": {"tableValue": 30, "tableValueInfo": [100, 300]}, + "batF": {"tableValue": 12, "tableValueInfo": [0, 50, None, None]}, + }) + assert merged["battery_series_5min_w"] == [100.0, 250.0] + + def test_no_battery_at_all_yields_no_series_and_no_discharge( + self, lumentree_api_client + ) -> None: + """Neither metric present means no battery reading, not a battery at 0.""" + merged = self._merged(lumentree_api_client, { + "pv": {"tableValue": 60, "tableValueInfo": [0, 0, 120, 240]}, + }) + assert "battery_series_5min_w" not in merged + assert "charge_today" not in merged + assert "discharge_today" not in merged + + def test_a_battery_that_reported_nothing_still_publishes_no_series( + self, lumentree_api_client + ) -> None: + """A present `bat` with no usable samples is the same answer as absent. + + The captured device reports `bat` with a 288-point series and a total + of 0, so presence alone cannot mean "there is a series to draw". + """ + merged = self._merged(lumentree_api_client, { + "bat": {"tableValue": 0, "tableValueInfo": [None, "x", None]}, + "batF": {"tableValue": None, "tableValueInfo": []}, + }) + assert "battery_series_5min_w" not in merged + assert merged["charge_today"] == 0.0 + assert merged["discharge_today"] == 0.0 + def test_empty_payload_does_not_raise(self, lumentree_api_client) -> None: """A response with no metrics at all yields no data rather than an error.""" assert self._merged(lumentree_api_client, {}) == {} @@ -193,6 +284,63 @@ def test_a_payload_with_no_usable_metric_is_indistinguishable_from_empty( assert self._merged(lumentree_api_client, {"pv": None, "grid": "nonsense"}) == {} +class TestLegacyBatteryPath: + """The legacy signed series must keep behaving exactly as it did. + + ``getBatDayData`` is still the fallback and still answers days the + combined endpoint cannot, so the frame rework in the combined merge must + not have changed what this path publishes. + """ + + def test_the_legacy_signed_series_is_negated_and_kept_whole( + self, lumentree_api_client + ) -> None: + """Positive on the wire means discharge; the sensor reads the opposite. + + The device is the authority here (docs/api/API_PROTOCOL.md), and the + negation is what makes the charge and discharge sensors point at the + right side. The zeros are real reported steps, so they stay. + """ + legacy = { + "/lesvr/getPVDayData": { + "returnValue": 1, + "data": {"pv": {"tableValue": 60, "tableValueInfo": []}}, + }, + "/lesvr/getBatDayData": { + "returnValue": 1, + "data": { + "bats": [{"tableValue": 30}, {"tableValue": 12}], + "tableValueInfo": [500, -200, 0], + }, + }, + "/lesvr/getOtherDayData": { + "returnValue": 1, + "data": { + "grid": {"tableValue": 116, "tableValueInfo": []}, + "homeload": {"tableValue": 170, "tableValueInfo": []}, + "essentialLoad": {"tableValue": 0, "tableValueInfo": []}, + }, + }, + } + session = _make_stub_session(legacy) + client = lumentree_api_client.LumentreeHttpApiClient(session=session) + client.set_token("stub-token") + + result = asyncio.run(client._fetch_battery_data({"deviceId": "H240909079"})) + + assert result["battery_series_5min_w"] == [-500.0, 200.0, 0.0] + assert result["charge_today"] == 3.0 + assert result["discharge_today"] == 1.2 + # The wire's +500 is a DISCHARGE, so it belongs to the discharge rollup; + # the -200 is the charge at slot 1 and is reported as +200 W. + assert result["battery_discharge_series_hour_kwh"][0] == pytest.approx( + 500 * (5 / 60) / 1000 + ) + assert result["battery_charge_series_hour_kwh"][0] == pytest.approx( + 200 * (5 / 60) / 1000 + ) + + def _combined_payload() -> dict: """A combined payload with one usable metric, for the fallback tests.""" return {"pv": {"tableValue": 60, "tableValueInfo": [0, 0, 120, 240]}} From b67cfeb6643c03b5158c86ce6cce88f132ede9cb Mon Sep 17 00:00:00 2001 From: Vokupt Date: Sun, 13 Sep 2026 10:08:14 +0700 Subject: [PATCH 15/24] no-mistakes(review): Refresh documented test counts after the series fix --- docs/DEVELOPMENT_NOTES.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/DEVELOPMENT_NOTES.md b/docs/DEVELOPMENT_NOTES.md index 79a5e1d..e1b40e0 100644 --- a/docs/DEVELOPMENT_NOTES.md +++ b/docs/DEVELOPMENT_NOTES.md @@ -11,12 +11,12 @@ There are two environments, and they collect different numbers: ```bash # CI's environment: pytest + aiohttp + paho + crcmod only. # Home Assistant is absent, so the end-to-end test skips. -python -m pytest tests/ -q # 67 passed, 6 skipped +python -m pytest tests/ -q # 75 passed, 6 skipped # The real end-to-end harness: a virtualenv with Home Assistant and # pytest-homeassistant-custom-component installed. The interpreter path is # local to the author's machine -- point this at your own environment. -"/Scripts/python.exe" -m pytest tests/ -q # 73 passed +"/Scripts/python.exe" -m pytest tests/ -q # 81 passed ``` The end-to-end test (`tests/test_e2e_mqtt_to_entity.py`) **skips**, it does not From 6140b11045c416d82ecf5fe4a4df1356dc9b60d8 Mon Sep 17 00:00:00 2001 From: Vokupt Date: Sun, 13 Sep 2026 10:32:48 +0700 Subject: [PATCH 16/24] no-mistakes(review): fold day series by slot, not by array index The hour rollup bucketed the flat 5-minute list by array position, so any series that was not exactly 288 long silently slid its readings into the wrong hours. A battery reporting a 500 W charge at slot 12 -- hour 1 -- with an unreadable entry at slot 1 reported that charge in hour 0 and left hour 1 empty. Every consumer that draws the hourly chart read the wrong hour, and nothing raised. Slot identity is now the unit the series math works in. A metric becomes a frame of (slot, value) pairs holding only the samples actually reported; charge/discharge signing, the total-load sum and the hour fold all key on the slot, so a hole shifts nothing downstream. The flat lists consumers read (pv/grid/load/essential/total-load/battery 5min_w and 5min_kwh) are projections of that frame, and the *_sum_kwh totals still sum reported readings -- both are position-independent and keep their values. Also fixes a latent frame-width bug: _slot_sum and _slot_difference bounded their slot loop by dict length, which under-counts once a frame has a hole and then drops the trailing slot. Documents, at the discharge normalisation, that a substituted 0 kWh is durable -- daily_coordinator writes it into the year cache on rollover -- so it is not corrected by a later poll. The predicate itself is unchanged. Test counts in DEVELOPMENT_NOTES.md re-measured: 80 passed / 6 skipped on a bare interpreter, 86 passed in the HA virtualenv. --- core/api_client.py | 221 ++++++++++++++------------------ docs/DEVELOPMENT_NOTES.md | 4 +- tests/test_day_stats_mapping.py | 102 +++++++++++++-- 3 files changed, 190 insertions(+), 137 deletions(-) diff --git a/core/api_client.py b/core/api_client.py index 9e26734..6613874 100644 --- a/core/api_client.py +++ b/core/api_client.py @@ -74,37 +74,35 @@ def _to_float_list(vals: Any) -> list[float]: return [] @staticmethod - def _series_5min_kwh(series_w: list[float]) -> list[float]: + def _series_5min_kwh(series_w: list[tuple[int, float]]) -> list[tuple[int, float]]: # Convert W (5‑minute interval) → kWh for each step - keep full precision factor = (5.0 / 60.0) / 1000.0 - return [w * factor for w in series_w] + return [(slot, w * factor) for slot, w in series_w] @staticmethod - def _series_hour_kwh(series_kwh5: list[float]) -> list[float]: - # 12 steps of 5‑min per hour + def _series_hour_kwh(series_kwh5: list[tuple[int, float]]) -> list[float]: + """Fold a 5-minute kWh frame into 24 hourly sums. + + The hour comes from the slot, not from the array index. That is the + whole reason this helper takes a frame: a series with a hole in it + keeps every later sample in the hour it was reported for, and a day + that stopped early leaves the remaining hours at zero instead of + pulling the readings it did send backwards. Bucketing the flat list + by index did exactly that damage, and did it silently. + """ if not series_kwh5: return [] - hours = [] - for h in range(24): - start = h * 12 - end = start + 12 - if start >= len(series_kwh5): - hours.append(0.0) - else: - hours.append(sum(series_kwh5[start:end])) # Keep full precision + hours = [0.0] * 24 + for slot, value in series_kwh5: + hour = slot // 12 # 12 steps of 5 minutes per hour + if 0 <= hour < 24: + hours[hour] += value # Keep full precision return hours @staticmethod - def _reported_values(frame: list[tuple[int, float | None]]) -> list[float]: - """Fold a frame back into the flat list of readings callers read. - - A slot nobody reported is dropped rather than written as 0 W: a hole - is not a reading, and stretching a series that stopped early out to the - width of its sibling would put invented readings on the chart. Losing - the width costs nothing -- the consumers that fold this list into hours - read a day shorter than 288 slots as zeros for the rest. - """ - return [value for _, value in frame if value is not None] + def _values(frame: list[tuple[int, float]]) -> list[float]: + """A frame's readings in slot order -- the flat lists consumers read.""" + return [value for _, value in frame] @staticmethod def _sum(series: list[float]) -> float: @@ -138,118 +136,97 @@ def _metric_total_kwh(metric: Any) -> float | None: return None @staticmethod - def _readings(vals: Any) -> list[float | None]: - """Float a raw series, marking entries that cannot be read as None. + def _slot_readings(metric: Any) -> list[tuple[int, float]]: + """A metric's reported 5-minute readings, each keyed by its slot. - Unlike a filter, this keeps the list exactly as wide as the wire, so a - sample's index is still the 5-minute slot it was reported for. A hole - is not a reading, so it survives as None rather than as 0 W and does - not slide the samples after it onto the wrong slot. + This is the identity a series has to keep: a sample's hour is derived + from the slot it was reported for, so an unreadable entry at slot n + must not pull the samples after it one slot earlier. Only reported + values are framed; a hole simply has no entry. """ + if not isinstance(metric, dict): + return [] + vals = metric.get("tableValueInfo") if not isinstance(vals, list): return [] - readings: list[float | None] = [] - for value in vals: + frame: list[tuple[int, float]] = [] + for slot, value in enumerate(vals): try: - readings.append(float(value)) + frame.append((slot, float(value))) except (TypeError, ValueError): - readings.append(None) - return readings - - @staticmethod - def _metric_readings(metric: Any) -> list[float | None]: - """Return a metric's 5-minute series positionally (empty when absent).""" - if not isinstance(metric, dict): - return [] - return LumentreeHttpApiClient._readings(metric.get("tableValueInfo")) - - @classmethod - def _metric_series_w(cls, metric: Any) -> list[float]: - """Return a metric's reported 5-minute values in W (empty when absent).""" - return [value for value in cls._metric_readings(metric) if value is not None] - - @staticmethod - def _frame(values: list[float | None]) -> list[tuple[int, float | None]]: - """Pair each sample with the 5-minute slot it was reported for. - - The frame is as wide as the series itself -- a short series contributes - nothing to the slots it never sent rather than zeros, and no payload in - this repo reports a full day of battery discharge to widen one against. - Consumers that fold the list into hours read a missing slot as 0 kWh. - """ - return [(slot, value) for slot, value in enumerate(values)] + continue + return frame @staticmethod def _slot_sum( - left: list[tuple[int, float | None]], - right: list[tuple[int, float | None]], - ) -> list[tuple[int, float | None]]: - """Add two frames slot by slot; an unreported slot contributes nothing. + left: list[tuple[int, float]], + right: list[tuple[int, float]], + ) -> list[tuple[int, float]]: + """Add two frames slot by slot. Used for the total-load series, where the two inputs are magnitudes - that simply add. A slot neither input reported stays unreported. + that simply add. A slot neither input reported contributes no entry. """ a = dict(left) b = dict(right) - summed: list[tuple[int, float | None]] = [] - for slot in range(max(len(a), len(b))): + summed: list[tuple[int, float]] = [] + for slot in range(max([*a, *b], default=-1) + 1): x = a.get(slot) y = b.get(slot) - summed.append((slot, None if x is None and y is None else float(x or 0.0) + float(y or 0.0))) + if x is None and y is None: + continue + summed.append((slot, float(x or 0.0) + float(y or 0.0))) return summed @staticmethod def _slot_difference( - charge_slots: list[tuple[int, float | None]], - discharge_slots: list[tuple[int, float | None]], - ) -> list[tuple[int, float | None]]: + charge_slots: list[tuple[int, float]], + discharge_slots: list[tuple[int, float]], + ) -> list[tuple[int, float]]: """Sign the two unsigned battery frames against each other, by slot. Charge reported and no discharge that slot means +charge; discharge reported with no charge means -discharge; both reported means the - difference. A slot neither series reported stays None, so a hole in + difference. A slot neither series reported has no entry, so a hole in one frame cannot book a phantom reading derived from the other. """ charge = dict(charge_slots) discharge = dict(discharge_slots) - width = max(len(charge), len(discharge)) - signed: list[tuple[int, float | None]] = [] - for slot in range(width): + signed: list[tuple[int, float]] = [] + for slot in range(max([*charge, *discharge], default=-1) + 1): c = charge.get(slot) d = discharge.get(slot) - if c is None and d is None: - signed.append((slot, None)) - elif d is None: - signed.append((slot, c)) - elif c is None: - signed.append((slot, -d)) - else: + if c is not None and d is not None: signed.append((slot, c - d)) + elif d is not None: + signed.append((slot, -d)) + elif c is not None: + signed.append((slot, c)) return signed @classmethod def _build_pv_result(cls, metric: Any) -> dict[str, Any]: result: dict[str, Any] = {"pv_today": cls._metric_total_kwh(metric)} - series_w = cls._metric_series_w(metric) - if series_w: - series_kwh5 = cls._series_5min_kwh(series_w) + series = cls._slot_readings(metric) + if series: + series_kwh5 = cls._series_5min_kwh(series) result.update({ - "pv_series_5min_w": series_w, - "pv_series_5min_kwh": series_kwh5, + "pv_series_5min_w": cls._values(series), + "pv_series_5min_kwh": cls._values(series_kwh5), "pv_series_hour_kwh": cls._series_hour_kwh(series_kwh5), - "pv_sum_kwh": cls._sum(series_kwh5), + "pv_sum_kwh": cls._sum(cls._values(series_kwh5)), }) return result @classmethod def _build_grid_result(cls, metric: Any) -> dict[str, Any]: result: dict[str, Any] = {"grid_in_today": cls._metric_total_kwh(metric)} - series_w = cls._metric_series_w(metric) - if series_w: - g5 = cls._series_5min_kwh(series_w) + series = cls._slot_readings(metric) + if series: + g5 = cls._series_5min_kwh(series) result.update({ - "grid_series_5min_w": series_w, - "grid_series_5min_kwh": g5, + "grid_series_5min_w": cls._values(series), + "grid_series_5min_kwh": cls._values(g5), "grid_series_hour_kwh": cls._series_hour_kwh(g5), }) return result @@ -273,51 +250,46 @@ def _build_load_result(cls, load_metric: Any, essential_metric: Any) -> dict[str if load_total is not None or essential_total is not None: result["total_load_today"] = float(load_total or 0.0) + float(essential_total or 0.0) - load_readings = cls._metric_readings(load_metric) - essential_readings = cls._metric_readings(essential_metric) - load_series_w = [value for value in load_readings if value is not None] - essential_series_w = [value for value in essential_readings if value is not None] + load_series = cls._slot_readings(load_metric) + essential_series = cls._slot_readings(essential_metric) - if load_series_w: - l5 = cls._series_5min_kwh(load_series_w) + if load_series: + l5 = cls._series_5min_kwh(load_series) result.update({ - "load_series_5min_w": load_series_w, - "load_series_5min_kwh": l5, + "load_series_5min_w": cls._values(load_series), + "load_series_5min_kwh": cls._values(l5), "load_series_hour_kwh": cls._series_hour_kwh(l5), }) - if essential_series_w: - e5 = cls._series_5min_kwh(essential_series_w) + if essential_series: + e5 = cls._series_5min_kwh(essential_series) result.update({ - "essential_series_5min_w": essential_series_w, - "essential_series_5min_kwh": e5, + "essential_series_5min_w": cls._values(essential_series), + "essential_series_5min_kwh": cls._values(e5), "essential_series_hour_kwh": cls._series_hour_kwh(e5), }) - if load_readings and essential_readings: + if load_series and essential_series: # Slots, not array positions: a short series contributes nothing to # the slots it never reported instead of silently adding its zeros # to the tail of the longer one. - total_load_w = cls._reported_values(cls._slot_sum( - cls._frame(load_readings), - cls._frame(essential_readings), - )) - total_load_kwh5 = cls._series_5min_kwh(total_load_w) + total_load = cls._slot_sum(load_series, essential_series) + total_load_kwh5 = cls._series_5min_kwh(total_load) result.update({ - "total_load_series_5min_w": total_load_w, - "total_load_series_5min_kwh": total_load_kwh5, + "total_load_series_5min_w": cls._values(total_load), + "total_load_series_5min_kwh": cls._values(total_load_kwh5), "total_load_series_hour_kwh": cls._series_hour_kwh(total_load_kwh5), }) if "total_load_today" not in result: - result["total_load_today"] = cls._sum(total_load_kwh5) + result["total_load_today"] = cls._sum(cls._values(total_load_kwh5)) return result @classmethod def _build_battery_result( cls, - series_slots: list[tuple[int, float | None]], + series_slots: list[tuple[int, float]], charge_today: float | None, discharge_today: float | None, ) -> dict[str, Any]: @@ -335,20 +307,16 @@ def _build_battery_result( "charge_today": charge_today, "discharge_today": discharge_today, } - reported = cls._reported_values(series_slots) - if not reported: + if not series_slots: return result # The hourly rollups keep one slot each, so a short day still lands in # the right hours. An unreported slot contributes no energy to either # side instead of being booked as an idle step. factor = (5.0 / 60.0) / 1000.0 - charge_kwh5: list[float] = [] - discharge_kwh5: list[float] = [] - for value in reported: - charge_kwh5.append(value * factor if value > 0 else 0.0) - discharge_kwh5.append(abs(value) * factor if value < 0 else 0.0) + charge_kwh5 = [(slot, value * factor) for slot, value in series_slots if value > 0] + discharge_kwh5 = [(slot, abs(value) * factor) for slot, value in series_slots if value < 0] result.update({ - "battery_series_5min_w": reported, + "battery_series_5min_w": cls._values(series_slots), "battery_charge_series_hour_kwh": cls._series_hour_kwh(charge_kwh5), "battery_discharge_series_hour_kwh": cls._series_hour_kwh(discharge_kwh5), }) @@ -897,10 +865,9 @@ async def _fetch_battery_data(self, base_params: dict[str, str]) -> dict[str, An # This endpoint's series is signed with positive meaning DISCHARGE # (which contradicts the old API_PROTOCOL.md; the device is the # authority). Negate so the shared builder sees positive = charge. - readings = self._readings(data.get("tableValueInfo")) - series_slots = self._frame( - [None if w is None else -w for w in readings] - ) + series_slots = [ + (slot, -value) for slot, value in self._slot_readings(data) + ] return self._build_battery_result(series_slots, charge_today, discharge_today) except (ApiException, AuthException) as exc: _LOGGER.warning(f"Failed battery stats ({type(exc).__name__}): {exc}") @@ -969,8 +936,8 @@ def _merge_all_day_payload(cls, payload: Any) -> dict[str, Any]: # Signing is by slot, so an entry that cannot be read in one series # cannot shift the other's samples onto the wrong 5-minute step. signed_slots = cls._slot_difference( - cls._frame(cls._metric_readings(data.get("bat"))), - cls._frame(cls._metric_readings(data.get("batF"))), + cls._slot_readings(data.get("bat")), + cls._slot_readings(data.get("batF")), ) charge_today = cls._metric_total_kwh(data.get("bat")) @@ -985,6 +952,10 @@ def _merge_all_day_payload(cls, payload: Any) -> dict[str, Any]: # as an explicit 0 on a day with no discharge. Counterfactual: a day # that discharged while omitting batF is reported as 0 kWh rather # than unknown, and no payload in this repo can rule that out. + # That wrong 0 is durable: daily_coordinator persists the day's + # discharge into the year cache on rollover, so the substituted value + # outlives the response that produced it and is not corrected by a + # later poll. if charge_today is not None and discharge_today is None: discharge_today = 0.0 diff --git a/docs/DEVELOPMENT_NOTES.md b/docs/DEVELOPMENT_NOTES.md index e1b40e0..224aff2 100644 --- a/docs/DEVELOPMENT_NOTES.md +++ b/docs/DEVELOPMENT_NOTES.md @@ -11,12 +11,12 @@ There are two environments, and they collect different numbers: ```bash # CI's environment: pytest + aiohttp + paho + crcmod only. # Home Assistant is absent, so the end-to-end test skips. -python -m pytest tests/ -q # 75 passed, 6 skipped +python -m pytest tests/ -q # 80 passed, 6 skipped # The real end-to-end harness: a virtualenv with Home Assistant and # pytest-homeassistant-custom-component installed. The interpreter path is # local to the author's machine -- point this at your own environment. -"/Scripts/python.exe" -m pytest tests/ -q # 81 passed +"/Scripts/python.exe" -m pytest tests/ -q # 86 passed ``` The end-to-end test (`tests/test_e2e_mqtt_to_entity.py`) **skips**, it does not diff --git a/tests/test_day_stats_mapping.py b/tests/test_day_stats_mapping.py index 77093d7..e085443 100644 --- a/tests/test_day_stats_mapping.py +++ b/tests/test_day_stats_mapping.py @@ -14,6 +14,12 @@ battery as a charging one, with no error anywhere. * ``batF`` absence. With no discharge, the combined endpoint omits ``batF`` entirely rather than returning zeros. Code that indexes it raises. +* Slot identity. A sample's position in ``tableValueInfo`` is the 5-minute + slot it was reported for, and both the charge/discharge split and the hourly + rollup have to key on that slot. Dropping an unreadable entry and then + working positionally slides every later sample one slot earlier, which books + charge as discharge and files readings under the wrong hour. This is the one + a hole-free fixture cannot catch, so the fixtures here include holes. The payload literals below are modelled on a capture from the real device rather than invented, so the builders are pinned to a real shape; they do not @@ -75,7 +81,29 @@ def test_unusable_totals_come_back_as_none(self, lumentree_api_client, value) -> ) is None def test_missing_metric_yields_no_series(self, lumentree_api_client) -> None: - assert lumentree_api_client.LumentreeHttpApiClient._metric_series_w(None) == [] + assert lumentree_api_client.LumentreeHttpApiClient._slot_readings(None) == [] + + def test_each_sample_is_keyed_by_the_slot_it_was_reported_for( + self, lumentree_api_client + ) -> None: + """A slot is the sample's position on the wire, holes included. + + An entry that cannot be read is dropped, not shifted: the 300 at wire + index 2 stays at slot 2 rather than sliding to slot 1. Every hour + fold downstream reads the slot, so this is the identity the whole + mapping rests on. + """ + frame = lumentree_api_client.LumentreeHttpApiClient._slot_readings( + {"tableValueInfo": [100, "x", 300]} + ) + assert frame == [(0, 100.0), (2, 300.0)] + + def test_a_series_with_no_readable_entry_frames_as_empty( + self, lumentree_api_client + ) -> None: + assert lumentree_api_client.LumentreeHttpApiClient._slot_readings( + {"tableValueInfo": [None, "x", {}]} + ) == [] class TestBatterySignConvention: @@ -127,27 +155,29 @@ def test_hourly_rollup_may_contain_both_charge_and_discharge(self, lumentree_api assert built["battery_charge_series_hour_kwh"][0] > 0 assert built["battery_discharge_series_hour_kwh"][0] > 0 - def test_unreported_slots_are_dropped_rather_than_written_as_zero( + def test_unreported_slots_are_not_published_as_readings( self, lumentree_api_client ) -> None: """A slot nobody reported is not a reading, and must not become a 0 W one. Downstream (entities/sensor.py) publishes this list and turns it into kWh, so a hole written as 0.0 W is a chart point that claims the - battery was idle at a moment the device said nothing about. + battery was idle at a moment the device said nothing about. The frame + simply has no entry for slot 1, so the flat list is two long -- and + slot 2's -300 W still folds into hour 0, where it was reported. """ built = lumentree_api_client.LumentreeHttpApiClient._build_battery_result( - [(0, 500.0), (1, None), (2, -300.0)], None, None + [(0, 500.0), (2, -300.0)], None, None ) assert built["battery_series_5min_w"] == [500.0, -300.0] + assert built["battery_discharge_series_hour_kwh"][0] == pytest.approx( + 300 * (5 / 60) / 1000 + ) def test_a_frame_with_no_reading_at_all_yields_no_series(self, lumentree_api_client) -> None: - """An empty or all-unreported frame must not publish an empty series.""" + """An empty frame must not publish an empty series.""" client = lumentree_api_client.LumentreeHttpApiClient assert "battery_series_5min_w" not in client._build_battery_result([], None, None) - assert "battery_series_5min_w" not in client._build_battery_result( - [(0, None), (1, None)], None, None - ) class TestAllDayDataMapping: @@ -189,7 +219,58 @@ def test_missing_batf_does_not_raise(self, lumentree_api_client) -> None: def test_absent_batf_does_not_truncate_the_charge_series(self, lumentree_api_client) -> None: """The charge series keeps its full length when discharge is absent.""" merged = self._merged(lumentree_api_client, ALL_DAY_NO_DISCHARGE) - assert len(merged["battery_series_5min_w"]) == 4 + assert merged["battery_series_5min_w"] == [0.0, 0.0, 0.0, 0.0] + + def test_the_hour_fold_keeps_each_sample_in_its_own_hour( + self, lumentree_api_client + ) -> None: + """The battery reproduction: a hole must not pull later readings back. + + `bat` reports a 500 W charge at slot 12 -- the first step of hour 1 -- + and cannot be read at slot 1. Folding the flat list by array index put + that charge in hour 0 and left hour 1 empty, because dropping the hole + shortened the list. The hour comes from the slot, so it lands in hour + 1 where it was actually reported. + """ + merged = self._merged(lumentree_api_client, { + "bat": {"tableValue": 30, "tableValueInfo": [0, None] + [0.0] * 10 + [500.0]}, + }) + rollup = merged["battery_charge_series_hour_kwh"] + assert rollup[0] == 0.0 + assert rollup[1] == pytest.approx(500 * (5 / 60) / 1000) + # The flat list keeps only the readings that were sent. + assert len(merged["battery_series_5min_w"]) == 12 + + def test_the_pv_hour_fold_keeps_each_sample_in_its_own_hour( + self, lumentree_api_client + ) -> None: + """The same reproduction on the PV metric, which has no battery path. + + PV goes straight from the payload to the fold, so if only the battery + builder were slot-aware this is the one that would still be wrong. + """ + merged = self._merged(lumentree_api_client, { + "pv": {"tableValue": 60, "tableValueInfo": [0, None] + [0.0] * 10 + [500.0]}, + }) + assert merged["pv_series_hour_kwh"][0] == 0.0 + assert merged["pv_series_hour_kwh"][1] == pytest.approx(500 * (5 / 60) / 1000) + + def test_a_hole_free_series_folds_exactly_as_before( + self, lumentree_api_client + ) -> None: + """Ordinary payloads must be untouched by the slot-aware fold. + + Every slot reported means slot == index for every sample, so the + rollup has to equal the plain index-based arithmetic it replaced. + """ + merged = self._merged(lumentree_api_client, ALL_DAY_WITH_DISCHARGE) + step = (5 / 60) / 1000 + # pv [0, 0, 120, 240] all inside hour 0 + assert merged["pv_series_hour_kwh"][0] == pytest.approx(360 * step) + assert merged["pv_sum_kwh"] == pytest.approx(360 * step) + # bat [0, 500, 0, 0] / batF [0, 0, 300, 0] + assert merged["battery_charge_series_hour_kwh"][0] == pytest.approx(500 * step) + assert merged["battery_discharge_series_hour_kwh"][0] == pytest.approx(300 * step) def test_battery_series_is_signed_charge_minus_discharge(self, lumentree_api_client) -> None: @@ -207,7 +288,8 @@ def test_a_hole_in_one_series_does_not_shift_the_other_series( every later charge sample slid one slot against `batF`. The reported result was [100, 250, 0, 0]: the 300 W charge step was booked as discharge and the real 50 W discharge step disappeared. Slot-signing - gives [100, -50, 300]: the hole is the only thing dropped. + gives [100, -50, 300, 0]: the hole is the only thing dropped, and the + reported 0 at slot 3 is a reading, so it stays. """ merged = self._merged(lumentree_api_client, { "bat": {"tableValue": 30, "tableValueInfo": [100, None, 300, 0]}, From ccd9490f9a05e4d3c8856b7594634c261ea49229 Mon Sep 17 00:00:00 2001 From: Vokupt Date: Sun, 13 Sep 2026 10:58:34 +0700 Subject: [PATCH 17/24] no-mistakes(review): keep both battery hour rollups on a one-sided day The slot rework derived each hourly battery side from a filtered comprehension, so a day with only charge produced an empty charge-eligible list and the discharge rollup was published as [] instead of 24 zeros. Base always emitted 24 entries -- it kept one slot per frame position and zero-filled the idle side -- so this was a regression in the same shape the round was fixing, on the documented no-discharge case the batF normalisation exists for. Both day endpoints reach it: the combined path whenever batF is absent with charge present, and the legacy getBatDayData fallback on a day that only discharged. Both sides are now built over every slot the frame reported, zero-filling the opposite side, which is what makes "this side was idle" and "there was no battery data" distinguishable answers. The absent-frame early-out is unchanged, so a payload with no battery metric still publishes no series. Also corrects the API_PROTOCOL caveat: the gloss on the captured bat series read "(0.157 kWh)", implying 0.01 kWh per series unit, where the client reads each sample as watts over a 5-minute slot. The 157 W day is about 0.013 kWh, so the parenthetical stated a factor 12x off from the shipping conversion. The load-bearing claim -- tableValue 0 against a non-zero series -- is unchanged; the raw series sum is no longer presented as interchangeable with the total's 0.1 kWh units. Test counts re-measured: 83 passed / 6 skipped bare, 89 passed in the HA virtualenv. --- core/api_client.py | 15 +++++++--- docs/DEVELOPMENT_NOTES.md | 4 +-- docs/api/API_PROTOCOL.md | 10 ++++--- tests/test_day_stats_mapping.py | 52 +++++++++++++++++++++++++++++++++ 4 files changed, 71 insertions(+), 10 deletions(-) diff --git a/core/api_client.py b/core/api_client.py index 6613874..4c7f2e3 100644 --- a/core/api_client.py +++ b/core/api_client.py @@ -310,11 +310,18 @@ def _build_battery_result( if not series_slots: return result # The hourly rollups keep one slot each, so a short day still lands in - # the right hours. An unreported slot contributes no energy to either - # side instead of being booked as an idle step. + # the right hours. Both sides are published over the slots the frame + # reported, so a day that only ever charged still carries a 24-entry + # discharge series of zeros: "this side was idle" and "there was no + # battery data at all" are different answers, and only the second one + # is allowed to publish no series. factor = (5.0 / 60.0) / 1000.0 - charge_kwh5 = [(slot, value * factor) for slot, value in series_slots if value > 0] - discharge_kwh5 = [(slot, abs(value) * factor) for slot, value in series_slots if value < 0] + charge_kwh5 = [ + (slot, value * factor if value > 0 else 0.0) for slot, value in series_slots + ] + discharge_kwh5 = [ + (slot, abs(value) * factor if value < 0 else 0.0) for slot, value in series_slots + ] result.update({ "battery_series_5min_w": cls._values(series_slots), "battery_charge_series_hour_kwh": cls._series_hour_kwh(charge_kwh5), diff --git a/docs/DEVELOPMENT_NOTES.md b/docs/DEVELOPMENT_NOTES.md index 224aff2..cdbc475 100644 --- a/docs/DEVELOPMENT_NOTES.md +++ b/docs/DEVELOPMENT_NOTES.md @@ -11,12 +11,12 @@ There are two environments, and they collect different numbers: ```bash # CI's environment: pytest + aiohttp + paho + crcmod only. # Home Assistant is absent, so the end-to-end test skips. -python -m pytest tests/ -q # 80 passed, 6 skipped +python -m pytest tests/ -q # 83 passed, 6 skipped # The real end-to-end harness: a virtualenv with Home Assistant and # pytest-homeassistant-custom-component installed. The interpreter path is # local to the author's machine -- point this at your own environment. -"/Scripts/python.exe" -m pytest tests/ -q # 86 passed +"/Scripts/python.exe" -m pytest tests/ -q # 89 passed ``` The end-to-end test (`tests/test_e2e_mqtt_to_entity.py`) **skips**, it does not diff --git a/docs/api/API_PROTOCOL.md b/docs/api/API_PROTOCOL.md index 952d27c..7564501 100644 --- a/docs/api/API_PROTOCOL.md +++ b/docs/api/API_PROTOCOL.md @@ -65,10 +65,12 @@ series-level equivalence between the two sources is **not** established. - **Caveat**: a metric's `tableValue` total and its `tableValueInfo` series can disagree — on the captured device `bat.tableValue` is 0 while its 288-point - series sums to 157 (0.157 kWh). The daily sensor reports the total, so it can - read 0 while its own series attribute is non-zero. This is a vendor data - property, not a mapping bug; `getBatDayData` read `bats[0].tableValue` the - same way, so it predates the combined endpoint. + series sums to 157. The daily sensor reports the total, so it can read 0 + while its own series attribute is non-zero. This is a vendor data property, + not a mapping bug; `getBatDayData` read `bats[0].tableValue` the same way, so + it predates the combined endpoint. Series samples are **watts** over a + 5-minute slot, so that 157 W day is roughly 0.013 kWh, not 0.157 — the raw + series sum is not interchangeable with the total's 0.1 kWh units. - **Path note**: the APK contains `lesvr/v2/getAllDayData` because app 3.2.4 targets a different host (`lesvrjm.suntcn.com`), where `v2/` is correct. Against `lesvr.suntcn.com` the `v2/` form answers `998` (does not exist) and diff --git a/tests/test_day_stats_mapping.py b/tests/test_day_stats_mapping.py index e085443..458c722 100644 --- a/tests/test_day_stats_mapping.py +++ b/tests/test_day_stats_mapping.py @@ -273,6 +273,58 @@ def test_a_hole_free_series_folds_exactly_as_before( assert merged["battery_discharge_series_hour_kwh"][0] == pytest.approx(300 * step) + def test_a_charge_only_day_publishes_a_full_discharge_rollup( + self, lumentree_api_client + ) -> None: + """A day that only ever charged still has a discharge series -- of zeros. + + This is the documented no-discharge case: `bat` is present with the + charge steps and `batF` is absent. Both chart attributes have to carry + 24 entries, or the discharge chart renders blank instead of flat. + """ + merged = self._merged(lumentree_api_client, { + "bat": {"tableValue": 30, "tableValueInfo": [0.0, 0.0, 300.0, 0.0]}, + }) + discharge = merged["battery_discharge_series_hour_kwh"] + assert len(discharge) == 24 + assert sum(discharge) == 0.0 + assert merged["battery_charge_series_hour_kwh"][0] == pytest.approx( + 300 * (5 / 60) / 1000 + ) + + def test_a_discharge_only_day_publishes_a_full_charge_rollup( + self, lumentree_api_client + ) -> None: + """The mirror: an all-discharge frame still reports a 24-entry charge series. + + Reached from the legacy `getBatDayData` fallback, whose wire series is + negated before it gets here, so a pure-discharge day arrives as a frame + with no positive sample at all. + """ + built = lumentree_api_client.LumentreeHttpApiClient._build_battery_result( + [(0, -0.0), (1, -0.0), (2, -300.0), (3, -0.0)], None, None + ) + charge = built["battery_charge_series_hour_kwh"] + assert len(charge) == 24 + assert sum(charge) == 0.0 + assert built["battery_discharge_series_hour_kwh"][0] == pytest.approx( + 300 * (5 / 60) / 1000 + ) + + def test_a_frame_with_no_battery_at_all_still_publishes_no_rollup( + self, lumentree_api_client + ) -> None: + """The idle-side fix must not undo the absent-frame early-out. + + "This side was idle" and "there was no battery data" are different + answers, and only the first one gets a zero-filled series. + """ + merged = self._merged(lumentree_api_client, { + "pv": {"tableValue": 60, "tableValueInfo": [0, 0, 120, 240]}, + }) + assert "battery_charge_series_hour_kwh" not in merged + assert "battery_discharge_series_hour_kwh" not in merged + def test_battery_series_is_signed_charge_minus_discharge(self, lumentree_api_client) -> None: """Two unsigned series become one signed series, positive = charge.""" merged = self._merged(lumentree_api_client, ALL_DAY_WITH_DISCHARGE) From 6437167eac778eed32d6b2bf0065edb765178cf6 Mon Sep 17 00:00:00 2001 From: Vokupt Date: Sun, 13 Sep 2026 11:48:00 +0700 Subject: [PATCH 18/24] no-mistakes(review): remember a missing combined endpoint instead of re-probing it Every poll asked the host for /lesvr/getAllDayData before falling back, so a host that answers 998 -- "this endpoint does not exist" -- paid one wasted request per poll forever and logged the fallback notice just as often. 998 is a property of the host, not of the device or the day, so it cannot change on the next poll. The client now records it. get_all_day_data sets _all_day_data_absent when the combined call is answered 998, and get_daily_stats checks that flag before calling -- when set the retry ladder starts at the three legacy per-metric calls. The combined endpoint stays the primary path and is still attempted on a host that has it; nothing was reordered to legacy-first. Detecting 998 specifically needed a code, and the exception carried it only inside its message string. ApiException now takes an optional code, set at the one raise site that reads the vendor's returnValue, so the handler tests the number rather than the prose. Only 998 sets the flag: a transport error or any other return value leaves it alone, so one dropped connection cannot demote the host for the life of the client. The flag is not per-date or per-device and carries no TTL, and it is declared in __slots__, which the class requires of any new attribute. Tests: after one 998 a second get_daily_stats issues the three legacy calls only and still returns the legacy result; a ServerConnectionError does not set the flag, so the next poll probes the combined endpoint again. Both fail before this change -- the second on the assertion that the endpoint was re-probed, not incidentally -- and pass after it. The stub session gained the ability to raise instead of answer, which is what stages the transport failure; the attempt is recorded before the raise so a retry stays countable. Also corrects two documentation claims the code contradicts. API_ENDPOINTS_DISCOVERED.md section 7.2 said data.batF was dropped while the titleParams entry carried "an empty tableValueInfo". The probe records a missing key and tableValueInfo: null -- null, not []. Both halves are now stated, with the probe evidence, because a reader that guards only against an empty list still falls into the null case; the instruction that code reading data["batF"] must tolerate the absent key is unchanged. The same null-vs-empty wording is fixed in API_PROTOCOL.md, and the section 4 table no longer lists batF as part of the DayData payload on the strength of a probe that did not contain it. API_PROTOCOL.md's "Data Units" section said tableValueInfo samples were in 0.1 kWh and offered value * 120 as a watt conversion. The code reads each sample as watts over a 5-minute slot, so per-slot energy is sample / 12000; the old formula encoded the 0.1 kWh assumption and (value * 0.1) / (5/60) * 1000 evaluates to value * 1200, which is what the watts reading of that assumption would give. The section now states the watts reading, keeps the total's 0.1 kWh units, notes that the two are therefore not interchangeable -- the same point the batF caveat makes -- and corrects the PV Day Data entry, which gave the 288-point series no unit at all. Documentation only: no runtime path reads titleParams, and no code changed for these two items. Test counts re-measured: 85 passed / 6 skipped bare, 91 passed in the HA virtualenv. --- core/api_client.py | 52 +++++++++++++++---- core/exceptions.py | 15 ++++-- docs/DEVELOPMENT_NOTES.md | 4 +- docs/api/API_ENDPOINTS_DISCOVERED.md | 8 +-- docs/api/API_PROTOCOL.md | 47 +++++++++++++---- tests/test_day_stats_mapping.py | 75 +++++++++++++++++++++++++++- 6 files changed, 170 insertions(+), 31 deletions(-) diff --git a/core/api_client.py b/core/api_client.py index 4c7f2e3..1e5f6b6 100644 --- a/core/api_client.py +++ b/core/api_client.py @@ -38,11 +38,16 @@ API_RETRY_BASE_DELAY = 1.0 # Start with 1 second API_RETRY_MAX_DELAY = 10.0 # Cap at 10 seconds +# The vendor's catch-all "this endpoint does not exist" answer. It is a +# property of the host, so it is worth remembering; see +# `_all_day_data_absent` and docs/api/API_ENDPOINTS_DISCOVERED.md. +RETURN_VALUE_ENDPOINT_MISSING = 998 + class LumentreeHttpApiClient: """HTTP API client for Lumentree cloud services.""" - __slots__ = ("_session", "_token", "_device_info_cache") + __slots__ = ("_session", "_token", "_device_info_cache", "_all_day_data_absent") _CACHE_TIMEOUT = 3600 # 1 hour @@ -55,6 +60,10 @@ def __init__(self, session: aiohttp.ClientSession) -> None: self._session = session self._token: str | None = None self._device_info_cache: dict[str, tuple[dict[str, Any], float]] = {} + # Set once the host has answered 998 for the combined day endpoint. + # 998 means "endpoint does not exist", which is a property of the host, + # not of the device or the day, so it holds for the client's lifetime. + self._all_day_data_absent = False # --------------------------- # Helpers for statistics @@ -432,7 +441,9 @@ async def _request( f"Auth failed (code={return_value}, status={response.status}): {msg}" ) - raise ApiException(f"API error: {msg} (code={return_value})") + raise ApiException( + f"API error: {msg} (code={return_value})", code=return_value + ) # Success - reset delay for next request delay = API_RETRY_BASE_DELAY @@ -686,6 +697,11 @@ async def get_daily_stats(self, device_identifier: str, query_date: str) -> dict rather than function. An empty result means exactly that for both sources, so this test is the endpoint's own "no data" answer either way. + One failure is not retried: a 998 ("endpoint does not exist") is a + property of the host, so it is cached on the client and every later + poll skips straight to the legacy calls. The fallback notice above + therefore fires at most once per client rather than once per poll. + Args: device_identifier: Device ID or serial number query_date: Date in YYYY-MM-DD format @@ -696,15 +712,21 @@ async def get_daily_stats(self, device_identifier: str, query_date: str) -> dict if _LOGGER.isEnabledFor(logging.DEBUG): _LOGGER.debug("Fetching daily stats for %s @ %s", device_identifier, query_date) - combined = await self.get_all_day_data(device_identifier, query_date) - if combined: - return combined - - _LOGGER.info( - "Combined day endpoint returned no data for %s @ %s; " - "falling back to the three per-metric endpoints", - device_identifier, query_date, - ) + if self._all_day_data_absent: + # A previous poll was told this host has no combined day endpoint. + # Asking again would buy the same 998, so the retry ladder starts at + # the legacy calls and the fallback notice below stays quiet. + combined: dict[str, Any] = {} + else: + combined = await self.get_all_day_data(device_identifier, query_date) + if combined: + return combined + + _LOGGER.info( + "Combined day endpoint returned no data for %s @ %s; " + "falling back to the three per-metric endpoints", + device_identifier, query_date, + ) base_params = {"deviceId": device_identifier, "queryDate": query_date} @@ -1007,6 +1029,14 @@ async def get_all_day_data(self, device_identifier: str, query_date: str) -> dic return self._merge_all_day_payload(resp.get("data")) except (ApiException, AuthException) as exc: + if getattr(exc, "code", None) == RETURN_VALUE_ENDPOINT_MISSING: + # The host answered "no such endpoint" for the combined day + # endpoint. That will not change on a later poll, so record it + # and stop paying for the request; `get_daily_stats` reads the + # flag and goes straight to the legacy calls. Only this code + # sets it -- a transport error or any other return value leaves + # it alone, so the combined endpoint is still retried. + self._all_day_data_absent = True _LOGGER.warning(f"Failed all-day stats ({type(exc).__name__}): {exc}") return {} except Exception: diff --git a/core/exceptions.py b/core/exceptions.py index 8908115..8d17293 100644 --- a/core/exceptions.py +++ b/core/exceptions.py @@ -8,9 +8,18 @@ class LumentreeException(Exception): class ApiException(LumentreeException): - """Exception for API-related errors.""" - - pass + """Exception for API-related errors. + + ``code`` is the vendor's ``returnValue`` for errors raised from a response + that answered with one. It is ``None`` for every other failure -- transport + errors, timeouts, unparsable bodies, and the auth path, which raises + :class:`AuthException` instead. A caller that has to react to one specific + return value reads this rather than matching against the message text. + """ + + def __init__(self, message: str, code: int | None = None) -> None: + super().__init__(message) + self.code = code class AuthException(ApiException): diff --git a/docs/DEVELOPMENT_NOTES.md b/docs/DEVELOPMENT_NOTES.md index cdbc475..2a8bc07 100644 --- a/docs/DEVELOPMENT_NOTES.md +++ b/docs/DEVELOPMENT_NOTES.md @@ -11,12 +11,12 @@ There are two environments, and they collect different numbers: ```bash # CI's environment: pytest + aiohttp + paho + crcmod only. # Home Assistant is absent, so the end-to-end test skips. -python -m pytest tests/ -q # 83 passed, 6 skipped +python -m pytest tests/ -q # 85 passed, 6 skipped # The real end-to-end harness: a virtualenv with Home Assistant and # pytest-homeassistant-custom-component installed. The interpreter path is # local to the author's machine -- point this at your own environment. -"/Scripts/python.exe" -m pytest tests/ -q # 89 passed +"/Scripts/python.exe" -m pytest tests/ -q # 91 passed ``` The end-to-end test (`tests/test_e2e_mqtt_to_entity.py`) **skips**, it does not diff --git a/docs/api/API_ENDPOINTS_DISCOVERED.md b/docs/api/API_ENDPOINTS_DISCOVERED.md index 38c1388..aeb5a27 100644 --- a/docs/api/API_ENDPOINTS_DISCOVERED.md +++ b/docs/api/API_ENDPOINTS_DISCOVERED.md @@ -146,7 +146,7 @@ Cộng thêm `_decrypt` dùng một hằng số AES (`svr_aes`) và một key đ | Endpoint | Method | Kết quả | Payload thật | |----------|--------|---------|--------------| -| `lesvr/getAllDayData` | GET | ✅ `1` | **Gộp cả ngày** — `pv`, `bat`, `batF`, `homeload`, `essentialLoad`, `grid`, `titleParams` | +| `lesvr/getAllDayData` | GET | ✅ `1` | **Gộp cả ngày** — `pv`, `bat`, `homeload`, `essentialLoad`, `grid`, `titleParams`. `batF` chỉ có khi ngày đó **có** phát điện; lần probe này không có nên key vắng hẳn — xem mục 7.2 | | `lesvr/getHistoryYearData` | GET | ✅ `1` | Thêm `firstYear` so với `getYearData` | | `lesvr/getMonthData` | GET | ✅ `1` | `deviceId`, `year`, `month` | | `lesvr/getYearData` | GET | ✅ `1` | `deviceId`, `year` | @@ -308,11 +308,13 @@ Cùng thiết bị, cùng `queryDate`, so từng chỉ số: | `homeload.tableValue` | 170 (`getOtherDayData`) | 170 | ✅ | | `essentialLoad.tableValue` | 0 (`getOtherDayData`) | 0 | ✅ | | `bat` (charge) | 0 (`getBatDayData:bats[0]`) | 0 | ✅ | -| `batF` (discharge) | 0 (`getBatDayData:bats[1]`) | `None` | ⚠️ | +| `batF` (discharge) | 0 (`getBatDayData:bats[1]`) | *key vắng hẳn* | ⚠️ | **Hai điểm cần lưu khi implement:** -1. **`batF` vắng mặt khi không có phát điện.** Legacy trả `bats[1].tableValue = 0`; `getAllDayData` **bỏ hẳn key `batF`** khỏi `data` (và `titleParams` có `batF` với `tableValueInfo` rỗng). Code đọc `data["batF"]` phải chịu được key vắng, không được `KeyError`. +1. **`batF` vắng mặt khi không có phát điện.** Legacy trả `bats[1].tableValue = 0`; `getAllDayData` **bỏ hẳn key `batF`** khỏi `data` — key không tồn tại, không phải key có giá trị `null`, cũng không phải `batF: {}`. Trong `titleParams` thì entry `batF` **có** mặt nhưng `tableValueInfo` của nó là **`null`**, không phải mảng rỗng. Hai điều đó khác nhau và cả hai đều cùng tồn tại: một reader chỉ chống mảng rỗng (`if not info:`) vẫn rơi vào nhánh `null`, còn reader chỉ chống `null` vẫn `KeyError` khi lấy `data["batF"]`. Code đọc `data["batF"]` phải chịu được key vắng, không được `KeyError`. + + > Đối chiếu với `docs/probe_results_getalldaydata.json`: `sorted(data.keys())` là `['bat', 'essentialLoad', 'grid', 'homeload', 'pv', 'titleParams']`, và entry `tableKey == "batF"` trong `titleParams` có `tableValueInfo: null`. 2. **Pin chưa được chứng minh.** Thiết bị test báo không có pin (`battery_type = No Battery`), nên cả hai đường đều ra 0 và phép so sánh **không kiểm chứng được** phần pin. Mapping PV/grid/load thì đã kiểm chứng thật. ### 7.3. Kết quả CŨ — KHÔNG dùng (header sai tên) diff --git a/docs/api/API_PROTOCOL.md b/docs/api/API_PROTOCOL.md index 7564501..217a869 100644 --- a/docs/api/API_PROTOCOL.md +++ b/docs/api/API_PROTOCOL.md @@ -56,10 +56,14 @@ - **Response**: one `data` object carrying every metric — `pv`, `bat` (charge), `batF` (discharge), `homeload`, `essentialLoad`, `grid` — each with its own `tableValue` / `tableValueInfo` (288 points), plus a `titleParams` array - listing the same metrics with their display names. + listing the same metrics with their display names. A metric with nothing to + report can be absent from `data` while still appearing in `titleParams`, so + `titleParams` is a shape to read, not an index of what `data` holds. - **Caveat**: `batF` is **omitted entirely** when there was no discharge, where - `getBatDayData` returns an explicit zero. The client normalises this (charge - present + `batF` absent ⇒ 0 kWh discharge) so the two sources stay + `getBatDayData` returns an explicit zero. The key is absent, not present and + null; the `batF` entry that still appears in `titleParams` carries + `tableValueInfo: null`, not an empty list. The client normalises the missing + key (charge present + `batF` absent ⇒ 0 kWh discharge) so the two sources stay interchangeable for callers. The recorded comparison behind that claim covers the day **totals** only — the per-metric series lists were not compared, so series-level equivalence between the two sources is **not** established. @@ -88,7 +92,9 @@ - `queryDate: {YYYY-MM-DD}` (optional, defaults to today) - **Response**: - `tableValue`: Total daily value (in 0.1 kWh units) - - `tableValueInfo`: Array of 288 values (5-minute intervals, 24h × 12 points/hour) + - `tableValueInfo`: Array of 288 values (5-minute intervals, 24h × 12 points/hour), each + a **watt** reading for its 5-minute slot — not a kWh figure and not in the total's + 0.1 kWh units. See [Data Units](#data-units). #### Get Battery Day Data (fallback) - **Endpoint**: `/lesvr/getBatDayData` @@ -173,13 +179,13 @@ "data": { "pv": { "tableValue": 24791, // Total (in 0.1 kWh units) - "tableValueInfo": [2217, 1423, ...] // Array of values + "tableValueInfo": [2217, 1423, ...] // 5-minute watts, not 0.1 kWh -- see Data Units }, "grid": { ... }, "homeload": { ... }, "essentialLoad": { ... }, "bat": { ... }, // Battery charge - "batF": { ... } // Battery discharge + "batF": { ... } // Battery discharge -- key absent entirely on a day with none } } ``` @@ -193,16 +199,37 @@ ``` `998` is a catch-all 404 — the endpoint does not exist. It is **not** an -authentication error. See +authentication error, and because it is a property of the host the client +remembers it: `get_all_day_data` sets `_all_day_data_absent` and every later +poll skips the combined endpoint for the life of the client. See [`API_ENDPOINTS_DISCOVERED.md`](API_ENDPOINTS_DISCOVERED.md#11-returnvalue-998--không-tồn-tại-không-phải-cần-auth) for the probe evidence. ## Data Units - **Daily totals**: `tableValue` in 0.1 kWh units (divide by 10.0 to get kWh) -- **5-minute series**: `tableValueInfo` array values in 0.1 kWh units -- **Power values**: Convert to Watt by: `(value * 0.1) / (5/60) * 1000` = W -- **Simplified**: `value * 120` for 5-minute kWh → W conversion +- **5-minute series**: `tableValueInfo` samples are **watts** over a 5-minute slot — + not 0.1 kWh, and not kWh. A whole series is a power trace, so it cannot be + summed into the total above. +- **Per-slot energy**: multiply a sample by 5 minutes and convert: + `sample * (5 / 60) / 1000` kWh per slot, i.e. `sample / 12000`. That factor is + what the client applies (`_series_5min_kwh` in + [core/api_client.py](../../core/api_client.py)). +- **If a series were ever in the total's 0.1 kWh units**, the watt conversion + would be `(value * 0.1) / (5/60) * 1000` = `value * 1200` — the formula an + earlier version of this document gave as the shipping one. It is not: the + client reads the series as watts, exactly as the caveat on + [Get All Day Data](#get-all-day-data-primary) describes. The two statements + are the same claim and must not drift apart. +- **Consequence**: a series sum and a `tableValue` total are in different units + and are **not interchangeable**. On the captured device `bat.tableValue` is 0 + while its `tableValueInfo` sums to 157, and 157 W over one 5-minute slot is + about 0.013 kWh — not 157, and not 15.7. + +> Historical note: an earlier version of this section listed the series as +> `0.1 kWh` and offered `value * 120` as a "simplified" watt conversion. That +> pairing was self-consistent arithmetic on the wrong premise; it is recorded +> here only so a reader who has seen it knows which reading supersedes it. ## Headers diff --git a/tests/test_day_stats_mapping.py b/tests/test_day_stats_mapping.py index 458c722..a684996 100644 --- a/tests/test_day_stats_mapping.py +++ b/tests/test_day_stats_mapping.py @@ -31,6 +31,7 @@ import asyncio import pytest +from aiohttp import ServerConnectionError # Shape modelled on GET /lesvr/getAllDayData on a real device (abridged). The # tableValue totals are taken from that capture (pv 60, grid 116, homeload 170, @@ -485,7 +486,9 @@ def _make_stub_session(responses: dict): ``responses`` maps an endpoint path to the JSON body to answer it with, so the client's real ``_request`` path runs and the calls it makes are - observable in ``session.calls``. + observable in ``session.calls``. A path mapped to an exception instance + raises it on entry instead, which is how a transport failure is staged; + the attempt is recorded before the raise, so a retry is still countable. """ class _Response: @@ -509,7 +512,10 @@ def __init__(self, recorder, path, headers) -> None: async def __aenter__(self): self._recorder.append({"path": self._path, "headers": dict(self._headers)}) - return _Response(responses[self._path]) + body = responses[self._path] + if isinstance(body, BaseException): + raise body + return _Response(body) async def __aexit__(self, *exc_info) -> bool: return False @@ -601,6 +607,71 @@ def test_a_usable_combined_response_is_served_without_the_legacy_calls( assert result["pv_today"] == 6.0 assert [c["path"] for c in session.calls] == ["/lesvr/getAllDayData"] + def test_a_missing_combined_endpoint_is_asked_for_only_once(self, lumentree_api_client) -> None: + """998 means "no such endpoint on this host", so stop paying for it. + + Regression: the combined call was attempted on every poll, so a host + without the endpoint cost one wasted request per poll forever and + logged the fallback notice just as often. + """ + responses = dict(self._LEGACY) + responses["/lesvr/getAllDayData"] = { + "returnValue": 998, + "msg": "您访问对页面不存在", + } + client, session = self._client(lumentree_api_client, responses) + + first = asyncio.run(client.get_daily_stats("H240909079", "2026-09-11")) + assert first["total_load_today"] == 17.0 + assert [c["path"] for c in session.calls] == [ + "/lesvr/getAllDayData", + "/lesvr/getPVDayData", + "/lesvr/getBatDayData", + "/lesvr/getOtherDayData", + ], f"first poll should probe the combined endpoint once: {session.calls}" + + del session.calls[:] + second = asyncio.run(client.get_daily_stats("H240909079", "2026-09-12")) + + assert "/lesvr/getAllDayData" not in {c["path"] for c in session.calls}, ( + f"the absent endpoint was probed again: {session.calls}" + ) + assert sorted(c["path"] for c in session.calls) == sorted(self._LEGACY), ( + f"the second poll should be the three legacy calls only: {session.calls}" + ) + assert second["total_load_today"] == 17.0 + assert second["pv_today"] == 6.0 + + def test_a_transport_error_leaves_the_combined_endpoint_in_the_retry_ladder( + self, lumentree_api_client, monkeypatch + ) -> None: + """Only 998 is durable -- a dead connection says nothing about the host. + + The next poll must try the combined endpoint again, otherwise one + dropped connection would silently demote the host to the three-call + path for the life of the client. + """ + for name in ("API_RETRY_BASE_DELAY", "API_RETRY_MAX_DELAY"): + monkeypatch.setattr(lumentree_api_client, name, 0.0) + + responses = dict(self._LEGACY) + responses["/lesvr/getAllDayData"] = ServerConnectionError("connection dropped") + client, session = self._client(lumentree_api_client, responses) + + first = asyncio.run(client.get_daily_stats("H240909079", "2026-09-11")) + assert first["total_load_today"] == 17.0 + assert client._all_day_data_absent is False, ( + "a transport failure must not be mistaken for a missing endpoint" + ) + + del session.calls[:] + second = asyncio.run(client.get_daily_stats("H240909079", "2026-09-12")) + + assert "/lesvr/getAllDayData" in {c["path"] for c in session.calls}, ( + f"the combined endpoint was dropped from the retry ladder: {session.calls}" + ) + assert second["total_load_today"] == 17.0 + class TestPayloadShapeGuards: """Malformed responses must degrade, not crash the coordinator.""" From d5cc65b69226fb1d0f20406e53f47638c80cf9a6 Mon Sep 17 00:00:00 2001 From: Vokupt Date: Sun, 13 Sep 2026 12:16:06 +0700 Subject: [PATCH 19/24] no-mistakes(review): reject non-finite vendor numbers and close the batF gate A vendor body carrying a bare NaN or Infinity survives every downstream guard -- float() succeeds, _drop_none_scalars keeps it (NaN is not None), the coordinator's `or 0.0` keeps it (NaN is truthy), and an all-zero emptiness check reads False for it -- so the day is not treated as empty and the value is persisted into the year cache and folded into the monthly, yearly and total rollups. One _finite_or_none helper now decides what a usable vendor number is; every wire coercion routes through it and a non-finite result is treated as absent, which is the answer downstream already handles. The legacy getBatDayData totals go through the same helper instead of duplicating the coercion. The batF normalisation is symmetric now: batF present with bat absent produced the same substituted zero as the other direction, but by a different road (key dropped, then read back as 0.0 by the coordinator). Both directions agree in the returned shape. The durable zero the normalisation can produce is accepted deliberately, and the comment now records that with its counterfactual named; distinguishing absent from measured-zero would need durable state and a cache-format decision, which is out of scope here. The coordinator and the cache format are untouched. --- core/api_client.py | 95 ++++++++++++++++--------- docs/DEVELOPMENT_NOTES.md | 19 ++++- tests/test_day_stats_mapping.py | 120 ++++++++++++++++++++++++++++++++ 3 files changed, 201 insertions(+), 33 deletions(-) diff --git a/core/api_client.py b/core/api_client.py index 1e5f6b6..13e7e4a 100644 --- a/core/api_client.py +++ b/core/api_client.py @@ -4,6 +4,7 @@ import asyncio import logging +import math import time from collections.abc import Iterable from typing import Any @@ -44,6 +45,26 @@ RETURN_VALUE_ENDPOINT_MISSING = 998 +def _finite_or_none(value: Any) -> float | None: + """Coerce a vendor number, treating an unusable one as absent. + + ``float()`` succeeds on the bare ``NaN``/``Infinity`` literals that + ``json.loads`` accepts, so a malformed or truncated vendor body produces a + non-finite number without raising. A non-finite reading is no more usable + than an unparsable one, and letting it through is worse: it survives + ``_drop_none_scalars`` (NaN is not None), survives the coordinator's + ``or 0.0`` (NaN is truthy) and survives an all-zero emptiness check (NaN + compares False), so it reaches the year cache and is summed into every + aggregate derived from that year. Absent is the only answer downstream + already knows how to handle. + """ + try: + number = float(value) + except (TypeError, ValueError): + return None + return number if math.isfinite(number) else None + + class LumentreeHttpApiClient: """HTTP API client for Lumentree cloud services.""" @@ -74,11 +95,9 @@ def _to_float_list(vals: Any) -> list[float]: if isinstance(vals, list): out: list[float] = [] for v in vals: - try: - out.append(float(v)) - except Exception: - # Skip invalid entries - continue + number = _finite_or_none(v) + if number is not None: + out.append(number) return out return [] @@ -139,10 +158,8 @@ def _metric_total_kwh(metric: Any) -> float | None: val = metric.get("tableValue") if val is None: return None - try: - return float(val) / 10.0 - except (TypeError, ValueError): - return None + number = _finite_or_none(val) + return None if number is None else number / 10.0 @staticmethod def _slot_readings(metric: Any) -> list[tuple[int, float]]: @@ -160,10 +177,9 @@ def _slot_readings(metric: Any) -> list[tuple[int, float]]: return [] frame: list[tuple[int, float]] = [] for slot, value in enumerate(vals): - try: - frame.append((slot, float(value))) - except (TypeError, ValueError): - continue + number = _finite_or_none(value) + if number is not None: + frame.append((slot, number)) return frame @staticmethod @@ -886,10 +902,10 @@ async def _fetch_battery_data(self, base_params: dict[str, str]) -> dict[str, An charge_today: float | None = None discharge_today: float | None = None if isinstance(bats_data, list): - if len(bats_data) > 0 and isinstance(bats_data[0], dict) and "tableValue" in bats_data[0]: - charge_today = float(bats_data[0]["tableValue"]) / 10.0 - if len(bats_data) > 1 and isinstance(bats_data[1], dict) and "tableValue" in bats_data[1]: - discharge_today = float(bats_data[1]["tableValue"]) / 10.0 + if len(bats_data) > 0 and isinstance(bats_data[0], dict): + charge_today = self._metric_total_kwh(bats_data[0]) + if len(bats_data) > 1 and isinstance(bats_data[1], dict): + discharge_today = self._metric_total_kwh(bats_data[1]) # This endpoint's series is signed with positive meaning DISCHARGE # (which contradicts the old API_PROTOCOL.md; the device is the @@ -971,22 +987,39 @@ def _merge_all_day_payload(cls, payload: Any) -> dict[str, Any]: charge_today = cls._metric_total_kwh(data.get("bat")) discharge_today = cls._metric_total_kwh(data.get("batF")) - # An absent batF is normalised to 0.0 kWh whenever the charge metric - # for the same day is present. The gate is charge presence because - # presence is the only proxy the payload offers for "this device has a - # battery and reported on this day": the slot-level `batF` omission is - # an output detail the legacy endpoint never shared. This is a chosen - # trade, not a proven equivalence -- it is what keeps the combined - # source answering the same as getBatDayData, whose bats[1] reads back - # as an explicit 0 on a day with no discharge. Counterfactual: a day - # that discharged while omitting batF is reported as 0 kWh rather - # than unknown, and no payload in this repo can rule that out. - # That wrong 0 is durable: daily_coordinator persists the day's - # discharge into the year cache on rollover, so the substituted value - # outlives the response that produced it and is not corrected by a - # later poll. + # An absent side is normalised to 0.0 kWh whenever the other side for + # the same day is present. The gate is the other side's presence + # because presence is the only proxy the payload offers for "this + # device has a battery and reported on this day": the slot-level + # omission is an output detail the legacy endpoint never shared. + # Symmetric on purpose -- "bat present, batF absent" and "batF present, + # bat absent" are the same situation seen from either end, and the + # second one would otherwise reach the same substituted zero by a + # different road: charge_today would stay None, _drop_none_scalars + # would drop the key, and the coordinator's `or 0.0` would read it back + # as 0.0. Both directions now agree in the returned dict shape and in + # the durable outcome, so neither is an undocumented instance of the + # other's trade. + # + # This is a chosen trade, not a proven equivalence -- it is what keeps + # the combined source answering the same as getBatDayData, whose + # absent bats[0]/bats[1] also reads back as 0 downstream. + # Counterfactual: a day that discharged while omitting batF is reported + # as 0 kWh rather than unknown, and no payload in this repo can rule + # that out. + # + # The wrong 0 is durable and that is accepted deliberately: the + # coordinator persists the day into the year cache on rollover, where + # recompute_aggregates folds it into the monthly, yearly and total + # statistics the dashboards read, and a later poll does not rewrite an + # already-finalized day. The alternative -- teaching the coordinator + # and the cache to distinguish "not reported" from "measured zero" -- + # needs durable state and a cache-format decision, so it is out of + # scope for a change whose point is one request instead of three. if charge_today is not None and discharge_today is None: discharge_today = 0.0 + elif discharge_today is not None and charge_today is None: + charge_today = 0.0 result.update(cls._build_battery_result(signed_slots, charge_today, discharge_today)) return cls._drop_none_scalars(result) diff --git a/docs/DEVELOPMENT_NOTES.md b/docs/DEVELOPMENT_NOTES.md index 2a8bc07..8537997 100644 --- a/docs/DEVELOPMENT_NOTES.md +++ b/docs/DEVELOPMENT_NOTES.md @@ -11,12 +11,12 @@ There are two environments, and they collect different numbers: ```bash # CI's environment: pytest + aiohttp + paho + crcmod only. # Home Assistant is absent, so the end-to-end test skips. -python -m pytest tests/ -q # 85 passed, 6 skipped +python -m pytest tests/ -q # 91 passed, 6 skipped # The real end-to-end harness: a virtualenv with Home Assistant and # pytest-homeassistant-custom-component installed. The interpreter path is # local to the author's machine -- point this at your own environment. -"/Scripts/python.exe" -m pytest tests/ -q # 91 passed +"/Scripts/python.exe" -m pytest tests/ -q # 97 passed ``` The end-to-end test (`tests/test_e2e_mqtt_to_entity.py`) **skips**, it does not @@ -128,6 +128,21 @@ homeassistant.config_entries import ConfigFlowResult`, and that symbol only exists from 2024.4. On 2023.1–2024.3 the integration installs and then fails at setup. +## A non-finite vendor number is not a reading + +`json.loads` accepts the bare `NaN` / `Infinity` / `-Infinity` literals, so a +truncated or malformed response body reaches the mapping helpers **without +raising**, and `float("nan")` then succeeds. Every guard downstream of that +point was written for `None`, and NaN slips past all of them: `_drop_none_scalars` +keeps it (NaN is not None), the coordinator's `or 0.0` keeps it (NaN is truthy), +and an `all(abs(v) < 1e-6 ...)` emptiness check reads False for it, so the day is +not treated as empty. The value then lands in the year cache and is summed into +the monthly, yearly and total rollups, where a later poll will not correct it. + +`core/api_client.py::_finite_or_none` is the one place that decides what a usable +vendor number is; every coercion of a wire value goes through it, and a +non-finite result is treated exactly like an unparsable one — absent. + ## Git and history `main` on GitHub has been rewritten more than once during this project's diff --git a/tests/test_day_stats_mapping.py b/tests/test_day_stats_mapping.py index a684996..b53f58b 100644 --- a/tests/test_day_stats_mapping.py +++ b/tests/test_day_stats_mapping.py @@ -29,6 +29,7 @@ from __future__ import annotations import asyncio +import math import pytest from aiohttp import ServerConnectionError @@ -418,6 +419,95 @@ def test_a_payload_with_no_usable_metric_is_indistinguishable_from_empty( """ assert self._merged(lumentree_api_client, {"pv": None, "grid": "nonsense"}) == {} + def test_a_non_finite_total_is_treated_as_absent( + self, lumentree_api_client + ) -> None: + """NaN and Infinity survive float(), so they need a finiteness guard. + + json.loads accepts the bare NaN/Infinity literals, so a malformed or + truncated vendor body reaches the helpers without raising. None is + what this code already means by "no usable reading"; NaN is not, + because it survives every downstream guard -- `or 0.0`, an all-zero + emptiness check -- and would be written into the year cache and summed + into every aggregate derived from that year. + """ + total = lumentree_api_client.LumentreeHttpApiClient._metric_total_kwh + assert total({"tableValue": float("nan")}) is None + assert total({"tableValue": float("inf")}) is None + assert total({"tableValue": float("-inf")}) is None + assert total({"tableValue": "nan"}) is None + + merged = self._merged(lumentree_api_client, { + "pv": {"tableValue": float("nan"), "tableValueInfo": []}, + }) + assert "pv_today" not in merged, f"a non-finite total was published: {merged}" + + def test_a_non_finite_total_does_not_defeat_the_emptiness_check( + self, lumentree_api_client + ) -> None: + """The merged dict must still read as "no data" once it is guarded. + + ``get_daily_stats`` treats an empty dict as "the endpoint had nothing", + and the coordinator treats an all-zero day as empty too. A NaN defeats + the second check (NaN compares False against the threshold), so a + guarded payload has to come back empty rather than merely NaN-free. + """ + merged = self._merged(lumentree_api_client, { + "pv": {"tableValue": float("inf"), "tableValueInfo": []}, + "grid": {"tableValue": float("-inf"), "tableValueInfo": []}, + }) + assert merged == {}, f"a non-finite payload was not treated as empty: {merged}" + + def test_a_non_finite_series_sample_becomes_a_hole( + self, lumentree_api_client + ) -> None: + """An unreadable sample is a hole, exactly like one that raises. + + The series is published as an attribute rather than through the cache, + but the same vendor body feeds it, and a single NaN would otherwise + propagate through the sum and every hour bucket it lands in. + """ + merged = self._merged(lumentree_api_client, { + "pv": { + "tableValue": 60, + "tableValueInfo": [0.0, float("nan"), 300.0] + [0.0] * 9, + }, + }) + # The hole keeps its slot, so 300 W stays at slot 2 -- hour 0. + assert merged["pv_series_5min_w"] == [0.0, 300.0] + [0.0] * 9 + assert not any(math.isnan(v) for v in merged["pv_series_hour_kwh"]) + assert math.isfinite(merged["pv_sum_kwh"]) + assert merged["pv_series_hour_kwh"][0] == pytest.approx(300 * (5 / 60) / 1000) + + def test_a_non_finite_sample_in_the_battery_series_becomes_a_hole( + self, lumentree_api_client + ) -> None: + """The battery frame has its own coercion path through the merge.""" + merged = self._merged(lumentree_api_client, { + "bat": { + "tableValue": 30, + "tableValueInfo": [0.0, float("inf"), 300.0] + [0.0] * 9, + }, + }) + assert merged["battery_series_5min_w"] == [0.0, 300.0] + [0.0] * 9 + assert not any(math.isnan(v) for v in merged["battery_charge_series_hour_kwh"]) + + def test_an_absent_charge_side_is_normalised_like_an_absent_discharge_side( + self, lumentree_api_client + ) -> None: + """The gate has to be symmetric, or the mirror case takes the other road. + + With batF present and bat absent, charge_today would stay None, the key + would be dropped, and the coordinator's `or 0.0` would read it back as + 0.0 -- the same substituted zero the discharge direction replaces + explicitly. Both directions must agree in the dict they return. + """ + merged = self._merged(lumentree_api_client, { + "batF": {"tableValue": 12, "tableValueInfo": [0, 0, 300, 0]}, + }) + assert merged["charge_today"] == 0.0 + assert merged["discharge_today"] == 1.2 + class TestLegacyBatteryPath: """The legacy signed series must keep behaving exactly as it did. @@ -475,6 +565,36 @@ def test_the_legacy_signed_series_is_negated_and_kept_whole( 200 * (5 / 60) / 1000 ) + def test_a_non_finite_legacy_total_does_not_surface_nan( + self, lumentree_api_client + ) -> None: + """The legacy totals need the same guard as the combined endpoint's. + + ``bats[0].tableValue`` is read straight off the wire by this path, and + a non-finite value there would reach the coordinator exactly as a + combined-endpoint one would -- through `or 0.0`, which NaN survives. + """ + legacy = { + "/lesvr/getBatDayData": { + "returnValue": 1, + "data": { + "bats": [{"tableValue": float("nan")}, {"tableValue": 12}], + "tableValueInfo": [], + }, + }, + } + session = _make_stub_session(legacy) + client = lumentree_api_client.LumentreeHttpApiClient(session=session) + client.set_token("stub-token") + + result = asyncio.run(client._fetch_battery_data({"deviceId": "H240909079"})) + + assert result["charge_today"] is None + assert result["discharge_today"] == 1.2 + assert not any( + math.isnan(v) for v in result.values() if isinstance(v, float) + ), f"a non-finite legacy total reached the caller: {result}" + def _combined_payload() -> dict: """A combined payload with one usable metric, for the fallback tests.""" From ed4a60eb6e16b0097d259e0b831712f7def0a835 Mon Sep 17 00:00:00 2001 From: Vokupt Date: Sun, 13 Sep 2026 12:39:55 +0700 Subject: [PATCH 20/24] no-mistakes(review): catch OverflowError and pin the battery series shape The finiteness helper caught (TypeError, ValueError) but json.loads returns an arbitrary-precision int for an integer literal of any magnitude, so a body carrying an oversized literal parses cleanly and only fails at float(). OverflowError is an ArithmeticError, not a ValueError, so it escaped the handler and every caller of it. On the getYearData path that is not one dropped sample: get_year_data re-raises from its own except Exception, the aggregator returns None, and the yearly coordinator falls back to the cache -- discarding the whole API-supplied year array over a single unreadable literal. This helper replaced a bare except Exception, so the narrower tuple was a regression; the explicit three-tuple restores that tolerance and nothing more. The battery series caveat is now on the record next to the durable-zero one. The union of the two frames happens within a payload, not across polls, so a day whose payload gains or loses a side between polls can publish a different series on each poll. Accepted for the same reason as the substituted zero -- a monotonic across-poll union would need durable per-day state and a cache-format decision -- with the counterfactual named as a vendor payload reporting one side only, which no payload in this repo can confirm or rule out. No behaviour change. Tests pin both: the oversized literal through json.loads drops only its own sample, and the discharge-only, charge-only and both-sides payloads each keep their current published shape. --- core/api_client.py | 21 ++++++- docs/DEVELOPMENT_NOTES.md | 13 +++- tests/test_day_stats_mapping.py | 103 ++++++++++++++++++++++++++++++++ 3 files changed, 134 insertions(+), 3 deletions(-) diff --git a/core/api_client.py b/core/api_client.py index 13e7e4a..0fc3e70 100644 --- a/core/api_client.py +++ b/core/api_client.py @@ -57,10 +57,16 @@ def _finite_or_none(value: Any) -> float | None: compares False), so it reaches the year cache and is summed into every aggregate derived from that year. Absent is the only answer downstream already knows how to handle. + + ``OverflowError`` is caught alongside them because ``json.loads`` produces + an arbitrary-precision ``int`` for an integer literal of any magnitude, and + ``float()`` refuses the ones that do not fit a double. It is an + ``ArithmeticError``, not a ``ValueError``, so listing it is not optional -- + without it one oversized literal in a body raises out of every caller. """ try: number = float(value) - except (TypeError, ValueError): + except (TypeError, ValueError, OverflowError): return None return number if math.isfinite(number) else None @@ -1016,6 +1022,19 @@ def _merge_all_day_payload(cls, payload: Any) -> dict[str, Any]: # and the cache to distinguish "not reported" from "measured zero" -- # needs durable state and a cache-format decision, so it is out of # scope for a change whose point is one request instead of three. + # + # A second consequence of the same trade: the coordinator re-queries the + # whole current day on every poll, so the published + # `battery_series_5min_w` and both hourly rollups reflect only what the + # response in hand carried. Within one payload the two frames are + # unioned by slot -- a side present at a slot keeps it, and neither can + # drop the other -- but nothing carries a slot across polls, so a day + # whose payload gains or loses a side between polls can publish a + # different series on each poll. Accepted for the same reason as the + # durable zero: a monotonic across-poll union would need durable + # per-day state and a cache-format decision. Counterfactual: a day + # where the vendor reports one side only, which no payload in this repo + # can confirm or rule out. if charge_today is not None and discharge_today is None: discharge_today = 0.0 elif discharge_today is not None and charge_today is None: diff --git a/docs/DEVELOPMENT_NOTES.md b/docs/DEVELOPMENT_NOTES.md index 8537997..5fb56b2 100644 --- a/docs/DEVELOPMENT_NOTES.md +++ b/docs/DEVELOPMENT_NOTES.md @@ -11,12 +11,12 @@ There are two environments, and they collect different numbers: ```bash # CI's environment: pytest + aiohttp + paho + crcmod only. # Home Assistant is absent, so the end-to-end test skips. -python -m pytest tests/ -q # 91 passed, 6 skipped +python -m pytest tests/ -q # 96 passed, 6 skipped # The real end-to-end harness: a virtualenv with Home Assistant and # pytest-homeassistant-custom-component installed. The interpreter path is # local to the author's machine -- point this at your own environment. -"/Scripts/python.exe" -m pytest tests/ -q # 97 passed +"/Scripts/python.exe" -m pytest tests/ -q # 102 passed ``` The end-to-end test (`tests/test_e2e_mqtt_to_entity.py`) **skips**, it does not @@ -143,6 +143,15 @@ the monthly, yearly and total rollups, where a later poll will not correct it. vendor number is; every coercion of a wire value goes through it, and a non-finite result is treated exactly like an unparsable one — absent. +`OverflowError` belongs in that handler's caught tuple. `json.loads` returns an +arbitrary-precision `int` for an integer literal of any magnitude, so a body +carrying `10000…0` parses cleanly and only fails at `float()`. `OverflowError` is +an `ArithmeticError`, **not** a `ValueError`, so a handler listing only the latter +lets it escape every caller. On the `getYearData` path that is not a dropped +sample: `get_year_data` re-raises from its own `except Exception`, the aggregator +returns `None`, and the yearly coordinator silently falls back to the cache, +discarding the whole API-supplied year array over one unreadable literal. + ## Git and history `main` on GitHub has been rewritten more than once during this project's diff --git a/tests/test_day_stats_mapping.py b/tests/test_day_stats_mapping.py index b53f58b..042ee7d 100644 --- a/tests/test_day_stats_mapping.py +++ b/tests/test_day_stats_mapping.py @@ -29,6 +29,7 @@ from __future__ import annotations import asyncio +import json import math import pytest @@ -375,6 +376,66 @@ def test_a_short_series_publishes_only_the_slots_it_reported( }) assert merged["battery_series_5min_w"] == [100.0, 250.0] + def test_a_discharge_only_payload_reports_zero_charge( + self, lumentree_api_client + ) -> None: + """A day that only discharged reports no charge -- 0.0, not unknown. + + This is the correct answer for those inputs: nothing charged, so the + charge total is 0.0 and its hourly rollup is 24 zeros. Pinned because + the substituted zero is a deliberate trade, so a future change that + makes it an omission or a None has to be a conscious one. + """ + merged = self._merged(lumentree_api_client, { + "batF": {"tableValue": 12, "tableValueInfo": [0, 300, 0, 0]}, + }) + assert merged["charge_today"] == 0.0 + assert merged["discharge_today"] == 1.2 + + charge = merged["battery_charge_series_hour_kwh"] + assert len(charge) == 24 + assert sum(charge) == 0.0 + # The 300 W sample is at slot 1, the second step of hour 0. + discharge = merged["battery_discharge_series_hour_kwh"] + assert discharge[0] == pytest.approx(300 * (5 / 60) / 1000) + assert sum(discharge[1:]) == 0.0 + + def test_a_charge_only_payload_reports_zero_discharge( + self, lumentree_api_client + ) -> None: + """The mirror of the previous case, and it must answer the same way.""" + merged = self._merged(lumentree_api_client, { + "bat": {"tableValue": 30, "tableValueInfo": [500, 0, 0, 0]}, + }) + assert merged["charge_today"] == 3.0 + assert merged["discharge_today"] == 0.0 + + discharge = merged["battery_discharge_series_hour_kwh"] + assert len(discharge) == 24 + assert sum(discharge) == 0.0 + assert merged["battery_charge_series_hour_kwh"][0] == pytest.approx( + 500 * (5 / 60) / 1000 + ) + + def test_both_sides_present_are_unioned_within_the_payload( + self, lumentree_api_client + ) -> None: + """One payload carrying both sides keeps both, at their own slots. + + This is what bounds the cross-poll caveat: the union happens inside a + single response, so whichever side a response carries is published in + full. It is not a claim about a payload that drops a side it had + previously reported. + """ + merged = self._merged(lumentree_api_client, { + "bat": {"tableValue": 30, "tableValueInfo": [500, 0, 0, 0]}, + "batF": {"tableValue": 12, "tableValueInfo": [0, 300, 0, 0]}, + }) + assert merged["battery_series_5min_w"] == [500.0, -300.0, 0.0, 0.0] + step = (5 / 60) / 1000 + assert merged["battery_charge_series_hour_kwh"][0] == pytest.approx(500 * step) + assert merged["battery_discharge_series_hour_kwh"][0] == pytest.approx(300 * step) + def test_no_battery_at_all_yields_no_series_and_no_discharge( self, lumentree_api_client ) -> None: @@ -508,6 +569,48 @@ def test_an_absent_charge_side_is_normalised_like_an_absent_discharge_side( assert merged["charge_today"] == 0.0 assert merged["discharge_today"] == 1.2 + def test_an_out_of_range_integer_is_dropped_not_raised( + self, lumentree_api_client + ) -> None: + """float() refuses an oversized integer, and the refusal must be caught. + + json.loads keeps an integer literal of any magnitude as a Python int, + so it parses cleanly and only fails at the coercion. OverflowError is + an ArithmeticError rather than a ValueError, so a handler that lists + only the latter lets it through -- and one such literal in a getYearData + body would raise out of the whole year's array instead of dropping the + single unreadable entry. + """ + client = lumentree_api_client.LumentreeHttpApiClient + too_big = 10**400 + + assert client._to_float_list([too_big]) == [] + assert client._to_float_list([1, too_big, 2]) == [1.0, 2.0] + assert client._metric_total_kwh({"tableValue": too_big}) is None + + def test_an_out_of_range_integer_in_a_parsed_body_drops_only_that_sample( + self, lumentree_api_client + ) -> None: + """The whole reachability path, driven through json.loads. + + A hand-built list would not prove the literal survives parsing. Built + this way, the payload is exactly what the vendor body produces, and the + assertion is that the merge still returns a dict with the readable + samples intact rather than raising. + """ + parsed = json.loads( + '{"pv": {"tableValue": 60, "tableValueInfo": [120, 10000' + + "0" * 400 + + ", 240]}}" + ) + assert isinstance(parsed["pv"]["tableValueInfo"][1], int) + + merged = self._merged(lumentree_api_client, parsed) + + assert merged["pv_today"] == 6.0 + # The unreadable sample is a hole, so the 240 W keeps slot 2. + assert merged["pv_series_5min_w"] == [120.0, 240.0] + class TestLegacyBatteryPath: """The legacy signed series must keep behaving exactly as it did. From af2660836419baa6833cac506da70698af1045a1 Mon Sep 17 00:00:00 2001 From: Vokupt Date: Sun, 13 Sep 2026 13:08:29 +0700 Subject: [PATCH 21/24] no-mistakes(review): fill a series hole so its index stays its slot The flat series lists published on the sensor attributes drop a slot the frame has no entry for, so one unreadable or unreported sample shortened the list and shifted every later reading one position earlier. Consumers read those lists positionally: entities/sensor.py copies battery_series_5min_w straight onto the charge/discharge sensors, and every dashboard derives the clock time as Math.floor(index / 12). A device that went quiet mid-day therefore plotted the rest of its day too early, and the battery chart, which reads the raw list with no hourly form, had no correct-timed alternative. _values now fills the gap with 0.0 up to the frame's own last slot, making index == slot for every existing consumer. The span is the frame's last reported slot rather than a fixed 288, because a day in progress has not reported the rest of it; an empty frame still publishes no series at all, so "no battery data" stays distinguishable from "a battery at zero". The internal frames and both hourly rollups are untouched -- they key on the slot -- and the padding is 0.0, so every sum derived from a list is unchanged. Tests now assert the filled position rather than the gap, and pin the sum-neutrality so the padding is provably free. --- core/api_client.py | 24 +++++++- docs/DEVELOPMENT_NOTES.md | 23 +++++++- tests/test_day_stats_mapping.py | 98 +++++++++++++++++++++++++++------ 3 files changed, 125 insertions(+), 20 deletions(-) diff --git a/core/api_client.py b/core/api_client.py index 0fc3e70..d8b833f 100644 --- a/core/api_client.py +++ b/core/api_client.py @@ -123,6 +123,11 @@ def _series_hour_kwh(series_kwh5: list[tuple[int, float]]) -> list[float]: that stopped early leaves the remaining hours at zero instead of pulling the readings it did send backwards. Bucketing the flat list by index did exactly that damage, and did it silently. + + The flat lists published beside these rollups need the same treatment + for a different reason: a consumer derives the clock time from the + array index, so an unfilled hole would plot every later sample earlier + in the day than it was reported. ``_values`` fills the hole instead. """ if not series_kwh5: return [] @@ -135,8 +140,23 @@ def _series_hour_kwh(series_kwh5: list[tuple[int, float]]) -> list[float]: @staticmethod def _values(frame: list[tuple[int, float]]) -> list[float]: - """A frame's readings in slot order -- the flat lists consumers read.""" - return [value for _, value in frame] + """A frame's readings in slot order -- the flat lists consumers read. + + Filled to the frame's own last slot, with 0.0 standing in for a slot + the frame has no entry for, so a reading's index is the slot it was + reported for. The consumers turn an index into a clock time, so a + hole left as a gap would silently shift the rest of the day earlier. + + The span is the frame's last reported slot and not the full 288: a day + in progress has not reported the rest of it, and inventing those slots + would draw readings the device never sent. + """ + if not frame: + return [] + values = [0.0] * (max(slot for slot, _ in frame) + 1) + for slot, value in frame: + values[slot] = value + return values @staticmethod def _sum(series: list[float]) -> float: diff --git a/docs/DEVELOPMENT_NOTES.md b/docs/DEVELOPMENT_NOTES.md index 5fb56b2..b6693c9 100644 --- a/docs/DEVELOPMENT_NOTES.md +++ b/docs/DEVELOPMENT_NOTES.md @@ -11,12 +11,12 @@ There are two environments, and they collect different numbers: ```bash # CI's environment: pytest + aiohttp + paho + crcmod only. # Home Assistant is absent, so the end-to-end test skips. -python -m pytest tests/ -q # 96 passed, 6 skipped +python -m pytest tests/ -q # 100 passed, 6 skipped # The real end-to-end harness: a virtualenv with Home Assistant and # pytest-homeassistant-custom-component installed. The interpreter path is # local to the author's machine -- point this at your own environment. -"/Scripts/python.exe" -m pytest tests/ -q # 102 passed +"/Scripts/python.exe" -m pytest tests/ -q # 106 passed ``` The end-to-end test (`tests/test_e2e_mqtt_to_entity.py`) **skips**, it does not @@ -152,6 +152,25 @@ sample: `get_year_data` re-raises from its own `except Exception`, the aggregato returns `None`, and the yearly coordinator silently falls back to the cache, discarding the whole API-supplied year array over one unreadable literal. +## A hole in a series must keep its position + +The day series are published twice over: as slot-keyed frames internally, and as +flat lists (`*_series_5min_w`) on the sensor attributes. The frames may hold a +hole — an unreported or unreadable slot simply has no entry — and each shape +needs the hole handled differently. + +`_series_hour_kwh` folds by `slot // 12`, so a hole is harmless there. + +The flat lists are not: `entities/sensor.py` copies `battery_series_5min_w` +straight onto the sensors, and every dashboard turns an array index into a clock +time with `Math.floor(index / 12)`. A hole published as a gap therefore plots +every later sample earlier in the day than it was reported. `_values` fills the +gap with `0.0` up to the frame's own last slot, making index == slot. It does +**not** pad to a fixed 288: a day in progress has not reported the rest of it, +and padding would draw readings the device never sent. An empty frame still +publishes no series at all, which is how "no battery data" stays distinguishable +from "a battery that sat at zero". + ## Git and history `main` on GitHub has been rewritten more than once during this project's diff --git a/tests/test_day_stats_mapping.py b/tests/test_day_stats_mapping.py index 042ee7d..fa5f578 100644 --- a/tests/test_day_stats_mapping.py +++ b/tests/test_day_stats_mapping.py @@ -158,21 +158,24 @@ def test_hourly_rollup_may_contain_both_charge_and_discharge(self, lumentree_api assert built["battery_charge_series_hour_kwh"][0] > 0 assert built["battery_discharge_series_hour_kwh"][0] > 0 - def test_unreported_slots_are_not_published_as_readings( + def test_an_unreported_slot_keeps_its_position( self, lumentree_api_client ) -> None: - """A slot nobody reported is not a reading, and must not become a 0 W one. - - Downstream (entities/sensor.py) publishes this list and turns it into - kWh, so a hole written as 0.0 W is a chart point that claims the - battery was idle at a moment the device said nothing about. The frame - simply has no entry for slot 1, so the flat list is two long -- and - slot 2's -300 W still folds into hour 0, where it was reported. + """A slot nobody reported holds its place instead of collapsing. + + The consumers derive the clock time from the array index + (``Math.floor(index / 12)`` in the dashboards), so a hole published as + a gap plots every later sample earlier in the day than it was reported + -- here the -300 W was reported for slot 2 but would render at index 1. + The 0.0 that fills the gap is a chart point the device did not send, + and that is the deliberate trade: index == slot is what keeps the rest + of the day where it belongs. The hourly fold is unaffected either way, + because it keys on the slot. """ built = lumentree_api_client.LumentreeHttpApiClient._build_battery_result( [(0, 500.0), (2, -300.0)], None, None ) - assert built["battery_series_5min_w"] == [500.0, -300.0] + assert built["battery_series_5min_w"] == [500.0, 0.0, -300.0] assert built["battery_discharge_series_hour_kwh"][0] == pytest.approx( 300 * (5 / 60) / 1000 ) @@ -241,8 +244,11 @@ def test_the_hour_fold_keeps_each_sample_in_its_own_hour( rollup = merged["battery_charge_series_hour_kwh"] assert rollup[0] == 0.0 assert rollup[1] == pytest.approx(500 * (5 / 60) / 1000) - # The flat list keeps only the readings that were sent. - assert len(merged["battery_series_5min_w"]) == 12 + # The flat list spans the reported frame, and the 500 W sits at index + # 12 -- the slot it was reported for, not the third list position. + series = merged["battery_series_5min_w"] + assert series[12] == 500.0 + assert len(series) == 13 def test_the_pv_hour_fold_keeps_each_sample_in_its_own_hour( self, lumentree_api_client @@ -436,6 +442,63 @@ def test_both_sides_present_are_unioned_within_the_payload( assert merged["battery_charge_series_hour_kwh"][0] == pytest.approx(500 * step) assert merged["battery_discharge_series_hour_kwh"][0] == pytest.approx(300 * step) + def test_a_hole_in_the_pv_series_is_published_at_its_own_index( + self, lumentree_api_client + ) -> None: + """The published list spans the frame with the hole filled in place. + + Index == slot is the contract the dashboards rely on + (``Math.floor(index / 12)``), so the full list is asserted rather than + its length: the 360 W must read out at index 2, not index 1. + """ + merged = self._merged(lumentree_api_client, { + "pv": {"tableValue": 6, "tableValueInfo": [120, "bad", 360]}, + }) + assert merged["pv_series_5min_w"] == [120.0, 0.0, 360.0] + + def test_a_hole_no_side_reported_is_published_at_its_own_index( + self, lumentree_api_client + ) -> None: + """A slot neither battery side reported still holds its position. + + Slot 2 is a hole in both frames, so `_slot_difference` has no entry for + it at all -- the case that collapses the list most easily. The 200 W at + slot 3 must stay at index 3. + """ + merged = self._merged(lumentree_api_client, { + "bat": {"tableValue": 30, "tableValueInfo": [500, 0, "x", 200]}, + "batF": {"tableValue": 12, "tableValueInfo": [0, 0, "y", 0]}, + }) + assert merged["battery_series_5min_w"] == [500.0, 0.0, 0.0, 200.0] + + def test_filling_a_hole_does_not_change_the_sum( + self, lumentree_api_client + ) -> None: + """The padding is 0.0, so every total derived from the list is unmoved. + + Asserted against the same payload without the hole, so this pins + sum-neutrality rather than just a number. + """ + with_hole = self._merged(lumentree_api_client, { + "pv": {"tableValue": 6, "tableValueInfo": [120, "bad", 360]}, + }) + without_hole = self._merged(lumentree_api_client, { + "pv": {"tableValue": 6, "tableValueInfo": [120, 0, 360]}, + }) + assert with_hole["pv_sum_kwh"] == without_hole["pv_sum_kwh"] + assert with_hole["pv_series_hour_kwh"] == without_hole["pv_series_hour_kwh"] + assert with_hole["pv_today"] == without_hole["pv_today"] + + def test_a_frame_with_no_reading_still_publishes_no_series( + self, lumentree_api_client + ) -> None: + """An empty frame has no span to fill, so it publishes nothing.""" + merged = self._merged(lumentree_api_client, { + "pv": {"tableValue": 6, "tableValueInfo": []}, + }) + assert "pv_series_5min_w" not in merged + assert "pv_sum_kwh" not in merged + def test_no_battery_at_all_yields_no_series_and_no_discharge( self, lumentree_api_client ) -> None: @@ -526,7 +589,9 @@ def test_a_non_finite_series_sample_becomes_a_hole( The series is published as an attribute rather than through the cache, but the same vendor body feeds it, and a single NaN would otherwise - propagate through the sum and every hour bucket it lands in. + propagate through the sum and every hour bucket it lands in. The hole + is published as 0.0 at its own index so the 300 W after it stays at + index 2, which is the slot it was reported for. """ merged = self._merged(lumentree_api_client, { "pv": { @@ -535,7 +600,7 @@ def test_a_non_finite_series_sample_becomes_a_hole( }, }) # The hole keeps its slot, so 300 W stays at slot 2 -- hour 0. - assert merged["pv_series_5min_w"] == [0.0, 300.0] + [0.0] * 9 + assert merged["pv_series_5min_w"] == [0.0, 0.0, 300.0] + [0.0] * 9 assert not any(math.isnan(v) for v in merged["pv_series_hour_kwh"]) assert math.isfinite(merged["pv_sum_kwh"]) assert merged["pv_series_hour_kwh"][0] == pytest.approx(300 * (5 / 60) / 1000) @@ -550,7 +615,7 @@ def test_a_non_finite_sample_in_the_battery_series_becomes_a_hole( "tableValueInfo": [0.0, float("inf"), 300.0] + [0.0] * 9, }, }) - assert merged["battery_series_5min_w"] == [0.0, 300.0] + [0.0] * 9 + assert merged["battery_series_5min_w"] == [0.0, 0.0, 300.0] + [0.0] * 9 assert not any(math.isnan(v) for v in merged["battery_charge_series_hour_kwh"]) def test_an_absent_charge_side_is_normalised_like_an_absent_discharge_side( @@ -608,8 +673,9 @@ def test_an_out_of_range_integer_in_a_parsed_body_drops_only_that_sample( merged = self._merged(lumentree_api_client, parsed) assert merged["pv_today"] == 6.0 - # The unreadable sample is a hole, so the 240 W keeps slot 2. - assert merged["pv_series_5min_w"] == [120.0, 240.0] + # The unreadable sample is a hole that keeps its index, so the 240 W + # stays at index 2, where it was reported. + assert merged["pv_series_5min_w"] == [120.0, 0.0, 240.0] class TestLegacyBatteryPath: From 2ab7af758dc5954cd98e725dd2b59c0d63f6940a Mon Sep 17 00:00:00 2001 From: Vokupt Date: Sun, 13 Sep 2026 13:42:04 +0700 Subject: [PATCH 22/24] no-mistakes(review): record the partial combined payload residual A combined day response carrying some metrics is returned as it stands, so a metric it omitted is never fetched from the legacy endpoints. The vendor documents that shape and the branch normalises it only for batF. An omitted pv/grid/homeload total becomes a durable zero for the day, through the caller's `or 0.0`. Keep the trigger keyed on the whole response -- widening it to a metric set would pay for three extra requests on the common path for a shape no payload here shows -- and record the residual beside the batF trade instead. Pin the trigger with a test that a partial combined response issues no legacy request, and update the suite counts. --- core/api_client.py | 17 +++++++++++++++++ docs/DEVELOPMENT_NOTES.md | 32 ++++++++++++++++++++++++++++++-- tests/test_day_stats_mapping.py | 28 ++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 2 deletions(-) diff --git a/core/api_client.py b/core/api_client.py index d8b833f..25cc167 100644 --- a/core/api_client.py +++ b/core/api_client.py @@ -739,6 +739,17 @@ async def get_daily_stats(self, device_identifier: str, query_date: str) -> dict rather than function. An empty result means exactly that for both sources, so this test is the endpoint's own "no data" answer either way. + The trigger is the whole response, so a response that carries some + metrics is returned as it stands and the endpoints are not consulted + for the metrics it omitted. The vendor does omit a metric that has + nothing to report while still listing it in `titleParams`, so an + omitted pv/grid/homeload total is published as 0.0 by the caller's + `or 0.0` and persisted for that day. Deliberate: widening the trigger + to a metric set would pay for three extra requests on the common path, + on the strength of a shape no payload in this repo shows for those + metrics -- they are unmeasured -- and the legacy path collapses an + omitted metric to 0.0 by that same route anyway. + One failure is not retried: a 998 ("endpoint does not exist") is a property of the host, so it is cached on the client and every later poll skips straight to the legacy calls. The fallback notice above @@ -1055,6 +1066,12 @@ def _merge_all_day_payload(cls, payload: Any) -> dict[str, Any]: # per-day state and a cache-format decision. Counterfactual: a day # where the vendor reports one side only, which no payload in this repo # can confirm or rule out. + # + # The absent-metric substitution above and `get_daily_stats` returning a + # partial response instead of falling back are one class of decision and + # are read together: an absent metric becomes a durable zero for the + # day. This change records both rather than fixing either; the + # recorded residual for pv/grid/homeload lives on `get_daily_stats`. if charge_today is not None and discharge_today is None: discharge_today = 0.0 elif discharge_today is not None and charge_today is None: diff --git a/docs/DEVELOPMENT_NOTES.md b/docs/DEVELOPMENT_NOTES.md index b6693c9..48f4e77 100644 --- a/docs/DEVELOPMENT_NOTES.md +++ b/docs/DEVELOPMENT_NOTES.md @@ -11,12 +11,12 @@ There are two environments, and they collect different numbers: ```bash # CI's environment: pytest + aiohttp + paho + crcmod only. # Home Assistant is absent, so the end-to-end test skips. -python -m pytest tests/ -q # 100 passed, 6 skipped +python -m pytest tests/ -q # 101 passed, 6 skipped # The real end-to-end harness: a virtualenv with Home Assistant and # pytest-homeassistant-custom-component installed. The interpreter path is # local to the author's machine -- point this at your own environment. -"/Scripts/python.exe" -m pytest tests/ -q # 106 passed +"/Scripts/python.exe" -m pytest tests/ -q # 107 passed ``` The end-to-end test (`tests/test_e2e_mqtt_to_entity.py`) **skips**, it does not @@ -171,6 +171,34 @@ and padding would draw readings the device never sent. An empty frame still publishes no series at all, which is how "no battery data" stays distinguishable from "a battery that sat at zero". +## A partial combined day response suppresses the legacy fallback + +`get_daily_stats` falls back to the three legacy endpoints only when the +combined `getAllDayData` response is *entirely* unusable. A response carrying +some metrics is returned as it stands, and the endpoints are not consulted for +the metrics it omitted. The vendor documents that shape — a metric with nothing +to report can be absent from `data` while still listed in `titleParams` — and +the branch's own capture shows it for `batF`, which is normalised. + +What is not normalised is the same shape on pv/grid/homeload. The coordinator +reads each total back with `float(... or 0.0)`, so an omitted `grid_in_today` +becomes a published `0.0`, `is_empty` is False because the other metrics are +non-zero, and the day is persisted into the year cache, where a later poll does +not rewrite it. That is a durable wrong zero with no error logged. + +Deliberate, and the trigger is not widened for it. Widening it to a metric set +would pay for the three legacy calls on every poll whose combined response is +missing any one metric — including the permanently-absent `batF` shape — which +is exactly the request count this change removes. Two things bound the +residual: no payload in this repo shows pv/grid/homeload going absent while +others are present, so the shape is unmeasured for them; and the legacy path +collapses an omitted metric to 0.0 by the same `or 0.0` route, which predates +this change. What the change removes is the second chance, not the zero. + +The batF substitution and this one are the same class of decision — an absent +metric becomes a durable zero — and `_merge_all_day_payload` records them +together. Neither is fixed here; both are recorded. + ## Git and history `main` on GitHub has been rewritten more than once during this project's diff --git a/tests/test_day_stats_mapping.py b/tests/test_day_stats_mapping.py index fa5f578..abf3f04 100644 --- a/tests/test_day_stats_mapping.py +++ b/tests/test_day_stats_mapping.py @@ -896,6 +896,34 @@ def test_a_usable_combined_response_is_served_without_the_legacy_calls( assert result["pv_today"] == 6.0 assert [c["path"] for c in session.calls] == ["/lesvr/getAllDayData"] + def test_a_partial_combined_response_is_served_without_the_legacy_calls( + self, lumentree_api_client + ) -> None: + """The trigger is the whole response, so a partial one is not widened. + + The combined payload here carries PV and omits grid and load. The + legacy endpoints answer those two with 11.6 and 17.0, and those calls + must not be made: paying three requests for a response the vendor + documents as ordinary per-metric absence is the cost this change + exists to remove. The omission is therefore visible in the result -- + no grid/load key at all -- rather than papered over here. + """ + responses = dict(self._LEGACY) + responses["/lesvr/getAllDayData"] = { + "returnValue": 1, + "data": {"pv": {"tableValue": 60, "tableValueInfo": [0, 0, 120, 240]}}, + } + client, session = self._client(lumentree_api_client, responses) + + result = asyncio.run(client.get_daily_stats("H240909079", "2026-09-11")) + + assert [c["path"] for c in session.calls] == ["/lesvr/getAllDayData"], ( + f"a partial combined response must not trigger the legacy calls: {session.calls}" + ) + assert result["pv_today"] == 6.0 + assert "grid_in_today" not in result + assert "total_load_today" not in result + def test_a_missing_combined_endpoint_is_asked_for_only_once(self, lumentree_api_client) -> None: """998 means "no such endpoint on this host", so stop paying for it. From 688cf083c77089458c0d042fb0a5a708b75a52f7 Mon Sep 17 00:00:00 2001 From: Vokupt Date: Sun, 13 Sep 2026 14:32:56 +0700 Subject: [PATCH 23/24] no-mistakes(document): Repoint dangling survey anchor, reword stale legacy-alias copies --- core/api_client.py | 99 ++--- docs/README.md | 2 +- docs/api/API_ENDPOINTS_DISCOVERED.md | 6 +- docs/api/API_TEST_GUIDE.md | 4 +- ...SHBOARD_BATTERY_CHARGE_DISCHARGE_README.md | 5 +- tests/conftest.py | 1 + tests/test_day_stats_mapping.py | 342 ++++++++++-------- tests/test_e2e_mqtt_to_entity.py | 23 +- 8 files changed, 267 insertions(+), 215 deletions(-) diff --git a/core/api_client.py b/core/api_client.py index 25cc167..cb9421e 100644 --- a/core/api_client.py +++ b/core/api_client.py @@ -261,12 +261,14 @@ def _build_pv_result(cls, metric: Any) -> dict[str, Any]: series = cls._slot_readings(metric) if series: series_kwh5 = cls._series_5min_kwh(series) - result.update({ - "pv_series_5min_w": cls._values(series), - "pv_series_5min_kwh": cls._values(series_kwh5), - "pv_series_hour_kwh": cls._series_hour_kwh(series_kwh5), - "pv_sum_kwh": cls._sum(cls._values(series_kwh5)), - }) + result.update( + { + "pv_series_5min_w": cls._values(series), + "pv_series_5min_kwh": cls._values(series_kwh5), + "pv_series_hour_kwh": cls._series_hour_kwh(series_kwh5), + "pv_sum_kwh": cls._sum(cls._values(series_kwh5)), + } + ) return result @classmethod @@ -275,11 +277,13 @@ def _build_grid_result(cls, metric: Any) -> dict[str, Any]: series = cls._slot_readings(metric) if series: g5 = cls._series_5min_kwh(series) - result.update({ - "grid_series_5min_w": cls._values(series), - "grid_series_5min_kwh": cls._values(g5), - "grid_series_hour_kwh": cls._series_hour_kwh(g5), - }) + result.update( + { + "grid_series_5min_w": cls._values(series), + "grid_series_5min_kwh": cls._values(g5), + "grid_series_hour_kwh": cls._series_hour_kwh(g5), + } + ) return result @classmethod @@ -306,18 +310,22 @@ def _build_load_result(cls, load_metric: Any, essential_metric: Any) -> dict[str if load_series: l5 = cls._series_5min_kwh(load_series) - result.update({ - "load_series_5min_w": cls._values(load_series), - "load_series_5min_kwh": cls._values(l5), - "load_series_hour_kwh": cls._series_hour_kwh(l5), - }) + result.update( + { + "load_series_5min_w": cls._values(load_series), + "load_series_5min_kwh": cls._values(l5), + "load_series_hour_kwh": cls._series_hour_kwh(l5), + } + ) if essential_series: e5 = cls._series_5min_kwh(essential_series) - result.update({ - "essential_series_5min_w": cls._values(essential_series), - "essential_series_5min_kwh": cls._values(e5), - "essential_series_hour_kwh": cls._series_hour_kwh(e5), - }) + result.update( + { + "essential_series_5min_w": cls._values(essential_series), + "essential_series_5min_kwh": cls._values(e5), + "essential_series_hour_kwh": cls._series_hour_kwh(e5), + } + ) if load_series and essential_series: # Slots, not array positions: a short series contributes nothing to @@ -326,11 +334,13 @@ def _build_load_result(cls, load_metric: Any, essential_metric: Any) -> dict[str total_load = cls._slot_sum(load_series, essential_series) total_load_kwh5 = cls._series_5min_kwh(total_load) - result.update({ - "total_load_series_5min_w": cls._values(total_load), - "total_load_series_5min_kwh": cls._values(total_load_kwh5), - "total_load_series_hour_kwh": cls._series_hour_kwh(total_load_kwh5), - }) + result.update( + { + "total_load_series_5min_w": cls._values(total_load), + "total_load_series_5min_kwh": cls._values(total_load_kwh5), + "total_load_series_hour_kwh": cls._series_hour_kwh(total_load_kwh5), + } + ) if "total_load_today" not in result: result["total_load_today"] = cls._sum(cls._values(total_load_kwh5)) @@ -367,17 +377,17 @@ def _build_battery_result( # battery data at all" are different answers, and only the second one # is allowed to publish no series. factor = (5.0 / 60.0) / 1000.0 - charge_kwh5 = [ - (slot, value * factor if value > 0 else 0.0) for slot, value in series_slots - ] + charge_kwh5 = [(slot, value * factor if value > 0 else 0.0) for slot, value in series_slots] discharge_kwh5 = [ (slot, abs(value) * factor if value < 0 else 0.0) for slot, value in series_slots ] - result.update({ - "battery_series_5min_w": cls._values(series_slots), - "battery_charge_series_hour_kwh": cls._series_hour_kwh(charge_kwh5), - "battery_discharge_series_hour_kwh": cls._series_hour_kwh(discharge_kwh5), - }) + result.update( + { + "battery_series_5min_w": cls._values(series_slots), + "battery_charge_series_hour_kwh": cls._series_hour_kwh(charge_kwh5), + "battery_discharge_series_hour_kwh": cls._series_hour_kwh(discharge_kwh5), + } + ) return result def set_token(self, token: str | None) -> None: @@ -778,7 +788,8 @@ async def get_daily_stats(self, device_identifier: str, query_date: str) -> dict _LOGGER.info( "Combined day endpoint returned no data for %s @ %s; " "falling back to the three per-metric endpoints", - device_identifier, query_date, + device_identifier, + query_date, ) base_params = {"deviceId": device_identifier, "queryDate": query_date} @@ -794,7 +805,6 @@ async def get_daily_stats(self, device_identifier: str, query_date: str) -> dict # Merge results return self._merge_stats_results(results) - async def get_year_data(self, device_identifier: str, year: int) -> dict[str, Any]: """Get yearly statistics data (12 months aggregated). @@ -913,7 +923,9 @@ async def _fetch_pv_data(self, base_params: dict[str, str]) -> dict[str, Any]: PV data dictionary """ try: - resp = await self._request("GET", URL_GET_PV_DAY_DATA, params=base_params, requires_auth=True) + resp = await self._request( + "GET", URL_GET_PV_DAY_DATA, params=base_params, requires_auth=True + ) return self._build_pv_result((resp.get("data") or {}).get("pv")) except (ApiException, AuthException) as exc: _LOGGER.warning(f"Failed PV stats ({type(exc).__name__}): {exc}") @@ -932,7 +944,9 @@ async def _fetch_battery_data(self, base_params: dict[str, str]) -> dict[str, An Battery data dictionary """ try: - resp = await self._request("GET", URL_GET_BAT_DAY_DATA, params=base_params, requires_auth=True) + resp = await self._request( + "GET", URL_GET_BAT_DAY_DATA, params=base_params, requires_auth=True + ) data = resp.get("data") or {} bats_data = data.get("bats", []) @@ -947,9 +961,7 @@ async def _fetch_battery_data(self, base_params: dict[str, str]) -> dict[str, An # This endpoint's series is signed with positive meaning DISCHARGE # (which contradicts the old API_PROTOCOL.md; the device is the # authority). Negate so the shared builder sees positive = charge. - series_slots = [ - (slot, -value) for slot, value in self._slot_readings(data) - ] + series_slots = [(slot, -value) for slot, value in self._slot_readings(data)] return self._build_battery_result(series_slots, charge_today, discharge_today) except (ApiException, AuthException) as exc: _LOGGER.warning(f"Failed battery stats ({type(exc).__name__}): {exc}") @@ -968,7 +980,9 @@ async def _fetch_other_data(self, base_params: dict[str, str]) -> dict[str, Any] Grid and load data dictionary """ try: - resp = await self._request("GET", URL_GET_OTHER_DAY_DATA, params=base_params, requires_auth=True) + resp = await self._request( + "GET", URL_GET_OTHER_DAY_DATA, params=base_params, requires_auth=True + ) data = resp.get("data") or {} result = self._build_grid_result(data.get("grid")) result.update(self._build_load_result(data.get("homeload"), data.get("essentialLoad"))) @@ -1132,7 +1146,6 @@ async def get_all_day_data(self, device_identifier: str, query_date: str) -> dic _LOGGER.exception("Unexpected all-day stats error") return {} - def _merge_stats_results(self, results: Iterable[Any]) -> dict[str, Any]: """Merge results from concurrent API calls. diff --git a/docs/README.md b/docs/README.md index 7dd44d7..3477fd1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -34,7 +34,7 @@ docs/ ### API Documentation - **[API Protocol](api/API_PROTOCOL.md)**: Complete API reference including authentication, endpoints, and request/response formats -- **[API Endpoints Discovered](api/API_ENDPOINTS_DISCOVERED.md)**: Endpoint survey, legacy-alias findings, and the corrections to API_PROTOCOL.md +- **[API Endpoints Discovered](api/API_ENDPOINTS_DISCOVERED.md)**: Endpoint survey, legacy-status findings, and the corrections to API_PROTOCOL.md - **[Register Map](api/REGISTER_MAP.md)**: How each real-time Modbus register was recovered from the vendor app, with its scale and signedness - **[API Testing Guide](api/API_TEST_GUIDE.md)**: Step-by-step guide for testing API endpoints - **[Error Handling](api/ERROR_HANDLING.md)**: Error handling patterns and best practices diff --git a/docs/api/API_ENDPOINTS_DISCOVERED.md b/docs/api/API_ENDPOINTS_DISCOVERED.md index aeb5a27..c906eb2 100644 --- a/docs/api/API_ENDPOINTS_DISCOVERED.md +++ b/docs/api/API_ENDPOINTS_DISCOVERED.md @@ -103,7 +103,7 @@ Integration khai báo `cloud_polling`. Do đó **mọi endpoint ghi (Tier 3) và | Host | Giao thức | Vai trò | Trong repo? | |------|-----------|---------|-------------| -| `lesvr.suntcn.com` | **HTTP thuần** + MQTT `:1886` | Host integration đang dùng | ✅ `const.py:21,41` | +| `lesvr.suntcn.com` | **HTTP thuần** + MQTT `:1886` | Host integration đang dùng | ✅ `const.py:22,47` | | `lesvrjm.suntcn.com` | **HTTPS** | Host app 3.2.4 dùng (`ServerApi` base URL) | ❌ chưa có | | `lehtapi.suntcn.com` | HTTPS | API của web admin console | ❌ chưa có | | `rsdown.suntcn.com` | HTTPS | CDN ảnh/tài liệu | ❌ (không cần) | @@ -166,7 +166,9 @@ Cộng thêm `_decrypt` dùng một hằng số AES (`svr_aes`) và một key đ | `lesvr/v2/getAllDayData` | GET | ❌ `998` | **Sai path** — đúng là `/lesvr/getAllDayData` | | `lesvr/getUserInfo` | GET | ❌ `203` | Tài khoản guest không đủ quyền (không phải lỗi endpoint) | -**Giá trị cao nhất:** `/lesvr/getAllDayData` — gộp PV + battery + load + grid vào **một call duy nhất**, thay cho 3 call riêng (`getPVDayData` + `getBatDayData` + `getOtherDayData`) mà integration đang dùng. Phép đối chiếu ở mục 7.2 đo được `pv`, `grid`, `homeload`, `essentialLoad` **khớp nhau** trên cả hai đường; phần **pin thì chưa kiểm chứng được** (thiết bị test báo không có pin nên cả hai đường đều ra 0). +**Giá trị cao nhất:** `/lesvr/getAllDayData` — gộp PV + battery + load + grid vào **một call duy nhất**, thay cho 3 call riêng (`getPVDayData` + `getBatDayData` + `getOtherDayData`). Phép đối chiếu ở mục 7.2 đo được `pv`, `grid`, `homeload`, `essentialLoad` **khớp nhau** trên cả hai đường; phần **pin thì chưa kiểm chứng được** (thiết bị test báo không có pin nên cả hai đường đều ra 0). + +> **Phạm vi thay thế:** chỉ đường coordinator dùng endpoint gộp; backfill trong `services/aggregator.py` vẫn gọi 3 endpoint legacy mỗi ngày. [`API_PROTOCOL.md`](API_PROTOCOL.md#api-endpoints) là nơi ghi nhận điều này. ### Tier 2 — Cần đánh giá thêm diff --git a/docs/api/API_TEST_GUIDE.md b/docs/api/API_TEST_GUIDE.md index fa977c2..6f70f35 100644 --- a/docs/api/API_TEST_GUIDE.md +++ b/docs/api/API_TEST_GUIDE.md @@ -1,8 +1,8 @@ # Hướng dẫn Test 2 API mới trong Postman > **Cập nhật:** `getYearData`/`getMonthData` vẫn chạy nhưng **không còn là API "mới"** — -> chúng cũng đã thành alias legacy giống 4 endpoint daily. Xem -> [`API_ENDPOINTS_DISCOVERED.md`](API_ENDPOINTS_DISCOVERED.md#12-bốn-endpoint-integration-đang-dùng-là-alias-legacy) +> chúng đã thành endpoint legacy giống các endpoint daily mà integration đang dùng. Xem +> [`API_ENDPOINTS_DISCOVERED.md`](API_ENDPOINTS_DISCOVERED.md#12-getservertime-là-legacy-v2getalldaydata-không-phải-đường-đi-đúng) > trước khi dùng hướng dẫn này để mở rộng integration. Phần còn lại của tài liệu > vẫn đúng với tư cách là cách test thủ công 2 endpoint đó. diff --git a/docs/dashboard/DASHBOARD_BATTERY_CHARGE_DISCHARGE_README.md b/docs/dashboard/DASHBOARD_BATTERY_CHARGE_DISCHARGE_README.md index af352c8..cf57e39 100644 --- a/docs/dashboard/DASHBOARD_BATTERY_CHARGE_DISCHARGE_README.md +++ b/docs/dashboard/DASHBOARD_BATTERY_CHARGE_DISCHARGE_README.md @@ -14,6 +14,7 @@ Dashboard này hiển thị biểu đồ nạp (charge) và xả (discharge) pin ### 1. API Response (HTTP API) ``` Endpoint: /lesvr/getBatDayData?deviceId={id}&queryDate={date} + (fallback — coordinator còn dùng /lesvr/getAllDayData; xem API_PROTOCOL.md) Response: { @@ -31,7 +32,7 @@ Response: } ``` -### 2. API Client Processing (`api_client.py:616-638`) +### 2. API Client Processing (xem `core/api_client.py`: `_fetch_battery_data` / `_build_battery_result`) ```python # API trả về: positive = discharge, negative = charge series_w = [500, 500, -200, -300, ...] # Từ API @@ -45,7 +46,7 @@ inverted_series_w = [-w for w in series_w] result["battery_series_5min_w"] = inverted_series_w ``` -### 3. Sensor Entity Processing (`sensor.py:973-992`) +### 3. Sensor Entity Processing (`entities/sensor.py`: `extra_state_attributes`) ```python # Charge sensor (KEY_DAILY_CHARGE_KWH): battery_series = [-500, -500, 200, 300, ...] # Từ coordinator diff --git a/tests/conftest.py b/tests/conftest.py index a9ab840..ffe98b0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -57,6 +57,7 @@ if getattr(_policy, "_loop_factory", None) is not asyncio.SelectorEventLoop: _policy._loop_factory = asyncio.SelectorEventLoop # type: ignore[attr-defined] + # Home Assistant is an optional dependency of this suite. The modules under # test (the MQTT payload parser and the cache store) are plain Python; only # their module-level imports need these names to exist. The end-to-end test diff --git a/tests/test_day_stats_mapping.py b/tests/test_day_stats_mapping.py index abf3f04..0abce18 100644 --- a/tests/test_day_stats_mapping.py +++ b/tests/test_day_stats_mapping.py @@ -65,9 +65,10 @@ class TestMetricHelpers: def test_total_is_converted_from_tenths_of_a_kwh(self, lumentree_api_client) -> None: - assert lumentree_api_client.LumentreeHttpApiClient._metric_total_kwh( - {"tableValue": 170} - ) == 17.0 + assert ( + lumentree_api_client.LumentreeHttpApiClient._metric_total_kwh({"tableValue": 170}) + == 17.0 + ) @pytest.mark.parametrize("value", [None, "not-a-number", {}]) def test_unusable_totals_come_back_as_none(self, lumentree_api_client, value) -> None: @@ -79,9 +80,10 @@ def test_unusable_totals_come_back_as_none(self, lumentree_api_client, value) -> if value == {}: assert lumentree_api_client.LumentreeHttpApiClient._metric_total_kwh(value) is None else: - assert lumentree_api_client.LumentreeHttpApiClient._metric_total_kwh( - {"tableValue": value} - ) is None + assert ( + lumentree_api_client.LumentreeHttpApiClient._metric_total_kwh({"tableValue": value}) + is None + ) def test_missing_metric_yields_no_series(self, lumentree_api_client) -> None: assert lumentree_api_client.LumentreeHttpApiClient._slot_readings(None) == [] @@ -101,12 +103,13 @@ def test_each_sample_is_keyed_by_the_slot_it_was_reported_for( ) assert frame == [(0, 100.0), (2, 300.0)] - def test_a_series_with_no_readable_entry_frames_as_empty( - self, lumentree_api_client - ) -> None: - assert lumentree_api_client.LumentreeHttpApiClient._slot_readings( - {"tableValueInfo": [None, "x", {}]} - ) == [] + def test_a_series_with_no_readable_entry_frames_as_empty(self, lumentree_api_client) -> None: + assert ( + lumentree_api_client.LumentreeHttpApiClient._slot_readings( + {"tableValueInfo": [None, "x", {}]} + ) + == [] + ) class TestBatterySignConvention: @@ -141,11 +144,11 @@ def test_a_five_minute_step_feeds_only_one_side(self, lumentree_api_client) -> N assert charge_5min[0] > 0 assert discharge_5min[0] > 0 # No step adds to both totals: the magnitudes are the two inputs, not doubled. - assert charge_5min[0] + discharge_5min[0] == pytest.approx( - (100 + 100) * (5 / 60) / 1000 - ) + assert charge_5min[0] + discharge_5min[0] == pytest.approx((100 + 100) * (5 / 60) / 1000) - def test_hourly_rollup_may_contain_both_charge_and_discharge(self, lumentree_api_client) -> None: + def test_hourly_rollup_may_contain_both_charge_and_discharge( + self, lumentree_api_client + ) -> None: """A single hour can legitimately show both, and the totalling must not hide it. 12 five-minute steps fold into one hour. A battery that charges for part @@ -158,9 +161,7 @@ def test_hourly_rollup_may_contain_both_charge_and_discharge(self, lumentree_api assert built["battery_charge_series_hour_kwh"][0] > 0 assert built["battery_discharge_series_hour_kwh"][0] > 0 - def test_an_unreported_slot_keeps_its_position( - self, lumentree_api_client - ) -> None: + def test_an_unreported_slot_keeps_its_position(self, lumentree_api_client) -> None: """A slot nobody reported holds its place instead of collapsing. The consumers derive the clock time from the array index @@ -176,9 +177,7 @@ def test_an_unreported_slot_keeps_its_position( [(0, 500.0), (2, -300.0)], None, None ) assert built["battery_series_5min_w"] == [500.0, 0.0, -300.0] - assert built["battery_discharge_series_hour_kwh"][0] == pytest.approx( - 300 * (5 / 60) / 1000 - ) + assert built["battery_discharge_series_hour_kwh"][0] == pytest.approx(300 * (5 / 60) / 1000) def test_a_frame_with_no_reading_at_all_yields_no_series(self, lumentree_api_client) -> None: """An empty frame must not publish an empty series.""" @@ -227,9 +226,7 @@ def test_absent_batf_does_not_truncate_the_charge_series(self, lumentree_api_cli merged = self._merged(lumentree_api_client, ALL_DAY_NO_DISCHARGE) assert merged["battery_series_5min_w"] == [0.0, 0.0, 0.0, 0.0] - def test_the_hour_fold_keeps_each_sample_in_its_own_hour( - self, lumentree_api_client - ) -> None: + def test_the_hour_fold_keeps_each_sample_in_its_own_hour(self, lumentree_api_client) -> None: """The battery reproduction: a hole must not pull later readings back. `bat` reports a 500 W charge at slot 12 -- the first step of hour 1 -- @@ -238,9 +235,12 @@ def test_the_hour_fold_keeps_each_sample_in_its_own_hour( shortened the list. The hour comes from the slot, so it lands in hour 1 where it was actually reported. """ - merged = self._merged(lumentree_api_client, { - "bat": {"tableValue": 30, "tableValueInfo": [0, None] + [0.0] * 10 + [500.0]}, - }) + merged = self._merged( + lumentree_api_client, + { + "bat": {"tableValue": 30, "tableValueInfo": [0, None] + [0.0] * 10 + [500.0]}, + }, + ) rollup = merged["battery_charge_series_hour_kwh"] assert rollup[0] == 0.0 assert rollup[1] == pytest.approx(500 * (5 / 60) / 1000) @@ -250,23 +250,22 @@ def test_the_hour_fold_keeps_each_sample_in_its_own_hour( assert series[12] == 500.0 assert len(series) == 13 - def test_the_pv_hour_fold_keeps_each_sample_in_its_own_hour( - self, lumentree_api_client - ) -> None: + def test_the_pv_hour_fold_keeps_each_sample_in_its_own_hour(self, lumentree_api_client) -> None: """The same reproduction on the PV metric, which has no battery path. PV goes straight from the payload to the fold, so if only the battery builder were slot-aware this is the one that would still be wrong. """ - merged = self._merged(lumentree_api_client, { - "pv": {"tableValue": 60, "tableValueInfo": [0, None] + [0.0] * 10 + [500.0]}, - }) + merged = self._merged( + lumentree_api_client, + { + "pv": {"tableValue": 60, "tableValueInfo": [0, None] + [0.0] * 10 + [500.0]}, + }, + ) assert merged["pv_series_hour_kwh"][0] == 0.0 assert merged["pv_series_hour_kwh"][1] == pytest.approx(500 * (5 / 60) / 1000) - def test_a_hole_free_series_folds_exactly_as_before( - self, lumentree_api_client - ) -> None: + def test_a_hole_free_series_folds_exactly_as_before(self, lumentree_api_client) -> None: """Ordinary payloads must be untouched by the slot-aware fold. Every slot reported means slot == index for every sample, so the @@ -281,7 +280,6 @@ def test_a_hole_free_series_folds_exactly_as_before( assert merged["battery_charge_series_hour_kwh"][0] == pytest.approx(500 * step) assert merged["battery_discharge_series_hour_kwh"][0] == pytest.approx(300 * step) - def test_a_charge_only_day_publishes_a_full_discharge_rollup( self, lumentree_api_client ) -> None: @@ -291,15 +289,16 @@ def test_a_charge_only_day_publishes_a_full_discharge_rollup( charge steps and `batF` is absent. Both chart attributes have to carry 24 entries, or the discharge chart renders blank instead of flat. """ - merged = self._merged(lumentree_api_client, { - "bat": {"tableValue": 30, "tableValueInfo": [0.0, 0.0, 300.0, 0.0]}, - }) + merged = self._merged( + lumentree_api_client, + { + "bat": {"tableValue": 30, "tableValueInfo": [0.0, 0.0, 300.0, 0.0]}, + }, + ) discharge = merged["battery_discharge_series_hour_kwh"] assert len(discharge) == 24 assert sum(discharge) == 0.0 - assert merged["battery_charge_series_hour_kwh"][0] == pytest.approx( - 300 * (5 / 60) / 1000 - ) + assert merged["battery_charge_series_hour_kwh"][0] == pytest.approx(300 * (5 / 60) / 1000) def test_a_discharge_only_day_publishes_a_full_charge_rollup( self, lumentree_api_client @@ -316,9 +315,7 @@ def test_a_discharge_only_day_publishes_a_full_charge_rollup( charge = built["battery_charge_series_hour_kwh"] assert len(charge) == 24 assert sum(charge) == 0.0 - assert built["battery_discharge_series_hour_kwh"][0] == pytest.approx( - 300 * (5 / 60) / 1000 - ) + assert built["battery_discharge_series_hour_kwh"][0] == pytest.approx(300 * (5 / 60) / 1000) def test_a_frame_with_no_battery_at_all_still_publishes_no_rollup( self, lumentree_api_client @@ -328,9 +325,12 @@ def test_a_frame_with_no_battery_at_all_still_publishes_no_rollup( "This side was idle" and "there was no battery data" are different answers, and only the first one gets a zero-filled series. """ - merged = self._merged(lumentree_api_client, { - "pv": {"tableValue": 60, "tableValueInfo": [0, 0, 120, 240]}, - }) + merged = self._merged( + lumentree_api_client, + { + "pv": {"tableValue": 60, "tableValueInfo": [0, 0, 120, 240]}, + }, + ) assert "battery_charge_series_hour_kwh" not in merged assert "battery_discharge_series_hour_kwh" not in merged @@ -352,19 +352,25 @@ def test_a_hole_in_one_series_does_not_shift_the_other_series( gives [100, -50, 300, 0]: the hole is the only thing dropped, and the reported 0 at slot 3 is a reading, so it stays. """ - merged = self._merged(lumentree_api_client, { - "bat": {"tableValue": 30, "tableValueInfo": [100, None, 300, 0]}, - "batF": {"tableValue": 12, "tableValueInfo": [0, 50, 0, 0]}, - }) + merged = self._merged( + lumentree_api_client, + { + "bat": {"tableValue": 30, "tableValueInfo": [100, None, 300, 0]}, + "batF": {"tableValue": 12, "tableValueInfo": [0, 50, 0, 0]}, + }, + ) assert merged["battery_series_5min_w"] == [100.0, -50.0, 300.0, 0.0] def test_a_hole_in_the_discharge_series_keeps_the_charge_series_aligned( self, lumentree_api_client ) -> None: - merged = self._merged(lumentree_api_client, { - "bat": {"tableValue": 30, "tableValueInfo": [100, 300, 0, 200]}, - "batF": {"tableValue": 12, "tableValueInfo": [0, None, 50, 0]}, - }) + merged = self._merged( + lumentree_api_client, + { + "bat": {"tableValue": 30, "tableValueInfo": [100, 300, 0, 200]}, + "batF": {"tableValue": 12, "tableValueInfo": [0, None, 50, 0]}, + }, + ) assert merged["battery_series_5min_w"] == [100.0, 300.0, -50.0, 200.0] def test_a_short_series_publishes_only_the_slots_it_reported( @@ -376,15 +382,16 @@ def test_a_short_series_publishes_only_the_slots_it_reported( battery that went quiet at slot 2 does not draw a flat line at 0 W across the remainder of the chart. """ - merged = self._merged(lumentree_api_client, { - "bat": {"tableValue": 30, "tableValueInfo": [100, 300]}, - "batF": {"tableValue": 12, "tableValueInfo": [0, 50, None, None]}, - }) + merged = self._merged( + lumentree_api_client, + { + "bat": {"tableValue": 30, "tableValueInfo": [100, 300]}, + "batF": {"tableValue": 12, "tableValueInfo": [0, 50, None, None]}, + }, + ) assert merged["battery_series_5min_w"] == [100.0, 250.0] - def test_a_discharge_only_payload_reports_zero_charge( - self, lumentree_api_client - ) -> None: + def test_a_discharge_only_payload_reports_zero_charge(self, lumentree_api_client) -> None: """A day that only discharged reports no charge -- 0.0, not unknown. This is the correct answer for those inputs: nothing charged, so the @@ -392,9 +399,12 @@ def test_a_discharge_only_payload_reports_zero_charge( the substituted zero is a deliberate trade, so a future change that makes it an omission or a None has to be a conscious one. """ - merged = self._merged(lumentree_api_client, { - "batF": {"tableValue": 12, "tableValueInfo": [0, 300, 0, 0]}, - }) + merged = self._merged( + lumentree_api_client, + { + "batF": {"tableValue": 12, "tableValueInfo": [0, 300, 0, 0]}, + }, + ) assert merged["charge_today"] == 0.0 assert merged["discharge_today"] == 1.2 @@ -406,26 +416,23 @@ def test_a_discharge_only_payload_reports_zero_charge( assert discharge[0] == pytest.approx(300 * (5 / 60) / 1000) assert sum(discharge[1:]) == 0.0 - def test_a_charge_only_payload_reports_zero_discharge( - self, lumentree_api_client - ) -> None: + def test_a_charge_only_payload_reports_zero_discharge(self, lumentree_api_client) -> None: """The mirror of the previous case, and it must answer the same way.""" - merged = self._merged(lumentree_api_client, { - "bat": {"tableValue": 30, "tableValueInfo": [500, 0, 0, 0]}, - }) + merged = self._merged( + lumentree_api_client, + { + "bat": {"tableValue": 30, "tableValueInfo": [500, 0, 0, 0]}, + }, + ) assert merged["charge_today"] == 3.0 assert merged["discharge_today"] == 0.0 discharge = merged["battery_discharge_series_hour_kwh"] assert len(discharge) == 24 assert sum(discharge) == 0.0 - assert merged["battery_charge_series_hour_kwh"][0] == pytest.approx( - 500 * (5 / 60) / 1000 - ) + assert merged["battery_charge_series_hour_kwh"][0] == pytest.approx(500 * (5 / 60) / 1000) - def test_both_sides_present_are_unioned_within_the_payload( - self, lumentree_api_client - ) -> None: + def test_both_sides_present_are_unioned_within_the_payload(self, lumentree_api_client) -> None: """One payload carrying both sides keeps both, at their own slots. This is what bounds the cross-poll caveat: the union happens inside a @@ -433,10 +440,13 @@ def test_both_sides_present_are_unioned_within_the_payload( full. It is not a claim about a payload that drops a side it had previously reported. """ - merged = self._merged(lumentree_api_client, { - "bat": {"tableValue": 30, "tableValueInfo": [500, 0, 0, 0]}, - "batF": {"tableValue": 12, "tableValueInfo": [0, 300, 0, 0]}, - }) + merged = self._merged( + lumentree_api_client, + { + "bat": {"tableValue": 30, "tableValueInfo": [500, 0, 0, 0]}, + "batF": {"tableValue": 12, "tableValueInfo": [0, 300, 0, 0]}, + }, + ) assert merged["battery_series_5min_w"] == [500.0, -300.0, 0.0, 0.0] step = (5 / 60) / 1000 assert merged["battery_charge_series_hour_kwh"][0] == pytest.approx(500 * step) @@ -451,9 +461,12 @@ def test_a_hole_in_the_pv_series_is_published_at_its_own_index( (``Math.floor(index / 12)``), so the full list is asserted rather than its length: the 360 W must read out at index 2, not index 1. """ - merged = self._merged(lumentree_api_client, { - "pv": {"tableValue": 6, "tableValueInfo": [120, "bad", 360]}, - }) + merged = self._merged( + lumentree_api_client, + { + "pv": {"tableValue": 6, "tableValueInfo": [120, "bad", 360]}, + }, + ) assert merged["pv_series_5min_w"] == [120.0, 0.0, 360.0] def test_a_hole_no_side_reported_is_published_at_its_own_index( @@ -465,37 +478,45 @@ def test_a_hole_no_side_reported_is_published_at_its_own_index( it at all -- the case that collapses the list most easily. The 200 W at slot 3 must stay at index 3. """ - merged = self._merged(lumentree_api_client, { - "bat": {"tableValue": 30, "tableValueInfo": [500, 0, "x", 200]}, - "batF": {"tableValue": 12, "tableValueInfo": [0, 0, "y", 0]}, - }) + merged = self._merged( + lumentree_api_client, + { + "bat": {"tableValue": 30, "tableValueInfo": [500, 0, "x", 200]}, + "batF": {"tableValue": 12, "tableValueInfo": [0, 0, "y", 0]}, + }, + ) assert merged["battery_series_5min_w"] == [500.0, 0.0, 0.0, 200.0] - def test_filling_a_hole_does_not_change_the_sum( - self, lumentree_api_client - ) -> None: + def test_filling_a_hole_does_not_change_the_sum(self, lumentree_api_client) -> None: """The padding is 0.0, so every total derived from the list is unmoved. Asserted against the same payload without the hole, so this pins sum-neutrality rather than just a number. """ - with_hole = self._merged(lumentree_api_client, { - "pv": {"tableValue": 6, "tableValueInfo": [120, "bad", 360]}, - }) - without_hole = self._merged(lumentree_api_client, { - "pv": {"tableValue": 6, "tableValueInfo": [120, 0, 360]}, - }) + with_hole = self._merged( + lumentree_api_client, + { + "pv": {"tableValue": 6, "tableValueInfo": [120, "bad", 360]}, + }, + ) + without_hole = self._merged( + lumentree_api_client, + { + "pv": {"tableValue": 6, "tableValueInfo": [120, 0, 360]}, + }, + ) assert with_hole["pv_sum_kwh"] == without_hole["pv_sum_kwh"] assert with_hole["pv_series_hour_kwh"] == without_hole["pv_series_hour_kwh"] assert with_hole["pv_today"] == without_hole["pv_today"] - def test_a_frame_with_no_reading_still_publishes_no_series( - self, lumentree_api_client - ) -> None: + def test_a_frame_with_no_reading_still_publishes_no_series(self, lumentree_api_client) -> None: """An empty frame has no span to fill, so it publishes nothing.""" - merged = self._merged(lumentree_api_client, { - "pv": {"tableValue": 6, "tableValueInfo": []}, - }) + merged = self._merged( + lumentree_api_client, + { + "pv": {"tableValue": 6, "tableValueInfo": []}, + }, + ) assert "pv_series_5min_w" not in merged assert "pv_sum_kwh" not in merged @@ -503,9 +524,12 @@ def test_no_battery_at_all_yields_no_series_and_no_discharge( self, lumentree_api_client ) -> None: """Neither metric present means no battery reading, not a battery at 0.""" - merged = self._merged(lumentree_api_client, { - "pv": {"tableValue": 60, "tableValueInfo": [0, 0, 120, 240]}, - }) + merged = self._merged( + lumentree_api_client, + { + "pv": {"tableValue": 60, "tableValueInfo": [0, 0, 120, 240]}, + }, + ) assert "battery_series_5min_w" not in merged assert "charge_today" not in merged assert "discharge_today" not in merged @@ -518,10 +542,13 @@ def test_a_battery_that_reported_nothing_still_publishes_no_series( The captured device reports `bat` with a 288-point series and a total of 0, so presence alone cannot mean "there is a series to draw". """ - merged = self._merged(lumentree_api_client, { - "bat": {"tableValue": 0, "tableValueInfo": [None, "x", None]}, - "batF": {"tableValue": None, "tableValueInfo": []}, - }) + merged = self._merged( + lumentree_api_client, + { + "bat": {"tableValue": 0, "tableValueInfo": [None, "x", None]}, + "batF": {"tableValue": None, "tableValueInfo": []}, + }, + ) assert "battery_series_5min_w" not in merged assert merged["charge_today"] == 0.0 assert merged["discharge_today"] == 0.0 @@ -543,9 +570,7 @@ def test_a_payload_with_no_usable_metric_is_indistinguishable_from_empty( """ assert self._merged(lumentree_api_client, {"pv": None, "grid": "nonsense"}) == {} - def test_a_non_finite_total_is_treated_as_absent( - self, lumentree_api_client - ) -> None: + def test_a_non_finite_total_is_treated_as_absent(self, lumentree_api_client) -> None: """NaN and Infinity survive float(), so they need a finiteness guard. json.loads accepts the bare NaN/Infinity literals, so a malformed or @@ -561,9 +586,12 @@ def test_a_non_finite_total_is_treated_as_absent( assert total({"tableValue": float("-inf")}) is None assert total({"tableValue": "nan"}) is None - merged = self._merged(lumentree_api_client, { - "pv": {"tableValue": float("nan"), "tableValueInfo": []}, - }) + merged = self._merged( + lumentree_api_client, + { + "pv": {"tableValue": float("nan"), "tableValueInfo": []}, + }, + ) assert "pv_today" not in merged, f"a non-finite total was published: {merged}" def test_a_non_finite_total_does_not_defeat_the_emptiness_check( @@ -576,15 +604,16 @@ def test_a_non_finite_total_does_not_defeat_the_emptiness_check( the second check (NaN compares False against the threshold), so a guarded payload has to come back empty rather than merely NaN-free. """ - merged = self._merged(lumentree_api_client, { - "pv": {"tableValue": float("inf"), "tableValueInfo": []}, - "grid": {"tableValue": float("-inf"), "tableValueInfo": []}, - }) + merged = self._merged( + lumentree_api_client, + { + "pv": {"tableValue": float("inf"), "tableValueInfo": []}, + "grid": {"tableValue": float("-inf"), "tableValueInfo": []}, + }, + ) assert merged == {}, f"a non-finite payload was not treated as empty: {merged}" - def test_a_non_finite_series_sample_becomes_a_hole( - self, lumentree_api_client - ) -> None: + def test_a_non_finite_series_sample_becomes_a_hole(self, lumentree_api_client) -> None: """An unreadable sample is a hole, exactly like one that raises. The series is published as an attribute rather than through the cache, @@ -593,12 +622,15 @@ def test_a_non_finite_series_sample_becomes_a_hole( is published as 0.0 at its own index so the 300 W after it stays at index 2, which is the slot it was reported for. """ - merged = self._merged(lumentree_api_client, { - "pv": { - "tableValue": 60, - "tableValueInfo": [0.0, float("nan"), 300.0] + [0.0] * 9, + merged = self._merged( + lumentree_api_client, + { + "pv": { + "tableValue": 60, + "tableValueInfo": [0.0, float("nan"), 300.0] + [0.0] * 9, + }, }, - }) + ) # The hole keeps its slot, so 300 W stays at slot 2 -- hour 0. assert merged["pv_series_5min_w"] == [0.0, 0.0, 300.0] + [0.0] * 9 assert not any(math.isnan(v) for v in merged["pv_series_hour_kwh"]) @@ -609,12 +641,15 @@ def test_a_non_finite_sample_in_the_battery_series_becomes_a_hole( self, lumentree_api_client ) -> None: """The battery frame has its own coercion path through the merge.""" - merged = self._merged(lumentree_api_client, { - "bat": { - "tableValue": 30, - "tableValueInfo": [0.0, float("inf"), 300.0] + [0.0] * 9, + merged = self._merged( + lumentree_api_client, + { + "bat": { + "tableValue": 30, + "tableValueInfo": [0.0, float("inf"), 300.0] + [0.0] * 9, + }, }, - }) + ) assert merged["battery_series_5min_w"] == [0.0, 0.0, 300.0] + [0.0] * 9 assert not any(math.isnan(v) for v in merged["battery_charge_series_hour_kwh"]) @@ -628,15 +663,16 @@ def test_an_absent_charge_side_is_normalised_like_an_absent_discharge_side( 0.0 -- the same substituted zero the discharge direction replaces explicitly. Both directions must agree in the dict they return. """ - merged = self._merged(lumentree_api_client, { - "batF": {"tableValue": 12, "tableValueInfo": [0, 0, 300, 0]}, - }) + merged = self._merged( + lumentree_api_client, + { + "batF": {"tableValue": 12, "tableValueInfo": [0, 0, 300, 0]}, + }, + ) assert merged["charge_today"] == 0.0 assert merged["discharge_today"] == 1.2 - def test_an_out_of_range_integer_is_dropped_not_raised( - self, lumentree_api_client - ) -> None: + def test_an_out_of_range_integer_is_dropped_not_raised(self, lumentree_api_client) -> None: """float() refuses an oversized integer, and the refusal must be caught. json.loads keeps an integer literal of any magnitude as a Python int, @@ -664,9 +700,7 @@ def test_an_out_of_range_integer_in_a_parsed_body_drops_only_that_sample( samples intact rather than raising. """ parsed = json.loads( - '{"pv": {"tableValue": 60, "tableValueInfo": [120, 10000' - + "0" * 400 - + ", 240]}}" + '{"pv": {"tableValue": 60, "tableValueInfo": [120, 10000' + "0" * 400 + ", 240]}}" ) assert isinstance(parsed["pv"]["tableValueInfo"][1], int) @@ -686,9 +720,7 @@ class TestLegacyBatteryPath: not have changed what this path publishes. """ - def test_the_legacy_signed_series_is_negated_and_kept_whole( - self, lumentree_api_client - ) -> None: + def test_the_legacy_signed_series_is_negated_and_kept_whole(self, lumentree_api_client) -> None: """Positive on the wire means discharge; the sensor reads the opposite. The device is the authority here (docs/api/API_PROTOCOL.md), and the @@ -730,13 +762,9 @@ def test_the_legacy_signed_series_is_negated_and_kept_whole( assert result["battery_discharge_series_hour_kwh"][0] == pytest.approx( 500 * (5 / 60) / 1000 ) - assert result["battery_charge_series_hour_kwh"][0] == pytest.approx( - 200 * (5 / 60) / 1000 - ) + assert result["battery_charge_series_hour_kwh"][0] == pytest.approx(200 * (5 / 60) / 1000) - def test_a_non_finite_legacy_total_does_not_surface_nan( - self, lumentree_api_client - ) -> None: + def test_a_non_finite_legacy_total_does_not_surface_nan(self, lumentree_api_client) -> None: """The legacy totals need the same guard as the combined endpoint's. ``bats[0].tableValue`` is read straight off the wire by this path, and @@ -760,9 +788,9 @@ def test_a_non_finite_legacy_total_does_not_surface_nan( assert result["charge_today"] is None assert result["discharge_today"] == 1.2 - assert not any( - math.isnan(v) for v in result.values() if isinstance(v, float) - ), f"a non-finite legacy total reached the caller: {result}" + assert not any(math.isnan(v) for v in result.values() if isinstance(v, float)), ( + f"a non-finite legacy total reached the caller: {result}" + ) def _combined_payload() -> dict: diff --git a/tests/test_e2e_mqtt_to_entity.py b/tests/test_e2e_mqtt_to_entity.py index 0b4abac..b1b00d0 100644 --- a/tests/test_e2e_mqtt_to_entity.py +++ b/tests/test_e2e_mqtt_to_entity.py @@ -80,7 +80,7 @@ def build_frame(registers: dict[int, int], n_regs: int = 151) -> str: # A full frame with one value pinned, so an assertion has something exact to # compare against without hardcoding the whole register map here. FRAME_FULL = build_frame({50: 77}) # register 50 = BATTERY_SOC -BAD_CRC_FRAME = (SEPARATOR + "01030000ffff") +BAD_CRC_FRAME = SEPARATOR + "01030000ffff" DEVICE_INFO = { "deviceId": DEVICE_SN, @@ -169,6 +169,7 @@ def vendor_http(aioclient_mock): the mocker, so a forgotten call fails the test instead of reaching the internet. """ + # ``aioclient_mock``'s response object exposes ``.status`` but not # ``response.ok``, which is the aiohttp property ``api_client._request`` # reads. Deriving it here keeps the shim at the mock boundary instead of @@ -237,9 +238,7 @@ async def _setup(hass: HomeAssistant, entry, mqtt: FakeTransport, monkeypatch) - # line, so running it is both safe and the point of the exercise. monkeypatch.setattr(mqtt_module.LumentreeMqttClient, "connect", mqtt.connect) monkeypatch.setattr(mqtt_module.LumentreeMqttClient, "async_request_data", mqtt.noop) - monkeypatch.setattr( - mqtt_module.LumentreeMqttClient, "async_request_battery_cells", mqtt.noop - ) + monkeypatch.setattr(mqtt_module.LumentreeMqttClient, "async_request_battery_cells", mqtt.noop) assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -292,7 +291,9 @@ def _deliver(client, payload_hex: str, topic: str | None = None) -> None: The first two arguments are paho's client and userdata, which ``_on_message`` never reads; only ``msg.topic`` and ``msg.payload`` matter. """ - msg = type("Msg", (), {"topic": topic or client._topic_sub, "payload": bytes.fromhex(payload_hex)}) + msg = type( + "Msg", (), {"topic": topic or client._topic_sub, "payload": bytes.fromhex(payload_hex)} + ) client._on_message(None, None, msg) @@ -316,7 +317,9 @@ def _state(hass: HomeAssistant, entity_id: str): return state -async def test_constructed_frame_reaches_entity_state(hass: HomeAssistant, entry, vendor_http, monkeypatch): +async def test_constructed_frame_reaches_entity_state( + hass: HomeAssistant, entry, vendor_http, monkeypatch +): """A constructed frame ends up as decoded numbers on the entity states.""" ctx = await _setup(hass, entry, FakeTransport(), monkeypatch) @@ -359,7 +362,9 @@ async def test_every_parsed_key_has_an_entity(hass: HomeAssistant, entry, vendor ) -async def test_dispatcher_signal_matches_between_client_and_sensor(hass: HomeAssistant, entry, vendor_http, monkeypatch): +async def test_dispatcher_signal_matches_between_client_and_sensor( + hass: HomeAssistant, entry, vendor_http, monkeypatch +): """The client's signal and the entities' subscription are one string. Both are built from ``SIGNAL_UPDATE_FORMAT`` in different modules. If either @@ -380,7 +385,9 @@ async def test_dispatcher_signal_matches_between_client_and_sensor(hass: HomeAss ) -async def test_malformed_frame_leaves_previous_state_intact(hass: HomeAssistant, entry, vendor_http, monkeypatch): +async def test_malformed_frame_leaves_previous_state_intact( + hass: HomeAssistant, entry, vendor_http, monkeypatch +): """Garbage on the topic must not blank out entity state.""" ctx = await _setup(hass, entry, FakeTransport(), monkeypatch) From 7f9313e6ae77cedaab0b338cc686868f3f319b6d Mon Sep 17 00:00:00 2001 From: Vokupt Date: Sun, 13 Sep 2026 14:44:05 +0700 Subject: [PATCH 24/24] no-mistakes(lint): annotate the transport-error responses dict for mypy --- tests/test_day_stats_mapping.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_day_stats_mapping.py b/tests/test_day_stats_mapping.py index 0abce18..f405d0b 100644 --- a/tests/test_day_stats_mapping.py +++ b/tests/test_day_stats_mapping.py @@ -999,7 +999,9 @@ def test_a_transport_error_leaves_the_combined_endpoint_in_the_retry_ladder( for name in ("API_RETRY_BASE_DELAY", "API_RETRY_MAX_DELAY"): monkeypatch.setattr(lumentree_api_client, name, 0.0) - responses = dict(self._LEGACY) + # Annotated: this is the one site that stores an exception rather than + # a payload, so the dict cannot stay inferred as dict[str, dict]. + responses: dict[str, object] = dict(self._LEGACY) responses["/lesvr/getAllDayData"] = ServerConnectionError("connection dropped") client, session = self._client(lumentree_api_client, responses)