- envelope: 7 fixed keys + conditional dataArea, no data key; int
type/snno, seconds timestamp, data setter auto len/crc (CRC32 upper);
SequenceNumber 1..9999 (increaseSN)
- crypto: encrypt is plaintext-header-first a||transform(b), key radix 32
at both tryParse sites; decrypt_data ports decryptData (dual-0x58
markers, s+'}' equipNo probe, s+','+T return); decrypt_request kept
for the device-side known-header split
- socket: raw send (no length prefix), accumulate-and-try receive
- battery: queryAPowerData shape (data={'update':2}), radix default 32,
--probe prints exact TX/RX bytes per variant; device_local.send_probe
- docs: RE_DART_FINDINGS ss3/5/8 + open items, BATTERY_POC host recipe
- tests: 19 new protocol tests, suite 44/44 green
3.6 KiB
Battery PoC — direct-connect charge state
Reads the aggregate battery percentage straight from the aGate over its own
SoftAP. No cloud, no MQTT, no Bluetooth. Recovered from the Dart AOT layer;
see RE_DART_FINDINGS.md §§3–5, 8, 10.
Requirements
- Host whose Wi-Fi adapter is associated to the device AP
(
AP_<sn[11:]>/<sn[8:]>,wifi.derive_hotspot_credentials). - L2 reachability to
10.100.1.1. In a container this means a bridgedmacvlan/ipvlanaddress on10.100.1.0/24ornetwork_mode: host(see repo-root.devcontainer/docker-compose.yml). A default Docker bridge (172.17.0.0/16) is NATed to a non-10.100.1.xsource and getsECONNREFUSED. - Python
requestsfor the HTTP helpers indevice_local.py.
What it does
- Probes TCP ports
9000, 18000in order (9000was open on the tested unit;0x4650 = 18000is the app constant but was closed there). - Sends
DEVICE_APOWER_INFO (0x19)withdata={"update": 2}(exactly what the app'squeryAPowerDatapasses asarguments), thenBMS (0x1D),EMS (0x1E): compact-JSONCommunicationObject(cmdType, equipNo, type, timeStamp, snno, len, crc[, dataArea], nodatakey) →encrypt_object(S1plaintext header first, tail(byte + abs_index + n) & 0xFFwithn = int(key, 32)) → sent raw over the socket (no length prefix —ClientSocket.sendforwards to_Socket.adddirectly). - Accumulates reply chunks and runs the app's
decryptDatalogic (second0x58marker →s + "}"equipNo probe → tail decode →s + "," + T), then extractscurrentBatterySoc→soc→avg(socArray).
Usage
# show derived hotspot creds only
python -m franklinwh_agate_client.battery --sn <20-char-SN> --show-hotspot
# print just the percentage
python -m franklinwh_agate_client.battery --sn <20-char-SN>
# full reply envelope
python -m franklinwh_agate_client.battery --sn <20-char-SN> --raw
# low-level probe: exact TX bytes + exact RX bytes per request variant
python -m franklinwh_agate_client.battery --sn <20-char-SN> --probe
Options: --host (default 10.100.1.1), --ports 9000,18000,
--key-radix {16,32} (default 32, per the radix: 32 literal at both
tryParse sites), --timeout.
Programmatic:
from franklinwh_agate_client import get_battery
out = get_battery("...", host="10.100.1.1")
print(out["value"], out["source"]) # e.g. 78.0 currentBatterySoc
Host probe recipe (run on the machine joined to the device AP)
The agent container cannot reach 10.100.1.x; run this on the host:
cd franklinwh-agate-client
python3 -m franklinwh_agate_client.battery \
--sn A03X2516013210060005 --host 10.100.1.1 --ports 9000,18000 --probe
Expected: secret key for ...: 1c (radix 32), then per-variant tx
(encrypted envelope, starts with the ASCII header
{"cmdType":25,"equipNo":"A03X2516013210060005",) and rx bytes.
A rejected frame is met with zero bytes + close; any non-empty rx is
the device answering — paste the rx hex back and the reply parses via
decrypt_data.
If every variant still returns zero bytes, capture one app-driven session
instead: install the Frida hook (blutter_out/blutter_frida.js pattern —
log Socket Manager send object: / receive object: ), tap Refresh in
the app while joined to the AP, and compare its TX bytes against ours.
Limits
- No live round-trip has succeeded yet from an isolated container netns
(all probes
ECONNREFUSED); first success needs a10.100.1.xsource. - BLE field order and
X-Device-*binding remain open (RE_DART_FINDINGS.mdOpen items).