# 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_` / ``, `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 ``` Business operations (one call per question, on a bound client): ```python from franklinwh_agate_client.device_local import LocalDeviceClient client = LocalDeviceClient("A03X2516013210060005", host="10.100.1.1", socket_port=9000) client.get_battery_soc() # charge % (apower, then BMS packs 1-2) client.get_household_wattage() # watts (detail, then load, then EMS math) client.get_device_inventory() # IBG/FHP/BMS serials + versions ``` `get_*` returns `{"value", "source", "detail", "reply", ...}` with `value: None` when nothing answered; transport errors propagate so multi-port callers (`get_battery`, `get_power`) can move on. ## 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/` — thin wrappers over the > library business API (run from the repo root, e.g. > `python3 scripts/host_probe7.py --sn …`). Wire bytes always come from > `EnvelopeCodec` (`protocol/messages.py`); scripts never reimplement the > cipher. Current: `host_probe6.py` (variants), `host_probe7.py` (BMS > packs), `host_probe8.py` (wattage). 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).