Skip to content

feat(tools): add an interactive console for manually running the library - #81

Draft
kb1ibt wants to merge 10 commits into
flip-dots:mainfrom
kb1ibt:pr/cli
Draft

kb1ibt wants to merge 10 commits into
flip-dots:mainfrom
kb1ibt:pr/cli

Conversation

@kb1ibt

@kb1ibt kb1ibt commented Oct 6, 2026

Copy link
Copy Markdown

Adds solixble, an interactive console for finding, connecting to and driving devices by hand, with every frame shown each way in clear text. Based on the paths PR; a separate concern from the protocol work, so it can wait or go independently.

pip install "SolixBLE[cli]"
solixble            # or: python -m SolixBLE
solixble> scan 10
solixble> connect 0
solixble> send 404a PARAMETERS_ON
solixble> console
  • scan [secs] lists advertising Anker devices with the advertisement's MAC, product type, sku and capability, and the class device_class_from_advertisement picks; scan [secs] raw adds each device's advertisement as bytes (the 0xffff record, marked when it doesn't parse, other manufacturer data, service data and services), for models whose record isn't known yet. connect <n|mac|address> [class] connects one, as that class or one you name.
  • info shows the class, outer protocol, path and the device's announcement; data, props, constants and call <method> [args] (enums by member name) use the device class as HaSolixBLE does.
  • send (_send_command), packet (_send_packet on any pattern) and nego (a frame on 030001 under the live session) take a parameter dict as the device modules write it, or a PARAMETERS_* name; '@ts' is the live timestamp. The frames of the next two seconds are printed after each send.
  • frames [n] shows every frame, both directions, as cleartext with its status byte and tags. capture <file> appends the whole session to a file, never truncated: the frames, each command and its output, the Python console's input and log records with tracebacks, every line dated. Frames are recorded by a mixin on the connected device's class (FrameTap), not by changing how the library sends or receives.
  • --log-level sets SolixBLE's log level and --bleak-log-level bleak's (default warning), so the library's debug output doesn't come with bleak's record per advertisement.
  • Two log fixes in device.py, seen in console captures: a requested disconnect() was logged as "came from other client" (the client is disposed of before bleak reports the drop; it now logs "Disconnected from ''."), and the init line read "withaddress". The disconnect callback is annotated with the BleakClient bleak passes it, which clears two mypy errors.
  • console opens a Python prompt on the same event loop with the devices in scope and top-level await (stdlib codeop, as python -m asyncio does).
  • token / --token sets the client token used in 4027 and as the Prime 420a owner; region / --region calls set_region. Channel 0c (the factory lane) is refused unless started with --allow-factory-channel.

prompt-toolkit is an optional extra ([cli]), so installing the library alone pulls in nothing new; it is in requirements_dev.txt for the tests. Docs: new cli.rst.

Suite: 481 passed (base: 459). mypy --strict: 201 errors (base: 203).

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

kb1ibt commented Oct 6, 2026 •

Copy link
Copy Markdown
Author

This PR, while based on #80, ports a tool I already used as part of my data collection stack. It acts very similarly to a network switch's serial console or SSH terminal and has an "advanced" mode, console, to drop into a Python REPL with autocomplete for most of the SolixBLE library.

kb1ibt and others added 8 commits October 6, 2026 03:04
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>
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>
scan lists advertising Anker devices with their advertisement record and the factory's class; connect, devices, use and disconnect manage the links; info, data, props, constants and call read and drive a device through its class; send, packet and nego send parameter dicts (or PARAMETERS_* names) through _send_command, _send_packet and the negotiation pattern, and print the frames that follow.

Every frame, both directions, is recorded as cleartext with its status and tags by a mixin on the connected device's class (FrameTap); frames shows them and capture appends them to a file, never truncated. console opens a Python prompt on the same event loop with top-level await. token and region set the 4027 / 420a client token and the Prime region; channel 0c is refused unless started with --allow-factory-channel.

prompt-toolkit is an optional extra (SolixBLE[cli]); the console runs as solixble or python -m SolixBLE.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
scan [secs] raw adds each scanned device's advertisement as bytes after the table: the Anker 0xffff record, marked when it doesn't parse, any other manufacturer data, service data and the advertised services, so a model whose record layout isn't known yet can still be read.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A capture now holds the session around the frames: each command and its output (frame lines only once), the Python console's input, and log records with their tracebacks, every line dated. Starting a capture says the file holds decrypted frames, the client token and serials in clear.

--log-level sets SolixBLE's level and --bleak-log-level bleak's and bleak-retry-connector's (default warning), so library debug output no longer comes with a record per advertisement. info shows method and status bytes in hex with one value column, and the tapped device class keeps its model's name in the library's log lines.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
disconnect() disposes of the client before bleak reports the drop, so the callback took the requested disconnect for another client's. The device now remembers the client it disposed of and logs "Disconnected from '<name>'." for it. The disconnect callback is annotated with the BleakClient bleak passes it, and the init debug line reads "with address".

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