Skip to content

feat(ble): choose the negotiation path from the advertised capability byte - #70

Open
kb1ibt wants to merge 3 commits into
flip-dots:mainfrom
kb1ibt:pr/negotiation-path
Open

kb1ibt wants to merge 3 commits into
flip-dots:mainfrom
kb1ibt:pr/negotiation-path

Conversation

@kb1ibt

@kb1ibt kb1ibt commented Sep 20, 2026

Copy link
Copy Markdown

Replaces the negotiation half of #49, rebuilt from main on Harvey's review.

Anker devices announce which handshake they accept: the 0xffff manufacturer record in the advertisement ends in a capability byte whose 0x04 bit means the encrypted 4xxx negotiation (AES-GCM under a static key until the ECDH exchange, then under the shared secret). The C2000 G2 advertises it and its current comms firmware (v0.3.3.0) rejects the plain-text path; the Prime chargers use it already.

  • SolixBLE/advertisement.py: capability_from_advertisement(advertising_data).
  • SolixBLEDevice(ble_device, capability=None): the capability byte selects the path; without it the class default applies (Prime devices encrypted, everything else plain text), so DeviceClass(ble_device) keeps working for HaSolixBLE.
  • The GCM handshake moves from PrimeDevice into the base class behind _encrypted_negotiation; PrimeDevice keeps only its UUID, the encrypted default and its post-authorize commands (4200, 420a).
  • Crypto primitives (gcm_encrypt/gcm_decrypt, cbc_encrypt/cbc_decrypt, generate_ecdh_key, ecdh_public_bytes, ecdh_shared_secret, _offset_seconds_west) live in utilities.py.
  • A fresh P-256 key is generated for every negotiation (as in Add AS220 (SOLIX S2000) device support #65 and anker-solix-api#345); the old static key is gone from const.py and the tests pin their own keys through fake_time.
  • 4803 carries the device MTU and auth mode, both echoed back in 4005; 4022 carries the POSIX timezone and the UTC offset in seconds west, which newer firmware validates.
  • Docs: docs/source/protocols.rst (per-path sections, as suggested in the feat(prime): A91B2 (240W) + A2345 (250W) live BLE telemetry + control #51 review) replaces the encrypted-negotiation page; the index toctree points at it.

Validated on hardware: C2000 G2 (A1783, encrypted path) and Prime Charger 250W (A2345, encrypted path); the plain-text path is covered by the existing recorded frames. Not re-tested on hardware: the 160W charger and the power bank, which inherit the moved handshake.

Suite: 363 passed (main: 345).

🤖 Generated with Claude Code

kb1ibt and others added 3 commits September 13, 2026 14:30
The Anker manufacturer record (company id 0xffff) carries the device MAC,
model, and a capability byte declaring which negotiation path the device
accepts -- readable at scan time, before any frame is sent. Add a parser
for it as the basis for choosing the cleartext vs encrypted handshake per
device rather than by product class.

Capability is length-relative (last byte when present, absent on the F3800),
so it is derived from the sku length rather than read at a fixed offset.
Decoded against the app's own field values for five bench records.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Move the AES-GCM negotiation out of PrimeDevice into SolixBLEDevice so any
device can take it. The path is selected from the capability byte in the
device's advertisement when the caller passes one, otherwise from the class
default (encrypted for Prime devices, plain text for the Solix power
stations). The device's declared MTU and auth mode from 4803 are echoed in
4005, and the timezone confer carries the local UTC offset.

A fresh P-256 key pair is generated for every negotiation instead of the
hard-coded key pairs; the keys the recorded test vectors were captured with
move to the test constants and are pinned by the fake_time fixture. The
cipher and ECDH primitives live in utilities as plain functions.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@ladamczyk-it

Copy link
Copy Markdown

Confirming this is needed on the C1000 Gen 2 (A1763) as well.

Setup: Home Assistant 2026.9.1 (Docker), BlueZ, Raspberry Pi onboard BT (BCM43438), SolixBLE 4.0.0b1.

Advertisement: manufacturer data (company 0xffff) 02007f1d23a37000b118444b393604 → capability byte 04.

With 4.0.0b1 (plaintext path): GATT connect and notify subscription succeed, but ~200 ms after the first 0001 negotiation write the station drops the link. btmon shows Disconnect Complete – Reason: Remote User Terminated Connection (0x13). Same result with 3.9.0.

With the Prime/GCM handshake: I used a quick subclass class C1000G2Prime(PrimeDevice, C1000G2) so the upstream C1000G2 telemetry parsing stayed unchanged. Full negotiation 4001 → 4801 … 4027 → 4827 succeeds, and after subscribing (4100, a1=21) the station streams c421/c900 telemetry every ~3 s.
It stayed connected for 3.5 min with 172 packets, and battery %, health, temperature, AC/USB port status + power, SoC limits, serial and part number all decode correctly.

Two observations that may be useful:

So the C1000 G2 on current firmware needs the capability-byte selection from this PR. Happy to test a branch on hardware.

Comment thread docs/source/protocols.rst

.. _BLEDevice: https://bleak.readthedocs.io/en/latest/api/index.html#bleak.backends.device.BLEDevice/

Anker devices share one packet format but negotiate a session in more than one

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 is annoyingly not always true, it might be a good idea to mention #64 here.

Comment thread SolixBLE/advertisement.py
capability: int | None


def parse_manufacturer_record(data: bytes) -> AnkerAdvertisement | None:

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.

It would be good if this used construct like is used for packet parsing since it combines the parsing and structure of the data into one thing.

In theory it could be as simple as:

format = Struct(
    "version_code" / Int8ub,
    "mac" / Bytes(6),
    "bind_type" / Int8ub,
    "product_type" / Bytes(2),
    "sku" / Switch(
        this.version_code,
        {
            1: String(3, encoding="ascii"),
            2: String(4, encoding="ascii"),
        },
    ),
    "capability" / Optional(Int8ub),
)

parsed = format.parse(data)
print(parsed.mac)

I got this by pasting it into the free Google AI so its probably wrong, but you get the idea, its way neater than manually parsing stuff and using data classes.

Comment thread SolixBLE/device.py
"""Initialise device object. Does not connect automatically."""
#: Whether to negotiate on the encrypted (AES-GCM, ``4xxx``) path when the
#: advertised capability byte is not known. A known capability overrides it.
_DEFAULT_ENCRYPTED_NEGOTIATION: bool = False

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 don't like the idea of using a bool for this since I expect more Anker shenanigans to change things up yet again in future for which a bool would not be enough.

@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.

This is way more digestible, I appreciate you taking the time to break things up.

I don't love the idea of having the device.py class handle the negotiation for Prime and Solix devices in itself, my idea behind having a separate class for Prime devices was to allow it to share the same interface as before but still support the different way these protocols operate and allow me to make changes to the prime protocol without risking breaking support for Solix devices, it also has the benefit of reducing merge conflicts.

Now that its clear there are even more protocols I think it might be a good idea to make device.py an interface only which just defines function definitions and then move the Solix style protocol into its own file and each protocol variant can have its own class which devices can choose to inherit from.

It should be possible to support multiple protocols on the same physical device by having a class which just implements the properties for each (power, battery, etc) which is then inherited by a class which inherits from that and a protocol.

I think it should then be possible to make a class/function which takes the advertisement data and decides what type of device it is and what protocol to use and returns the correct class.

Any thoughts? I am reasonably confident its a better approach but I don’t have any experience with the newer protocol variant which could throw a wrench into the works of this plan.

@kb1ibt

kb1ibt commented Oct 5, 2026 •

Copy link
Copy Markdown
Author

Replying to @flip-dots:

Now that its clear there are even more protocols I think it might be a good idea to make device.py an interface only which just defines function definitions and then move the Solix style protocol into its own file and each protocol variant can have its own class which devices can choose to inherit from.

It should be possible to support multiple protocols on the same physical device by having a class which just implements the properties for each (power, battery, etc) which is then inherited by a class which inherits from that and a protocol.

I think it should then be possible to make a class/function which takes the advertisement data and decides what type of device it is and what protocol to use and returns the correct class.

Any thoughts? I am reasonably confident its a better approach but I don’t have any experience with the newer protocol variant which could throw a wrench into the works of this plan.

I took your suggestions and the inline review and restructured #70 into four stacked PRs; two are up. #77 is a bug fix: Parameters now reads any reply status byte, not just 00 (so 4827 09 keeps its a1), and a parsed reply builds back to the same bytes. #78 is the base the rest builds on:

  • it decodes the frame header (pattern = composer + channel, command = fragment/encrypted flags + 12-bit message type) and routes by channel, so the button-press grant on 030101 reaches the negotiation and 03000f replies are no longer dropped;
  • it reassembles on the fragment flag and decrypts once per frame;
  • it adds the two transports we know of, legacy (1780, Add support for older F2000/767 firmware version #64) and negotiating (ff09), using the names from your legacy_f2000 branch (UUID_TELEMETRY / UUID_COMMAND, UUID_IDENTIFIERS) so Add support for older F2000/767 firmware version #64 can sit on top without renames;
  • it has the advertisement-driven factory you suggested, device_class_from_advertisement(), parsed with a construct per your Add support for older F2000/767 firmware version #64 comment.

The remaining two PRs handle the negotiation itself. The outer protocol is plain (CBC session) or encrypted (static GCM, then dynamic GCM). The path inside it is chosen by the device's own capability reply: ECDH, legacy AES (#63), or an UnsupportedNegotiation error that names what the device announced when nothing matches.

On interface vs inheritance: I went with composition rather than one class per protocol that devices inherit. The protocol turned out to belong to the device's firmware, not its model. A C2000 G2 on v0.3.3.0 refuses the plain handshake that older units accept, and a firmware update can change the answer between two connections. So each device instance holds a negotiated session (outer + path, behind typing.Protocols), and the device class stays the model class. That keeps DeviceClass(ble_device) and HaSolixBLE's type(device) in [...] checks working, and lets one model run either protocol. Your interface_restructure names (AnkerBLEDevice and friends) would still fit as a later rename if you want them.

One thing I noticed on legacy_f2000: discover_devices calls .intersection on the UUID_IDENTIFIERS tuple, which raises AttributeError. #78 uses a membership test there instead.

This branch has not been deployed

No deployments
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.

3 participants