Skip to content

Add AS220 (SOLIX S2000) device support - #65

Open
moorecp wants to merge 4 commits into
flip-dots:mainfrom
moorecp:add-as220-s2000
Open

Add AS220 (SOLIX S2000) device support#65
moorecp wants to merge 4 commits into
flip-dots:mainfrom
moorecp:add-as220-s2000

Conversation

@moorecp

@moorecp moorecp commented Aug 26, 2026

Copy link
Copy Markdown

Summary

Adds support for the SOLIX S2000 (model AS220) portable power station, a new-firmware device that was blocking local BLE use (see #63). Reverse-engineered end-to-end from live traffic and confirmed on real hardware.

The S2000 is a hybrid: it uses the PrimeDevice encrypted-negotiation + AES-GCM session layer (static key b8ff…, ECDH then key=secret[:16] / nonce=secret[16:28], AAD 3322…), combined with the Gen-2 C1000 telemetry framing (fragmented c900/c421 packets started by a 4100 subscribe). So AS220(PrimeDevice) reuses nearly all of the existing crypto and only adds the S2000-specific negotiation/auth payloads and telemetry parsing.

Local UUID binding, no account

Unlike the Prime chargers, the S2000 only streams telemetry to a client UUID bound to the device. Binding is fully local, no Anker account:

  • AS220.bind() runs the device-registration handshake (the 0x22/0x27/0x23 auth carrying the UUID plus device serial), then the user presses the unit's Power button once to confirm. This is a physical proximity check; the press opens a short pairing window.
  • After that, a normal connect() with the same client_uuid streams telemetry.
  • The device is single-owner, so binding a new UUID replaces the previous one, including the phone app's. If client_uuid is omitted, a random one is generated.

Sensors — mapped and confirmed live on hardware

Every field below was validated against a live S2000, and cross-checked against the unit's own display where possible:

  • Identity: serial number, model
  • Battery: percentage, health, and charging_status — battery power-flow (idle / discharging / charging, from a5[2]); confirmed across a full charge cycle including idle at 100% with the charger still attached
  • Power (watts): total power out, ac_power_in (real charge power — read 1129 W matching the unit's display, vs a ~55 W trickle at 100%, confirming it's a live measurement), ac_power_out, solar_power_in, and aggregate usb_power — the S2000 reports one total across all USB ports (A1 + C1 + C2), not per-port; verified by driving a USB-A and a USB-C load simultaneously
  • Port status: ac_input (wall charger connected, f0), ac_output, usb_output
  • time_remaininga6[7:9] in tenths of an hour; time-to-empty while discharging, time-to-full while charging. Tracked live against the unit's display through load and charge changes (16.7 h → 27.5 h → 0.1 h-to-full)
  • min / max battery limits

Deliberately excluded — voltage / VA / frequency. a3/a4 carry a static 1200 (= 120.0 V nominal) and 60 (= 60 Hz), but they never vary across any load or charge state and the unit exposes no live V/VA readout to reference against — so they are nominal constants, not measurements, and are left out rather than shipped as fake sensors.

Tests

tests/test_as220.py — 5 tests: telemetry parsing against a real captured c421 payload (serial, model, battery, health, temperature, AC/USB status + watts, charging status, time-remaining), the tenths-of-hour time_remaining conversion, serial extraction from the 4829 device-info response, defensive parsing of omitted TLVs, and UUID generation. Full suite passes (113 tests).

Companion integration

Home Assistant wiring is in flip-dots/HaSolixBLE#45. That PR's manifest requires SolixBLE>=3.10.0 (the release that will carry AS220), so the intended order is: merge this PR → cut a SolixBLE ≥3.10.0 release → merge HaSolixBLE#45. This PR leaves pyproject.toml at 3.9.0 for you to bump at release time.

Notes

Minor: the post-bind confirmation sometimes arrives on pattern 03010f/030111 cmd 4827, which the base _process_notification doesn't route to the negotiator (it logs "Unexpected packet type"). Harmless, the bind still completes; could be tidied by routing that pattern too.

🤖 Generated with Claude Code

https://claude.ai/code/session_011WbLpvDpPK2ykge7M5V5ph

The SOLIX S2000 (model AS220) is a new-firmware Anker power station that
combines the PrimeDevice encrypted-negotiation + AES-GCM session layer with
the Gen-2 C1000 telemetry framing (fragmented c900/c421 packets started by a
4100 subscribe). It was reverse-engineered end-to-end from live BLE traffic.

Unlike the other models the S2000 only streams telemetry to a client UUID that
has been bound to the device. Binding is fully local (no Anker account): the new
AS220.bind() runs the device-registration handshake and the user presses the
unit's Power button once to confirm. The device is single-owner, so a new bind
replaces the previous UUID.

Telemetry sensors confirmed on real hardware: serial, model, battery %,
temperature, total/AC power in/out, solar in, and battery limits. Additional
TLVs (per-port watts, etc.) can be mapped later.

- SolixBLE/devices/as220.py: AS220(PrimeDevice) with bind() + telemetry
- register AS220 in package __init__ files
- tests/test_as220.py: telemetry parse, serial extraction, defensive parsing,
  UUID generation (4 tests)
- README + docs (as220.rst, api toctree)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011WbLpvDpPK2ykge7M5V5ph
Mapped against a live S2000 by driving loads and watching TLV deltas:

- ac_power_in (a6[3:5]) + ac_input status (f0[1]): confirmed by
  unplugging/replugging the wall charger — a6[3:5] and f0[1] both drop
  to 0 on unplug while AC output keeps flowing from battery.
- usb_c1_power / usb_port_c1 (aa): confirmed by plugging a USB-C load;
  aa[2:] ramped 0->14W and aa[1] latched on.
- battery_health (a5[4:5]).
- ac_output / ac_power_out (a7): layout inherited from the C1000G2;
  the AC output was never toggled off during capture, noted in docstring.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011WbLpvDpPK2ykge7M5V5ph
@moorecp

moorecp commented Aug 26, 2026

Copy link
Copy Markdown
Author

Note: AC charge-in mapping has one unverified corner (full battery)

The AC fields were mapped live by driving loads and diffing telemetry. One caveat worth recording for whoever picks this up:

  • ac_power_in (a6[3:5]) and ac_power_out (a7[2:4]) read identical at rest because the unit's charge rate happened to match the AC load. They only separate when the wall charger is unpluggeda6[3:5] and the f0[1] charger-connected flag drop to 0 while a7[2:4] keeps flowing from battery. That unplug/replug test is what confirmed a6[3:5] = AC input.
  • Limitation: the capture was taken with the battery at 100%, so plugging the charger into a full pack produces no clean charging draw. This means a6[3:5] is confirmed to track the charger connection, but whether it reports true charge wattage vs. net AC-in (charge + pass-through) can't be distinguished from a full-battery capture. A future capture with a partially-drained pack, cycling the charger, would nail this down.
  • ac_output on/off status (a7[1:2]) was never toggled off during capture (loads stayed powered), so its off-state is inherited from the C1000G2 layout, not independently verified on the S2000. Noted in the docstring.

Live-mapped against the unit's own display:
- time_remaining (a6[7:9], tenths of an hour): tracked the estimate through
  load changes (167->16.7h at 104W, 275->27.5h at 56W).
- USB is a single AGGREGATE figure, not per-port: a USB-A load and a USB-C
  load summed into `aa` (the S2000 has A1+C1 sharing an 18W budget and a
  separate 100W C2, all reported as one total). Rename usb_c1_power->usb_power
  and usb_port_c1->usb_output to reflect that.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011WbLpvDpPK2ykge7M5V5ph
@moorecp

moorecp commented Aug 26, 2026

Copy link
Copy Markdown
Author

Update: AC charge-in mapping now confirmed against the unit's display

The full-battery caveat from my earlier note is resolved. Re-tested with the pack drawn down to ~96% and actively charging:

  • ac_power_in (a6[3:5]) read 1129 W, matching the unit's own display ("a little over 1100 W"). At 100% it only showed ~55 W (trickle), so this confirms a6[3:5] is a genuine live charge-power measurement, not a nominal/pass-through value.
  • time_remaining (a6[7:9]) flipped from 24.2 h (to-empty, discharging) to 0.1 h (to-full, ~topped off) the moment the charger was connected — confirming the dual meaning documented on the property (time-to-empty discharging / time-to-full charging).
  • ac_input status (f0[1]) latched to Input on connect.

All verified live in Home Assistant end-to-end. The one remaining unconfirmed field is AC-output voltage: a3/a4 carry a static 1200 (120.0 V nominal) and 60 (60 Hz), but they never vary across load/charge states and the unit exposes no live V/VA readout to reference against, so I've left voltage out rather than ship a nominal constant as a measurement.

a5[2] = battery flow state, confirmed live against the unit: 0 idle,
1 discharging, 2 charging. Observed 2 while charging (96->99%), 1 while
running a load off battery, and 0 once full with the charger still
attached. Reuses the existing ChargingStatus enum.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011WbLpvDpPK2ykge7M5V5ph
@flip-dots

Copy link
Copy Markdown
Owner

This is great, I was hoping someone would figure out the account binding stuff (I don’t have one of those models to test), there has been a decent amount of work (#50, #22, #36) relating to account binding of which this might be applicable to some of them.

There is currently a major rewrite underway of packet handling in #61 which I plan on merging soon, which will require this to be rebased, that and I will probably end up making some other large changes (my tests are a mess atm). In terms of when I expect this to be merged/released I plan on doing a beta release soon(ish) (v4.0.0-beta-1) and I would be happy to include this PR in that or any follow up beta releases. It might be a while before I am happy to do a full release (Lots of stuff has been changed and I have not done any long term reliability testing).

@flip-dots

Copy link
Copy Markdown
Owner

Potentially related: flip-dots/HaSolixBLE#3

@kb1ibt

kb1ibt commented Sep 8, 2026

Copy link
Copy Markdown

As I mentioned in the code review comment in #50, #50 (comment) it doesn't even need to be a UUID, in fact

The token content is not checked against the device — I enrolled one unit using the other unit's serial, then ABCDEFGHIJKLMNO. But it is not ignored either: each new token needs its own button press, including on an already-paired unit. So it is a real pairing model — one press per client, and enrolling one client does not open the device to the next.

Reproduced identically on both units (AQLQJB1G22202385, AQLQJB1G22202305), so this is model behaviour, not a per-unit quirk.

Inference: field 2's length selects the mechanism — 0x28 (40) = account binding, 0x10 (16) = local client token. That fits your point about still wanting cloud support for devices like the Solarbank: both live on the same opcode, and a device can support either.

@kb1ibt

kb1ibt commented Sep 9, 2026

Copy link
Copy Markdown

In #49, I made a significant enhancement which should help with this code as well: the switch to using the capability field, because the latest firmware coming out from Anker has started restricting which types of negotiation it supports, even attempting at step 0/1, and that PR's negotiation doesn't even hard-code a UUID; it can have a unique client token.

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.

3 participants