feat: live BLE telemetry for Anker Prime chargers (A2345/A91B2) + C2000 G2 + frame reassembly (#42)#45
Conversation
…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>
|
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>
Update — c490 summary fixes + test coverage (
|
|
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>
73eae66 to
702c016
Compare
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 frozena104<ts>baked into each stage constant (NEGOTIATION_COMMAND_*), and thefe04<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 (afrozen_timefixture 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
4027registration must bind the accountowner_user_id. AddOWNER_USER_ID(opt-in;Nonekeeps the legacya224<uuid>registration for older firmware) — when set, register4027 = a104<ts> a228<owner_user_id>, which is accepted (ack00) where the UUID form is rejected (ack09).What's included
Prime chargers (
prime_device/prime_charger_250w/prime_usb_charger) — the two changes above, plus: send the real420bREALTIME_TRIGGER enable block (a20a…, was a no-op barea10121) that starts the4303per-port stream; drop the stale app-UUID from the420agetter; 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;4a00snapshot +4303/4302stream remapped onto one port view; AC-outlet switch states.C2000 Gen 2 (
c2000g2/c1000g2) — new model; nested self-delimiting telemetry walkers factored into a sharedparsingmodule.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_idwithout the Anker app.Testing — validated on real hardware
4a00+4303stream, per-port + AC-switch decode vs cloudca00+4303stream (incl. the hardened05F0owner-binding), per-port decode vs cloudFull test suite green (timestamp-agnostic negotiation fixtures + new parsing/reassembly coverage).
Not hardware-tested
PrimeCharger160wandPrimePowerBank20kinherit 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 vsmain(verified) — including the live-timestamp switch, which changes frame contents but not the stage sequence. The only post-connect deltas they see are the appended420btrigger and the UUID-dropped420a(registration stays the legacya224<uuid>sinceOWNER_USER_IDis unset). Protocol-compatible, but a confirmation on a 160w would be appreciated — happy to gate420bto the 250w path instead if you'd prefer.