Skip to content

feat(ble): capability-driven encrypted negotiation + client-token auth for hardened firmware (#22) - #49

Closed
kb1ibt wants to merge 5 commits into
flip-dots:mainfrom
kb1ibt:pr/negotiations
Closed

kb1ibt wants to merge 5 commits into
flip-dots:mainfrom
kb1ibt:pr/negotiations

Conversation

@kb1ibt

@kb1ibt kb1ibt commented Jul 21, 2026

Copy link
Copy Markdown

Split out of #45 per review — the shared connection layer.

Replaces the fixed-replay negotiation frames with live/dynamic frames — each carries the current timestamp (and the stage-5 confer the local timezone), which newer firmware requires and rejects a stale one — and adds the account owner_user_id binding that hardened Prime devices need before they arm telemetry (without it they ack 09 and withhold updates).

This is what lets the C1000 G2 / C2000 G2 and hardened Prime chargers complete negotiation and stream instead of being dropped mid-handshake — the disconnect reported in #22.

Stacked on #48 (reassembly). The c490 summary decode, Prime device support, and docs follow as separate PRs on top of this one. 77 tests pass.

Comment thread docs/source/owner_user_id.rst Outdated

Requirements:

- `anker-solix-api <https://pypi.org/project/anker-solix-api/>`_ (``pip install anker-solix-api``)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

anker-solix-api is not published on PyPI, and installing from the git repo (git+https://github.com/thomluther/anker-solix-api) with pip or uv doesn't seem to install all dependencies. It does look like there is some poetry support in the pyproject.toml though.

@pkolbus pkolbus left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kb1ibt, nice work!

@flip-dots this (PR stack) gets my 250W charging station working -- addressing the problem we tried to work through in #17. I'm guessing that it's this PR in particular, although the others probably help.

Some comments on the owner-id collection here.

I did take a pass at improving the script snippet in owner_user_id.rst, as https://gist.github.com/pkolbus/7cdb87b04d6673ae93218ac4cf90b818. uv provides a way to run a script in an isolated env with declared dependencies, and the Anker credentials and country ID are collected as terminal input. It's probably fine to have this collection remain separate and owner ID be a configuration item on the HA side, at least initially. anker_solix_api doesn't have any published packages on PyPI and keeping the script separate reduces the dependency burden and keeps the integration clearly cloud-free.

Comment thread docs/source/owner_user_id.rst Outdated
import asyncio

from aiohttp import ClientSession
from api.api import AnkerSolixApi # anker-solix-api

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
from api.api import AnkerSolixApi # anker-solix-api
from anker_solix_api.api import AnkerSolixApi

Comment thread docs/source/owner_user_id.rst Outdated

async def main() -> None:
async with ClientSession() as session:
api = AnkerSolixApi("EMAIL", "PASSWORD", "US", session)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kb1ibt added a commit to kb1ibt/SolixBLE that referenced this pull request Jul 26, 2026
…dance, link the page

Review feedback from @pkolbus on flip-dots#49; all three points verified before changing anything:

- anker-solix-api is NOT on PyPI (pypi.org/pypi/anker-solix-api returns 404), so both 'pip install anker-solix-api' and the PyPI link were wrong. It is a Poetry project and a plain pip install from git does not pull its dependencies, so document clone + poetry install, plus a uv inline-metadata script that resolves the declared deps into a throwaway env -- nothing lands in the system or HA Python. The uv approach and prompting for credentials are @pkolbus's suggestions.

- The import 'from api.api import AnkerSolixApi' does not resolve (the package is anker_solix_api), so the snippet failed on copy-paste. Now 'from anker_solix_api.api import AnkerSolixApi'.

- Added a Country code section: the third constructor argument selects the API server region, so a wrong value fails the login outright rather than returning a different result. Points at API_COUNTRIES in apitypes.py for the valid codes.

Also adds the page to the index toctree -- it was introduced here without one, so Sphinx warned 'document isn't included in any toctree' and the page was unreachable by navigation. Verified with the declared sphinx 8.2/rtd-theme 3.1.0: no warnings from this file, the page links from index, and the countryid anchor resolves.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@kb1ibt

kb1ibt commented Jul 26, 2026

Copy link
Copy Markdown
Author

Thanks — all three were right, and one was a real copy-paste breaker. Fixed in 8c9d82f:

  • PyPI: confirmed, pypi.org/pypi/anker-solix-api 404s, so both the pip install line and the link were wrong. Now documents clone + poetry install, plus your uv inline-metadata approach (with the credential prompts) as the least invasive way to run it once — nothing lands in system Python.
  • Import: from api.api import … doesn't resolve at all; the package is anker_solix_api. Applied your suggestion.
  • countryId: added a section noting it selects the API server region, so a wrong code fails the login outright rather than returning something different, pointing at API_COUNTRIES for valid values.

Also noticed while building the docs that the page wasn't in any toctree, so Sphinx warned and it was unreachable by navigation — added it in the same commit.

Agreed it should stay separate, and for a slightly broader reason than HA: SolixBLE gets consumed outside Home Assistant too. Mine is a standalone collector that ships both cloud-MQTT and local-BLE telemetry into InfluxDB line protocol — it takes owner_user_id from its own config file, fetching the account user_id from the Anker login once and writing it back to config, then running BLE-only in remote locations like Pennsic or Burning Man with no further cloud round-trip. That's really the point of keeping collection out of the library: you obtain the ID once where there's internet, and everything after that is offline. Leaving it as a caller-supplied config value works for any consumer, HA or not.

On #17 — glad the stack fixed it. For the record, the Unexpected end of packet had a specific cause: those payloads parse cleanly as TLVs and then hit exactly 16 trailing bytes, which is the AES-GCM authentication tag being decrypted with the body instead of stripped. The TLV walker reads the tag's first byte as a tag/length pair and pops an empty bytearray. Not corruption — a MAC-tag handling bug, which is why it looked like truncation.

kb1ibt and others added 3 commits September 8, 2026 02:44
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>
…ation

Reads the advertised capability byte to pick the negotiation path, and adds
the encrypted GCM (4xxx) handshake plus client-token authorization to the
base class, built to the A1783 comms-module firmware.

A device whose advert sets the ECDH capability bit (or a class that defaults
to it) negotiates under the static GCM key, echoes the device's own auth mode
in 4005, carries a signed int32 UTC offset in the 4022 confer, and authorizes
the link with a 4027 registration of a stable, generated client token. On
hardened firmware a fresh token is accepted after a physical button press,
whose grant arrives unsolicited on pattern 030101; an already-registered
token authorizes immediately. `negotiated` gates on that authorization for
the encrypted path.

Cleartext devices are unchanged (the branch is a no-op when the capability
bit is clear), and PrimeDevice keeps its own negotiation, so its captured
vectors and telemetry are untouched.

The static key, nonce and AAD are the values the module firmware derives from
two DROM constants at connect time; the client token replaces the account
owner-id binding, since the device stores it as an opaque enrollable handle
and needs no cloud account.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a usage page covering how the advertised capability byte selects the
cleartext versus encrypted handshake, and how to pair a client with a stable,
persisted token -- including the one-time physical button press that firmware
enforcing pairing requires on the first connection. Links it into the toctree.

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

kb1ibt commented Sep 9, 2026

Copy link
Copy Markdown
Author

Rebased onto main (post-#61 packet rewrite). Changes from the prior version: negotiation is now capability-driven — the advert's capability byte (0x04 ECDH bit) picks the encrypted 4xxx/GCM vs cleartext 0xxx/CBC path before connect; the account owner_user_id binding is replaced with a self-generated, persisted client token enrolled once by a physical button press (no cloud/account dependency); reworked onto the construct-based Packet/Parameters layer.

@kb1ibt kb1ibt changed the title feat(ble): dynamic-frame negotiation + owner_user_id for hardened firmware (#22) feat(ble): capability-driven encrypted negotiation + client-token auth for hardened firmware (#22) Sep 9, 2026
@kb1ibt

kb1ibt commented Sep 9, 2026

Copy link
Copy Markdown
Author

This is 1/2 of the work needed for #68

@kb1ibt

kb1ibt commented Sep 9, 2026

Copy link
Copy Markdown
Author

@flip-dots can you re-review this since it is part of the blocker for #66 / #68 and likely quite a few others.

kb1ibt and others added 2 commits September 9, 2026 12:27
Harvey noted device.py is already large/complicated. The _offset_seconds_west
helper (UTC offset as a signed int32 LE, seconds west, sent as a3 in the 4022
timezone confer) uses no instance state, so move it to SolixBLE/utilities.py
next to the existing get_posix_tz timezone helper and import it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Harvey asked for constants over the magic values in the negotiation. Extract the
two the firmware decode gives clear meaning: the client's MTU proposal
(a4 of 0003/0005 -- u16 LE 0x00f0 = 61440 = "no limit", device streams at
min(this, its ceiling)) and the encryptMethod it confirms (a5 of 0005 -- the
device selects ECDH on a5 & 0x44). The a3 = 0x20 field stays inline: the comms-
module firmware logs and ignores it, so a name would imply meaning it does not have.

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

kb1ibt commented Sep 20, 2026

Copy link
Copy Markdown
Author

Rebuilt from main on your review and split into two focused PRs: #70 (negotiation path, chosen from the advertised capability byte) and #71 (client-token pairing with button-press confirmation). Closing this in favour of those.

@kb1ibt kb1ibt closed this Sep 20, 2026
@kb1ibt
kb1ibt deleted the pr/negotiations branch September 20, 2026 19:07
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