Skip to content

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

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

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

Conversation

@kb1ibt

@kb1ibt kb1ibt commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

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
kb1ibt force-pushed the pr/negotiation branch 2 times, most recently from 88aecf5 to f3d2ea6 Compare October 6, 2026 07:15
@kb1ibt

kb1ibt commented Oct 6, 2026

Copy link
Copy Markdown
Contributor 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.

kb1ibt and others added 3 commits October 7, 2026 16:32
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>
- Keys is a construct Struct over the shared secret (key, IV, 12-byte GCM nonce); the static key parses the key and nonce the same way.
- The announcement is a construct Container with the same fields; device.announcement keeps its shape.
- x803, x827 and the x005, x003 and x022 values parse and build through Int8ul, Int16ul and Int32sl; the client's a3 and MTU ceiling are named constants.
- install_session_keys leaves the keys alone for an empty secret, as the no-packets case expects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@flip-dots

Copy link
Copy Markdown
Owner

I think this is a good approach but the terminology is a bit confusing (though I don't know what else you could use to describe it), I think a diagram or two (even ASCII) would help a lot here, something which shows how those classes fit together and maybe a flow chart of how it works and what classes are called during a negotiation and how a packet is built / parsed. The way everything works has gotten a lot more complicated (though I don't have any objections to the complexity itself, its kind of needed) and I think it justifies some diagrams. I think its possible to embed diagrams in sphinx docs using mermaid if you want to try that but I have not used it much before.

I had been thinking of restructuring it like this but I think your approach is probably better. I plan on creating a new pre-release with all of the current merged changes and seeing how that goes before taking another look at this either next week or the one after.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kb1ibt added a commit to kb1ibt/SolixBLE that referenced this pull request Oct 12, 2026
Adds the flip-dots#79 diagrams in their path-aware form, both paths' reply tables, and a note on how the A91B2 answers each outer.

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

kb1ibt commented Oct 12, 2026

Copy link
Copy Markdown
Contributor Author

ASCII diagrams in the RST, here's the mermaid as well,
SolixBLE-r3-protocols-mermaid.md

@kb1ibt
kb1ibt marked this pull request as ready for review October 12, 2026 02:57

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.

2 participants