Skip to content

feat(paths): Choose the negotiation path from the device's capability reply - #80

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

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

Conversation

@kb1ibt

@kb1ibt kb1ibt commented Oct 6, 2026

Copy link
Copy Markdown

Last of four stacked PRs replacing #70; based on the negotiation PR. The device's capability reply (x803) now picks the key-establishment path, so a device the library doesn't speak fails fast with an error naming what it declared, and the older AES-only modules (#63) get their own path.

  • Path registry (SolixBLE/protocols/session.py, PATHS = (EcdhPath, LegacyAesPath)): each path has matches(announcement, outer); the first that matches runs the rest of the negotiation. A path can depend on the outer, so legacy AES is offered only after a plain 0001 and is never reached by stepping down from 4001.
  • ECDH matches a3 & 0x44 with an auth method in a5 (every current build), on either outer.
  • Legacy AES matches a1 & 0x02 on the plain outer, e.g. the older a1 02 · a3 04 · no a5 reply in F3800 (A1790) fails encryption negotiation — device expects a newer key exchange (a5=02, 4022/4822/4023/4823) instead of 0021/0821 #63. 0005 states AES (a5 02); after 0805 00 the client keys CBC on its client id's first 16 bytes with the device serial's first 16 bytes as IV and sends 4022 (clock); 4822 returns 00 a1 10 <16-byte key>, which becomes the session key with the same IV; then 4023 binds the client (a2 client id, a3 serial), as the app sends it.
  • Rejections: no matching path, an x805 status other than 00, an x821 without the device key, or an x822 without a key raise UnsupportedNegotiation (now exported), and connect() returns False at once, logging the declared values (base_method, encrypt_method, auth_method, mtu) and the stage. Before, such a device was answered with the ECDH steps regardless and ran into the negotiation timeout.
  • Docs: protocols.rst "Paths" (the x803 table and how to add a path).

Legacy AES: the opening, the 0005, the initial key and IV, and the 4822 layout match the Anker app's own legacy sessions with an older Prime module, decrypted end to end. The switch to the 4822 key (IV unchanged) is how the device firmware installs it; I have no device on that firmware, so the path after 4822 is untested on hardware. #63 F3800 owners: could you try F3800(ble_device) on this branch and post the debug log? The tests use synthetic values only.

Also: EcdhPath and LegacyAesPath share record_identity and send_clock from protocols/base.py; tests/helpers.py gains feed_negotiation.

kb1ibt and others added 2 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 3 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>
The x803 reply picks the key-establishment path from a registry (PATHS = EcdhPath, LegacyAesPath); each path's matches(announcement, outer) decides, so legacy AES is offered only after a plain 0001 and never by stepping down from 4001.

ECDH matches a3 & 0x44 with an auth method in a5, on either outer. Legacy AES (flip-dots#63) matches a1 & 0x02 on the plain outer: 0005 states AES, CBC keyed on the client id and serial carries 4022, the key in 4822 becomes the session key with the same IV, and 4023 binds the client.

No matching path, a rejected x805, or an x821/x822 without a key raises UnsupportedNegotiation (now exported); connect() returns False at once, logging the declared values and the stage.

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

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