107 lines
4.4 KiB
Markdown
107 lines
4.4 KiB
Markdown
# 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
|
||
```
|
||
|
||
> Prefer the standalone scripts in `scripts/` — they need no install and
|
||
> track the latest corrections: `host_probe4.py` is current (socket-space
|
||
> ids + `opt`-seeded payloads). Copy the file content and run
|
||
> `python3 host_probe4.py --sn A03X2516013210060005 --ports 9000`.
|
||
|
||
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.
|
||
|
||
## Status (2026-10-16) — live round-trip works
|
||
|
||
Two-way comms verified against `10.100.1.1:9000` from a host on
|
||
`10.100.1.0/24`: discovery → `1102` device-info reply (versions, `FHP_SN`,
|
||
`BMS_SN`), per-pack BMS reads (`{"id": 1}` / `{"id": 2}` → live `batSoc`),
|
||
EMS telemetry. `get_battery()` runs that exact sequence (discovery,
|
||
apower, BMS packs 1–2, EMS) and returns the first SoC found; an all-zero
|
||
BMS slot is skipped, not reported as 0 %.
|
||
|
||
`DEVICE_APOWER_INFO` answers `{"result": 1, "reason": -1}` on the tested
|
||
unit — SoC comes from the BMS packs instead.
|
||
|
||
Remaining limits:
|
||
|
||
- Needs L2 reachability to `10.100.1.1` (host on the device AP or bridged
|
||
container); the app's own socket port `18000` is closed on this unit.
|
||
- BLE field order and `X-Device-*` binding remain open
|
||
(`RE_DART_FINDINGS.md` Open items).
|