Files
franklinwh-agate-client/docs/BATTERY_POC.md
T
kfj001 9e69f07636 Correct wire format to disassembly: raw S1||crypt(S2), radix 32, int snno/seconds, decryptData response parser, --probe
- 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
2026-10-15 23:01:58 +00:00

90 lines
3.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 bridged
`macvlan`/`ipvlan` address on `10.100.1.0/24` or `network_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.x` source and gets
`ECONNREFUSED`.
- Python `requests` for the HTTP helpers in `device_local.py`.
## What it does
1. Probes TCP ports `9000, 18000` in order (`9000` was open on the tested
unit; `0x4650 = 18000` is the app constant but was closed there).
2. Sends `DEVICE_APOWER_INFO (0x19)` with `data={"update": 2}` (exactly what
the app's `queryAPowerData` passes as `arguments`), then `BMS (0x1D)`,
`EMS (0x1E)`: compact-JSON `CommunicationObject`
(`cmdType, equipNo, type, timeStamp, snno, len, crc[, dataArea]`, no
`data` key) → `encrypt_object` (`S1` plaintext header first, tail
`(byte + abs_index + n) & 0xFF` with `n = int(key, 32)`) → sent **raw**
over the socket (no length prefix — `ClientSocket.send` forwards to
`_Socket.add` directly).
3. Accumulates reply chunks and runs the app's `decryptData` logic (second
`0x58` marker → `s + "}"` equipNo probe → tail decode → `s + "," + T`),
then extracts `currentBatterySoc` → `soc` → `avg(socArray)`.
## Usage
```bash
# 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:
```python
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:
```bash
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 a `10.100.1.x` source.
- BLE field order and `X-Device-*` binding remain open
(`RE_DART_FINDINGS.md` Open items).