feat(ble): decode the c490 protobuf device-summary (C1000 G2 / C2000 G2) - #50
feat(ble): decode the c490 protobuf device-summary (C1000 G2 / C2000 G2)#50kb1ibt wants to merge 5 commits into
Conversation
…flip-dots#42) Fragment reassembly lived inside _process_telemetry_packet and only ran for _TELEMETRY_COMMANDS, so any other multi-fragment session frame (e.g. the C2000 G2 c490 device-info blob) had only its first fragment decrypted and the rest dropped (flip-dots#42). Extract it into a shared _reassemble()/_join_fragments() that runs in _process_notification ahead of the cipher split, so telemetry and unknown session frames share one reassembler regardless of the AES variant (GCM vs CBC). Single vs fragment is decided by the live notification length (ATT_MTU - 3, via the ff09 _FRAME_OVERHEAD) rather than the frag byte, so families that put no frag byte on singles (the A91B2 station) need no per-device override; a short single keeps a 0x11 frag byte only when it is a valid single marker. Runs start only on index 1 and terminate on the <index><total> count (so an exact multiple of the cap, with no short tail, still completes); a partial/cold fragment that cannot decrypt is dropped rather than crashing the notification handler. Adds tests/test_reassembly.py (single-no-frag, 0x11 single, two-fragment, exact-multiple-no-tail, cold index!=1) and gives the mock client a realistic 256-byte MTU so the length gate exercises as it does on device. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…mware Replace the fixed-replay negotiation frames with live/dynamic frames -- each carries the current timestamp (and the stage-5 confer the local timezone), which newer firmware requires and rejects a stale one -- and add the account owner_user_id binding that hardened Prime devices need before they arm telemetry (without it they ack 09 and withhold updates). This is the shared connection layer that lets the C1000 G2 / C2000 G2 and hardened Prime chargers complete negotiation and stream, rather than being dropped mid-handshake (addresses flip-dots#22). Split out of flip-dots#45 per review; the c490 summary decode, Prime device support, and docs follow as separate PRs. Based on the reassembly PR (flip-dots#48). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The base 4022 confer sent a dynamic local timezone but declared a fixed a5 length of 0x16 (22 bytes), which only matches zones like EST5EDT,M3.2.0,M11.1.0. Any other zone misframes the TLV -- CET-1CEST,M3.5.0,M10.5.0/3 is 26 bytes, GMT0BST,M3.5.0/1,M10.5.0 is 24, JST-9 is 5 -- so the device reads a truncated timezone and parses the remainder as garbage. Compute the length from the string, as the Prime confer in prime_device.py already does. Byte-identical on 22-byte (US Eastern/Mountain) hosts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…dance, link the page Review feedback from @pkolbus on flip-dots#49; all three points verified before changing anything: - anker-solix-api is NOT on PyPI (pypi.org/pypi/anker-solix-api returns 404), so both 'pip install anker-solix-api' and the PyPI link were wrong. It is a Poetry project and a plain pip install from git does not pull its dependencies, so document clone + poetry install, plus a uv inline-metadata script that resolves the declared deps into a throwaway env -- nothing lands in the system or HA Python. The uv approach and prompting for credentials are @pkolbus's suggestions. - The import 'from api.api import AnkerSolixApi' does not resolve (the package is anker_solix_api), so the snippet failed on copy-paste. Now 'from anker_solix_api.api import AnkerSolixApi'. - Added a Country code section: the third constructor argument selects the API server region, so a wrong value fails the login outright rather than returning a different result. Points at API_COUNTRIES in apitypes.py for the valid codes. Also adds the page to the index toctree -- it was introduced here without one, so Sphinx warned 'document isn't included in any toctree' and the page was unreachable by navigation. Verified with the declared sphinx 8.2/rtd-theme 3.1.0: no warnings from this file, the page links from index, and the countryid anchor resolves. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add walk_protobuf (parsing.py) + _protobuf_body to decode the protobuf device-summary frame (the C2000 G2's c490) into a `.path` field map exposed via `summary`, rather than mis-reading it as 1-byte-tag TLV (which garbles the varint tags). Add the C2000 G2 (A1783) device on top of the C1000 G2. Split out of flip-dots#45 per review; stacked on the negotiation PR. Prime device support and docs follow as separate PRs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
flip-dots
left a comment
There was a problem hiding this comment.
The more I read this PR the more I think this needs to be in its own dedicated class (maybe a new type e.g cloud_device.py) since its quite different from the way a lot of other devices work but I am not entirely decided about that.
Is this cloud stuff in the negotiations 100% needed to make this device work on the latest firmware? How does the app handle local usage only? Is there a reset command we can use to use our own cloud ID or some sort of adoption process we can use?
I am not completely against allowing this library to work with a device already set up with the cloud (especially for the solar banks for which its more or less mandatory) but if it can be avoided (which I am hoping it can be) I would rather we make sure there is nothing that can be done there first before implementing this cloud protocol that is overly difficult for an end user to use.
| Use this class to connect, monitor and control a Gen 2 C2000(X) power | ||
| station. This model is also known as the A1783. | ||
|
|
||
| The C2000 G2 is the larger sibling of the C1000 G2 (A1763) and shares its |
There was a problem hiding this comment.
Technical details like this (anything which starts talking about implementation rather than what you need to know to use the module) which are automatically rendered in the docs should be hidden behind a collapsed note like here
| """ | ||
| return int(time.time()).to_bytes(4, "little").hex() | ||
|
|
||
| @staticmethod |
There was a problem hiding this comment.
This code sucks but I have had a quick look around and there does not seem to be a better way to do it. Maybe create a utils.py file and hide this code inside it, device.py is already large/complicated enough.
There was a problem hiding this comment.
I shouldn’t be surprised that even in 2026 time zones are still awful to deal with.
| def summary(self) -> dict[str, object]: | ||
| """Fields from the latest protobuf device-summary frame, if any. | ||
|
|
||
| Populated from a ``_PROTOBUF_TELEMETRY_COMMANDS`` frame (e.g. the C2000 G2's |
There was a problem hiding this comment.
This is probably fine for now but I might end up moving this stuff into a dedicated class which supporting devices can implement to get this functionality depending upon how widespread this bonus telemetry ends up being.
| return await self._client.write_gatt_char( | ||
| UUID_COMMAND, bytes.fromhex(NEGOTIATION_COMMAND_1) | ||
| UUID_COMMAND, | ||
| self._negotiation_packet("0003", "a30120a40200f0"), |
There was a problem hiding this comment.
Use constants for these and other magic values like them.
| #: (SolixBLE #22) firmware rejects a plain ``a224<uuid>`` registration (ack status | ||
| #: ``09``) and only arms telemetry once the registration carries the account that | ||
| #: owns the device. Account-specific -- MUST be sourced from config, never hard-coded | ||
| #: upstream. ``None`` falls back to the legacy ``a224<uuid>`` payload. |
There was a problem hiding this comment.
Do we know what happens if the device is setup with Bluetooth only (i.e not setup using the cloud)?
It would be good to have a way to use these newer devices without the need for the cloud at all.
That being said I think it still makes sense to support this for devices like the Solarbank where you might want both cloud and local control.
| """ | ||
| Send the negotiation initiation command. | ||
| """ | ||
| def _live_negotiation_packet(self, cmd: str, extra: str = "") -> bytes: |
There was a problem hiding this comment.
Why not reuse _negotiation_packet() from device.py?
| # Device identity (chip, BLE firmware, serial, MAC) is sent only | ||
| # in this stage-3 message, never in telemetry, so persist it for | ||
| # the identity properties to read. | ||
| self._device_info = parameters |
There was a problem hiding this comment.
I think self._device_info and self._summary should be named self._data_device and self._data_summary to be consistent with the existing self._data and distinguish it from other cached values.
Split out of #45 per review — the c490 protobuf device-summary decode.
Adds
walk_protobuf(parsing.py) +_protobuf_bodyto decode the protobuf device-summary frame (the C2000 G2'sc490) into a.pathfield map exposed viasummary, rather than mis-reading it as the 1-byte-tag TLV the base parser expects (which garbles the varint tags). Adds the C2000 G2 (A1783) device on top of the C1000 G2.Stacked on #49 (negotiation) → #48 (reassembly). GitHub shows the cumulative diff until those merge; this PR's own contribution is the last commit (the c490 decode). 106 tests pass.