Skip to content

feat(prime): A2345 charger + A91B2 station live BLE telemetry - #51

Open
kb1ibt wants to merge 8 commits into
flip-dots:mainfrom
kb1ibt:pr/prime
Open

feat(prime): A2345 charger + A91B2 station live BLE telemetry#51
kb1ibt wants to merge 8 commits into
flip-dots:mainfrom
kb1ibt:pr/prime

Conversation

@kb1ibt

@kb1ibt kb1ibt commented Jul 21, 2026

Copy link
Copy Markdown

Split out of #45 per review — live BLE telemetry for the Anker Prime chargers.

Adds the Prime USB-charger base (prime_usb_charger) carrying the shared 4303/ca00 telemetry decode, and builds the A2345 (250W charger) and A91B2 (240W charging station) devices on it: per-port voltage/current/power/status, total output, per-port switches, and the station's two-frame-layout / AC-switch specifics.

Stacked on #50 (c490) → #49 (negotiation) → #48 (reassembly). GitHub shows the cumulative diff until those merge; this PR's own contribution is the last commit (the Prime device support). 123 tests pass.

kb1ibt and others added 2 commits July 20, 2026 13:29
…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>
kb1ibt and others added 5 commits July 26, 2026 03:32
…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>
Add the Anker Prime USB-charger base (prime_usb_charger) carrying the shared
4303/ca00 telemetry decode, and build the A2345 (250W charger) and A91B2 (240W
charging station) devices on it: per-port voltage/current/power/status, total
output, per-port switches, and the station's two-frame-layout / AC-switch
specifics.

Split out of flip-dots#45 per review; stacked on the negotiation + c490 PRs. Docs follow
as a separate PR.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Prime chargers stream ~1/s after a 420b trigger, but the realtime window lapses after ~10s, so the stream stalls unless the client re-arms it. Add a generic keepalive task to SolixBLEDevice: when a subclass sets _KEEPALIVE_CMD, connect() starts a background loop re-sending it every _KEEPALIVE_INTERVAL (8s) and disconnect() cancels it.

PrimeDevice sets _KEEPALIVE_CMD=420b (default a10121 payload via _send_command). PrimeChargingStation240w (A91B2, CBC) overrides _keepalive_loop to send 420b as a10121fe0503<ts> through _send_packet (response=True), matching its _post_connect framing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The A91B2 confer replayed a hardcoded US-Eastern POSIX TZ string (originally a GB string from the captured app session, later my own zone). Use the base _local_posix_tz() helper -- like the base and Prime confer paths already do -- with a dynamic a5 length byte, so any host sends its own zone. On an Eastern-time host the on-wire payload is byte-identical to the previous constant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@flip-dots flip-dots left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not certain but I suspect that these devices would work if you just changed the base class from PrimeDevice to SolixBLEDevice for them since the negotiations look very similar to that of earlier Solix devices and that protocol is implemented in the base SolixBLEDevice.

Other than that try to stick to pre-existing convention, like properties not being inherited, using existing types, and using the same property names.

@property
def usb_c2_current(self) -> float:
"""USB C2 Port current (A).
def usb_c1_switch(self) -> bool:

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it not possible to use PortStatus like the other devices?

@property
def usb_c2_power(self) -> float:
"""USB C2 Port power (W).
def usb_c2_switch(self) -> bool:

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needs to use the same naming scheme as other devices. e.g here

"""
return PortStatus(self._parse_int("a4", begin=1, end=2))

@property

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Device properties (e.g power, voltage, etc) should only be on the device class itself (e.g C300.py) not in a class inherited by others (e.g device.py / prime_device.py).

Hard coding port selections into a class which might be inherited by multiple devices prevents you from using it with future devices which may not have those ports. E.g prime_charger_160w is unable to use this class since it only has 3 USB C ports and no type A ports.

_LOGGER = logging.getLogger(__name__)

#: Cleartext-negotiation / confer packet pattern (``0xxx`` and ``4022``/``4023``).
_NEGOTIATION_PATTERN = b"\x03\x00\x01"

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use the same format as other constants.

class PrimeChargingStation240w(PrimeUsbCharger):
"""Anker Prime Charging Station (240W / A91B2), an 8-in-1 charging station.

Despite sharing the Prime USB-charger telemetry layout, the station is **not**

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Technical details not needed to simply use the library should be hidden in a collapsed note.



class PrimeChargingStation240w(PrimeUsbCharger):
"""Anker Prime Charging Station (240W / A91B2), an 8-in-1 charging station.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Damn all the cool Anker stuff seems to be for the US market only, it would be cool as hell if there was a UK/EU version of this.

)[1:]

stages = (
("0001", "a104" + self._ts(), "0801"),

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks suspiciously similar to the negotiation for the regular Solix devices. Does treating it as a Solix rather than a Prime type device work?

timezone = self._local_posix_tz().encode()

# 4022 -- timezone; 4023 -- bind device serial (both AES-CBC, 030001).
await self._send_packet(

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given the similarity to the Solix style devices which usually don't need polling to provide any info, is this needed?

# ------------------------------------------------- 4a00 snapshot additions

@property
def ac_1_switch(self) -> bool:

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use the same property names as existing ones from other devices. There is already ac_output and since this is the first device with two AC outputs I think it makes sense to call them ac_output_1 and ac_output_2. Try to stick to convention for the others as well since the Home Assistant integration which uses this library relies on different devices using the same names for ports.

return bool(self._parse_int("ab", begin=1, end=2))

@property
def usb_total_power_out(self) -> float:

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you really want this property we can keep it but I would rather avoid adding more properties that don't already exist (e.g total_power_out is already established) since its more maintenance and testing and its easy enough for someone to aggregate the values themselves.

@flip-dots flip-dots mentioned this pull request Aug 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants