Skip to content

feat(negotiation): Negotiate through per-device sessions with plain and encrypted outers - #79

Draft
kb1ibt wants to merge 6 commits into
flip-dots:mainfrom
kb1ibt:pr/negotiation
Draft

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

Conversation

@kb1ibt

@kb1ibt kb1ibt commented Oct 6, 2026

Copy link
Copy Markdown

Third of four stacked PRs replacing #70; based on #78. It answers the protocol half of your review: each variant is its own class, chosen without a bool, and a device holds the one its firmware speaks.

A device now holds a negotiated session for each connection (SolixBLE/protocols/, typing.Protocol interfaces with @override):

  • Outer protocol (PlainOuter / EncryptedOuter): how the negotiation travels and which cipher the session uses. Plain opens with 0001 in the clear and runs CBC; encrypted opens with 4001 under the static GCM key and runs GCM. The session cipher follows the opening frame.
  • Path (EcdhPath): the ECDH key exchange that follows the shared opening, on either outer. It sends a fresh P-256 key every negotiation (as Add AS220 (SOLIX S2000) device support #65 and anker-solix-api#345 do), states its method in x005 as the app does (a5 40 on plain, a5 44 with the device's MTU and auth mode on encrypted), and sends x022 with the UTC offset as int32 seconds west and the POSIX time zone.
  • Choosing the outer: the advertisement's capability byte when the caller passes it (DeviceClass(ble_device, advertisement=...)), else the outer that last authorized on this instance, else the model's default. A device that drops the link before answering a plain 0001 refuses it, and connect() reopens once with the encrypted outer in the same call; the encrypted outer is never stepped down to plain. DeviceClass(ble_device) with no advertisement keeps working for HaSolixBLE, and type(device) stays the model class.
  • Authorization: plain at 0821; encrypted at 4827 status 00, or at the first session push the client can decrypt. A 4827 on the device-initiated pattern 030101 (the grant after a button press) reaches the same handler. A 09 status and its confirmation window are recorded on device.announcement; waiting for the button is left to the pairing PR, which overrides _negotiation_should_restart() / _negotiation_deadline().
  • PrimeDevice keeps its UUID, the encrypted default and _post_authorize (4200, 420a); its copy of the negotiation and its own _send_command go.

Prime frames: every Prime command now ends with the typed timestamp trailer fe 05 03 <ts> (the base _send_command), and 420a is built as the app builds it: a2 = region typed 02 (set_region(), else the host locale's, else GB), a3 = this client's identifier as the owner, no a5. These match what the Anker app sends to the Prime Charger 250W (A2345) and the Prime Charging Station (A91B2) and what their firmware reads. I have no 160W or 20K power bank to test on, so their new bytes are untested; could you, or a 160W/20K owner, try it?

Derived, not recorded, test vectors: the C1000 plain 4022 (new layout), the Prime 4200 / 420a, and the 32 Prime command frames with the typed trailer. Each was derived from the recorded session (the pinned test key and the device's recorded public point) and the old frames decrypt to the old plaintexts first. The pinned keys reproduce the public points in the recorded 0021 / 4021, so the recorded negotiations are unchanged up to those frames.

Also: typing_extensions for Python < 3.12 (override); PRIVATE_KEY and NEGOTIATION_COMMAND_* move from const.py to tests/const.py; tests/helpers.py gains install_session_keys, RecordingLink and MockDevice.refuse_after; docs: protocols.rst "Outer protocols".

kb1ibt and others added 3 commits October 5, 2026 16:23
Parameters only recognised a 00 prefix, so a reply such as 4827 09 a1021e00 parsed to an empty dict. Any first byte below the first TLV tag (0xa1) is now the reply's status, exposed as ParameterDict.status; pushes have none.

Building also wrote no prefix: the If condition combined two construct expressions with Python's `or` at import time, which left only `this._parsing`. A parsed reply now builds back to the same bytes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Frames are routed by the decoded pattern (composer, channel) and command (0x80 fragmented, 0x40 encrypted, 12-bit message type) instead of whole patterns:
- channel 0x01 reaches the negotiation on either composer, so the 030101 grant does too
- session frames on either composer reach futures and telemetry; 03000f replies are no longer dropped
- other channels are logged and dropped
- fragments are reassembled on the 0x80 flag instead of a 253-byte length
- a session frame is decrypted once, on the 0x40 flag; a second future no longer gets a double decrypt
- _process_session is the one dispatch point for session frames

SolixBLE.transport adds NegotiatingTransport (ff09) and LegacyTransport (1780, the flip-dots#64 transport); _TRANSPORT selects the GATT characteristics, a legacy-transport device skips negotiation, and discover_devices matches either service. SolixBLE.advertisement decodes the 0xffff record with a construct, and SolixBLE.factory picks the model class from it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
From the advertisements in SolixBLE and HaSolixBLE issues: product types b112 (F3800 Plus, the A1790's command and telemetry map), b006 (Solarbank 2 E1600 Pro), b103 (C800; A1753/4/5 share one map) and b119 (C1000 Plus / X Gen 2, the A1763's display-board build), and the advertised model names. The A1763 part-number entry goes: that model advertises "SOLIX C1000 Gen 2".

The A1340 Prime power bank advertises service 2215 and uses 22150002/22150003 characteristics with the ff09 framing and negotiation; it gets Transport2215, and the factory returns None for it as for the legacy transport until a class exists. discover_devices also matches the 0xffff record, which a passive scan carries without the services, and uses the scanner it is given.

Telemetry is matched on the 12-bit message type, so a clear fragmented 8405 (SolixBLE #1) is read as the c405 a model lists; the always-telemetry type stays 0x300.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
kb1ibt and others added 2 commits October 6, 2026 03:13
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A device holds a negotiated session per connection (SolixBLE.protocols): an outer protocol (PlainOuter: 0001 in clear, CBC session; EncryptedOuter: 4001 under the static GCM key, GCM session) and the ECDH path after the shared opening. The path sends a fresh P-256 key every negotiation, states its x005 method as the app does per outer, and sends x022 with the UTC offset as int32 seconds west and the POSIX time zone.

The outer comes from the advertisement's capability byte when given, else the outer that last authorized on this instance, else the model's default. A device that drops the link before answering a plain 0001 refuses it: connect() reopens once with the encrypted outer and never steps down. Encrypted sessions authorize at 4827 00 or the first decryptable session push; 4827 on 030101 reaches the same handler, and a 09 status and its window are recorded on device.announcement. The opening re-send and deadline sit behind _negotiation_should_restart() / _negotiation_deadline().

PrimeDevice keeps its UUID, the encrypted default and _post_authorize. Prime commands use the base's typed fe 05 03 trailer, and 420a carries the region (set_region(), else the host locale's, else GB) typed 02 and this client's identifier as the owner, as the app sends it. The changed test frames are derived from the recorded sessions.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A device that refuses the plain 0001 can drop the link before the write returns, and the write then raises BleakError out of connect(). If the link is down by then, the error is the refusal: the negotiation sees a dropped link and connect() reopens with 4001, as when the drop lands after the write. Seen on an A1783.

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

kb1ibt commented Oct 6, 2026

Copy link
Copy Markdown
Author

One follow-up commit on this PR (6d838d2), from running the plain-refusal case against a C2000 Gen 2 with the #81 console:

  • A drop during the opening write is a refusal too. The C2000 Gen 2 refuses a plain 0001 by dropping the link, and sometimes the drop lands before the write returns. The write then raised BleakError: disconnected out of connect() instead of reopening with 4001. When the drop came after the write, the reopen already worked: 4801 … 4822 00, 4827 00, connected. Now, if the opening write fails because the link has already dropped, the negotiation treats it as a dropped link, so connect() reopens with the encrypted outer either way. A write error on a link that's still up is still raised.
  • MockDevice.refuse_after(during_write=True) drops the link inside the write and raises, as bleak does; the new test failed with the same BleakError before the fix.

#80 and #81 are rebased on it.

Suite: 469 passed (468 before this commit). mypy --strict: 202 errors, unchanged.

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.

1 participant