Skip to content

feat: live BLE telemetry for Anker Prime chargers (A2345/A91B2) + C2000 G2 + frame reassembly (#42)#45

Open
kb1ibt wants to merge 8 commits into
flip-dots:mainfrom
kb1ibt:feat/prime-charger-live-subscribe
Open

feat: live BLE telemetry for Anker Prime chargers (A2345/A91B2) + C2000 G2 + frame reassembly (#42)#45
kb1ibt wants to merge 8 commits into
flip-dots:mainfrom
kb1ibt:feat/prime-charger-live-subscribe

Conversation

@kb1ibt

@kb1ibt kb1ibt commented Jul 17, 2026

Copy link
Copy Markdown

Summary

Adds live, cloud-free BLE telemetry for the Anker Prime charger family and the C2000 Gen 2 power station, plus a shared frame-reassembly fix (#42).

Two foundational changes are what make the newer firmware actually stream:

1. Computed (live) timestamps throughout the handshake — the major refactor.
The negotiation and session commands previously replayed timestamps hard-coded from a single captured app session: BASE_TIMESTAMP, the frozen a104<ts> baked into each stage constant (NEGOTIATION_COMMAND_*), and the fe04<ts> / fe0503<ts> command trailers. Anker's replay protection (the reason those timestamps exist) rejects a stale one, so on firmware that enforces it the device negotiated fine but never streamed and dropped the link after ~40s. Every frame is now built at send time with the live clock — the init, each negotiation stage, the confer, the registration, and every _send_command — via _ts() / _live_negotiation_packet (Prime, AES-GCM) and the equivalent base path (AES-CBC). Nothing is replayed from a frozen constant anymore. This is why the fixtures had to become timestamp-agnostic (a frozen_time fixture pins the clock so the on-wire bytes are reproducible).

2. Owner binding for hardened firmware.
Even with live timestamps, the hardened Prime firmware (the C1000 G2 "#22" lockdown, now shipping on some A2345 units) withholds telemetry until the client proves account ownership: the 4027 registration must bind the account owner_user_id. Add OWNER_USER_ID (opt-in; None keeps the legacy a224<uuid> registration for older firmware) — when set, register 4027 = a104<ts> a228<owner_user_id>, which is accepted (ack 00) where the UUID form is rejected (ack 09).

What's included

Prime chargers (prime_device / prime_charger_250w / prime_usb_charger) — the two changes above, plus: send the real 420b REALTIME_TRIGGER enable block (a20a…, was a no-op bare a10121) that starts the 4303 per-port stream; drop the stale app-UUID from the 420a getter; persist the stage-3 identity (serial / fw / MAC); expose serial, total power, and per-port switches.

A91B2 240W charging station (prime_charging_station_240w) — full support: base/CBC transport layered over the Prime class; 4a00 snapshot + 4303/4302 stream remapped onto one port view; AC-outlet switch states.

C2000 Gen 2 (c2000g2 / c1000g2) — new model; nested self-delimiting telemetry walkers factored into a shared parsing module.

Frame reassembly (#42) (device) — multi-fragment session frames reassembled with an ATT-MTU length gate so single- and multi-packet telemetry both decode, across GCM and CBC.

Docs — guide for obtaining owner_user_id without the Anker app.

Testing — validated on real hardware

device model verified
C2000 Gen 2 A1783 negotiation, telemetry, per-port decode; cross-checked vs cloud
Prime Charging Station 240W A91B2 negotiation, 4a00+4303 stream, per-port + AC-switch decode vs cloud
Prime Charger 250W A2345 negotiation, ca00+4303 stream (incl. the hardened 05F0 owner-binding), per-port decode vs cloud

Full test suite green (timestamp-agnostic negotiation fixtures + new parsing/reassembly coverage).

Not hardware-tested

PrimeCharger160w and PrimePowerBank20k inherit the same shared _post_connect, so they pick up these changes but I don't have the hardware to re-test them. The negotiation handshake order is unchanged vs main (verified) — including the live-timestamp switch, which changes frame contents but not the stage sequence. The only post-connect deltas they see are the appended 420b trigger and the UUID-dropped 420a (registration stays the legacy a224<uuid> since OWNER_USER_ID is unset). Protocol-compatible, but a confirmation on a 160w would be appreciated — happy to gate 420b to the 250w path instead if you'd prefer.

kb1ibt and others added 6 commits July 17, 2026 15:52
…00 decode)

The Prime negotiation replayed the stage-7 subscribe/registration with a
hard-coded timestamp, which the device rejects as a replay: it then never
streamed telemetry and dropped the link after ~40s. Send stage-7a/7b via
_send_command so each carries the live session timestamp.

- prime_device: send stage-7 subscribe (4200/a10121) and registration (420a)
  via _send_command; drop the frozen fe04<timestamp> trailer from the payload
  constants.
- prime_device: remove the single-packet _process_telemetry_packet override so
  Prime devices inherit the base multi-fragment reassembly (the 250W ca00 frame
  arrives as two fragments).
- prime_charger_250w: telemetry command ca00; per-port params a4..a9 (C1..A2).

Confirmed on an A2345 (250W): negotiates, streams ca00, and decodes per-port
voltage/current/power/status matching the Anker cloud values.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- prime_device: persist the stage-3 device identity (serial/BLE-firmware/MAC),
  which is sent only during negotiation and never in telemetry.
- prime_charger_250w: add serial_number (from that identity), total_power_out
  (sum of the six ports), and the five usbc_/usba_switch states (fields aa-ae).

Confirmed on an A2345: serial AQLQJB1G06101746, total 13.42 W, switches on.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…refactor

- Extract PrimeUsbCharger (six-USB-port telemetry + serial + the ca00 command),
  shared by both Prime chargers; PrimeCharger250w becomes a thin subclass.
- Add PrimeChargingStation240w (A91B2): USB ports via the base plus the two
  AC-outlet switch states (ac_1_switch/ac_2_switch); decode validated against the
  anker-solix-api A91B2 map. Live telemetry pending its post-ECDH enable step.
- Refactor negotiation: the device's trailing 4822/4827 after the stage-5 ECDH
  are acks, not stages needing a response. Move the registration (4027) and
  subscribe (4200) to _post_connect, mirroring the gen-2 power stations' 4100.
  Negotiation writes use write-with-response for reliability on weaker links.
- PrimeChargingStation240w overrides _post_connect to a no-op: it rejects the
  charger's 4027 registration (dropping the link) and its own enable sequence is
  unknown (needs an Anker-app handshake capture), so it negotiates and stays
  connected but does not stream yet.

Confirmed on an A2345: negotiates, registers + subscribes from _post_connect, and
streams ca00 telemetry, decoding per-port values matching the cloud.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Prime charger classes carried _EXPECTED_TELEMETRY_LENGTH = 198, an
untested guess inherited from the original prime_charger_250w file. On
real hardware the A2345 fragments its ca00 telemetry at 253 (253 + 101),
identical to the c1000g2 family, so the expected full-fragment length is
253, not 198. The field is currently unused, but the reassembly rework in
flip-dots#42 keys the single-vs-fragment gate on this constant,
so the correct value matters ahead of that landing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…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>
… decode

Hardened Prime firmware (the C1000 G2 "flip-dots#22" lockdown, now shipping on some
A2345 units) negotiates normally but withholds BLE telemetry until the client
proves account ownership: the 4027 registration must bind the account
owner_user_id (a228). A generic app UUID (a224) is acknowledged but rejected
(ack status 09) and the link idle-drops, so streaming was impossible on those
units. This completes live Prime telemetry and the shared decode plumbing the
newer models need.

prime_device:
- Add OWNER_USER_ID (opt-in; None keeps the legacy a224<uuid> registration for
  older firmware). When set, register with 4027 = a104<ts> a228<owner_user_id>,
  which the hardened firmware accepts (ack 00) and then streams.
- Send the real 420b REALTIME_TRIGGER enable block (a20a...) that starts the
  4303 per-port stream; it was previously a no-op bare a10121.
- Drop the stale app-UUID from the 420a getter.
- Build every negotiation and session frame with a live timestamp (init, each
  stage, the confer, registration, and _send_command) so the device's replay
  protection accepts them instead of dropping a stale/replayed value.

A91B2 240W charging station: full support -- base/CBC transport layered over the
Prime class, with the 4a00 snapshot and 4303/4302 stream remapped onto one port
view.

C2000 G2: add the model and factor the nested self-delimiting telemetry walkers
into a shared parsing module (also used by c1000g2).

device: reassemble multi-fragment session frames with a length gate so single-
and multi-packet telemetry both decode.

docs: guide for obtaining owner_user_id without the Anker app (anker-solix-api
login or app-traffic capture).

tests: make the Prime and base negotiation fixtures timestamp-agnostic via a
frozen_time fixture (frames now carry a live clock), regenerate the affected
fixtures, and add parsing coverage.

Note: only PrimeCharger250w (A2345) was validated on hardware. PrimeCharger160w
and PrimePowerBank20k inherit the same post-connect path and should still work
(the negotiation handshake order is unchanged; they add the 420b trigger with
OWNER_USER_ID unset), but were not re-tested on real devices.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@kb1ibt

kb1ibt commented Jul 17, 2026

Copy link
Copy Markdown
Author

Much of this work was done using Apple PacketLogger and comparing it against the app_log.log file from the AppData backup of the same mobile device. Tested against the 3 listed device models, and ran pytest MANY times.

Adds the missing coverage for the C2000 G2 c490 protobuf summary and the
Prime charger telemetry, and fixes the bugs that coverage surfaced.

c490 summary path (C1000 G2 / C2000 G2):
- import walk_protobuf in device.py -- the summary path raised NameError on
  the first real frame; nothing exercised it before.
- walk a2's protobuf value, not the whole frame: the summary was filling with
  garbage (.20/.6) so every .23.x property silently returned its default.
  _protobuf_body strips the a1/a2 wrapper (a2 is a bin field with a 2-byte
  length the flat TLV parser can't read) and bounds the slice to a2's declared
  length, so the walk sees exactly the protobuf and never the a3 trailer.
- walk_protobuf now records message containers and the wire-3/4 group marker
  instead of dropping them, matching the definitive 116-field reference -- an
  under-count is a wrong decode.

Prime chargers:
- promote the 4303/ca00 shared telemetry decode from the A91B2 station up to
  PrimeUsbCharger, so the A2345 charger inherits it and decodes its 4303 stream
  (ports remapped a2-a7 -> a4-a9) instead of logging it as unknown.

Tests (83 -> 123):
- test_summary.py: the c490 decrypt -> unwrap -> walk -> property path on real
  captured A1763 frames, plus _protobuf_body wrapper/bounding and defaults.
- test_parsing.py: read_varint boundaries, walk_protobuf across all wire types
  (container recording, group stop, string/hex leaves), walk_lv.
- test_reassembly.py: single-vs-fragment classification, the full-length
  threshold (derived, not hardcoded), 3-fragment runs, interleaved per-command
  buffers.
- test_devices.py: a real A1783 c421 frame's fields, expansion_present, and the
  Prime 250w/station ca00/4a00 snapshot + 4303 stream remap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@kb1ibt

kb1ibt commented Jul 18, 2026

Copy link
Copy Markdown
Author

Update — c490 summary fixes + test coverage (f102712)

Adds coverage for the two paths that had none — the C2000 G2 c490 protobuf
summary and the Prime charger telemetry — and fixes the bugs that surfaced once
they were actually exercised.

c490 device-summary (C1000G2 / C2000G2)

  • walk_protobuf was never imported in device.py, so the summary path raised
    NameError on the first real frame. Nothing hit it before, so it stayed latent.
  • The walk ran on the whole decrypted frame, but the protobuf is the value of
    the outer a2 field (a bin field with a 2-byte length the flat TLV parser
    can't read). Result: summary filled with garbage (.20/.6) and every .23.x
    property silently returned its default. New _protobuf_body strips the a1/a2
    wrapper and bounds the slice to a2's declared length, so the walk sees
    exactly the protobuf and never the trailing a3 string.
  • walk_protobuf now records message containers (and the wire-3/4 group marker)
    instead of dropping them, matching the reference 116-field decode. Verified over a
    288-frame corpus: leaf values unchanged, no field under-count.

Prime chargers

  • Promoted the 4303/ca00 shared telemetry decode from the A91B2 station up to
    PrimeUsbCharger, so the A2345 charger inherits it and decodes its 4303 stream
    (ports remapped a2a7a4a9) instead of logging it as unknown. The
    station keeps only its CBC/negotiation and AC-switch specifics.

Tests: 83 → 123

  • test_summary.py (new): the full decrypt → unwrap → walk → property path on real
    captured frames, _protobuf_body wrapper/bounding, and defaults-before-first-frame.
  • test_parsing.py: read_varint boundaries; walk_protobuf across every wire type
    (container recording, group stop, string vs hex leaves); walk_lv.
  • test_reassembly.py: single-vs-fragment classification, the full-length threshold
    (derived from _FRAME_OVERHEAD, not hardcoded), 3-fragment runs, interleaved
    per-command buffers.
  • test_devices.py: a real A1783 c421 frame's fields, expansion_present, and the
    Prime 250w/station ca00/4a00 snapshot + 4303 stream remap.

Note: the flat _parse_payload is intentionally left 1-byte-length only — Anker's
2-byte-length disambiguation is a guess-and-validate heuristic (type must be
str/bin + a validation pass), so the wrapper strip is handled structurally in
_protobuf_body rather than by generalizing the parser every device depends on.

@kb1ibt
kb1ibt marked this pull request as ready for review July 18, 2026 02:23
@kb1ibt
kb1ibt marked this pull request as draft July 18, 2026 02:26
@kb1ibt

kb1ibt commented Jul 18, 2026

Copy link
Copy Markdown
Author

I realized I have one more commit to add, the changes to the docs.

- add device pages for C2000G2 and PrimeChargingStation240w and wire them
  into the api toctree.
- power-station table: add a C2000 G2 (A1783) column; Time remaining is now
  ✅ (remaining_time_hours); mark the C1000/C2000 G2 fields the device
  exposes but the library does not yet decode as 🚧; add the c490-summary
  rows (max charge power, pack voltage, cumulative energy, charge presence).
- prime-charger table: add a 240w (A91B2) column and the A2345's newly
  exposed capabilities (port on/off state, total power, serial); mark the
  device-known-but-unimplemented features (port control, timer, charging
  mode, clock, brightness, knob, DevNote, ...) as 🚧; give the A91B2's two
  AC outlets their own row.
- add a legend (✅ / 🚧 / ❌ / N/A / ❔) and notes for the A91B2 USB-C-only
  control and the cloud-armed c490 rows.
- fix a duplicate ``anker-solix-api`` hyperlink target in owner_user_id.rst
  (use anonymous links) so the branch's Sphinx build is warning-clean.

The index rewrite also strips ragged trailing whitespace from the (otherwise
unchanged) solar and power-bank tables.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

1 participant