Add AS220 (SOLIX S2000) device support - #65
Conversation
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
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:
|
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
Update: AC charge-in mapping now confirmed against the unit's displayThe full-battery caveat from my earlier note is resolved. Re-tested with the pack drawn down to ~96% and actively charging:
All verified live in Home Assistant end-to-end. The one remaining unconfirmed field is AC-output voltage: |
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
|
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). |
|
Potentially related: flip-dots/HaSolixBLE#3 |
|
As I mentioned in the code review comment in #50, #50 (comment) it doesn't even need to be a UUID, in fact
|
|
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. |
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 thenkey=secret[:16]/nonce=secret[16:28], AAD3322…), combined with the Gen-2 C1000 telemetry framing (fragmentedc900/c421packets started by a4100subscribe). SoAS220(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 (the0x22/0x27/0x23auth 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.connect()with the sameclient_uuidstreams telemetry.client_uuidis 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:
charging_status— battery power-flow (idle / discharging / charging, froma5[2]); confirmed across a full charge cycle includingidleat 100% with the charger still attachedac_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 aggregateusb_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 simultaneouslyac_input(wall charger connected,f0),ac_output,usb_outputtime_remaining—a6[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)Deliberately excluded — voltage / VA / frequency.
a3/a4carry a static1200(= 120.0 V nominal) and60(= 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 capturedc421payload (serial, model, battery, health, temperature, AC/USB status + watts, charging status, time-remaining), the tenths-of-hourtime_remainingconversion, serial extraction from the4829device-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 aSolixBLE≥3.10.0 release → merge HaSolixBLE#45. This PR leavespyproject.tomlat3.9.0for you to bump at release time.Notes
Minor: the post-bind confirmation sometimes arrives on pattern
03010f/030111cmd4827, which the base_process_notificationdoesn'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