diff --git a/docs/BATTERY_POC.md b/docs/BATTERY_POC.md index 2e0d921..abb4b5d 100644 --- a/docs/BATTERY_POC.md +++ b/docs/BATTERY_POC.md @@ -86,9 +86,21 @@ 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 +## Status (2026-10-16) — live round-trip works -- No live round-trip has succeeded yet from an isolated container netns - (all probes `ECONNREFUSED`); first success needs a `10.100.1.x` source. +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). diff --git a/docs/RE_DART_FINDINGS.md b/docs/RE_DART_FINDINGS.md index 453ab66..21b32dc 100644 --- a/docs/RE_DART_FINDINGS.md +++ b/docs/RE_DART_FINDINGS.md @@ -494,23 +494,25 @@ keepAlive 10 s, LWT, QoS 0, event names), `MethodHandlerManager` handler list, Still open: -1. Exact BLE frame field order & the 2-byte magic semantics (confirm with `btsnoop`). -2. ~~Exact `encryptObject` radix + trailing-header handling~~ — **resolved - from disassembly (see §5): radix 32, plaintext header first - (`S1 || transform(S2)`), `decryptData` marker model. Remaining: first - confirmed live round-trip.** +1. Live verification of the BLE frame layout (settled from disassembly in + §9 — `magic ++ crc ++ totalLen ++ sn ++ content`, radix-32 CRC pairs — + but no live BLE exchange yet) and the GATT service/notify + characteristics (write char matches `0000f002-…`). +2. ~~Exact `encryptObject` radix + framing~~ — **resolved live (2026-10-16, + see §5 and `tests/fixtures/*.bin`): radix 16, plaintext header first + (`S1 || transform(S2)`) both directions.** (An earlier radix-32 reading + of a nearby `0x20` literal is superseded.) The `decryptData` + `0x58`-marker model is kept for the unverified Bluetooth path only. 3. ~~Local HTTP port + TLS behavior for `https://IP:PORT`~~ — **revised (2026-10-13, §8 empirical):** no HTTP/TLS port exists on the device at all (only 53 + 9000). Either local mode is socket-only on this firmware, or the HTTP service is state-dependent and wasn't running. Revisit if a `/terminal` response is ever captured from the SoftAP. -4. ~~Local socket port confirmation~~ — **partially resolved: `9000` open on - one unit** (§8 empirical); the `0x4650` constant is not listening there, - so the port may be firmware-/device-dependent. Remaining: first confirmed - RPC round-trip — the corrected request bytes (raw `S1 || transform(S2)`, - radix 32, int `snno`, seconds `timeStamp`, true SN - `A03X2516013210060005`) are ready via - `battery --probe`; needs a host on `10.100.1.0/24`. +4. ~~Local socket round-trip~~ — **done (2026-10-16): full two-way RPC on + `10.100.1.1:9000`** (discovery → `1102` info, per-pack BMS `batSoc`, + EMS telemetry). Remaining: the live endpoint's exact role for port 9000 + vs the app's `18000` constant (closed here), and the `1102 = req + 1` + reply-numbering rule across more commands. 5. Which class in `classes5.dex` sets the `X-Device-*` headers and their exact values (string-pool location confirmed; class binding in progress — §13). 6. (Tooling, optional) Enable REA's DEX tools: obtain a **jadx-headless-mcp 0.7.1** JAR diff --git a/franklinwh_agate_client/battery.py b/franklinwh_agate_client/battery.py index 5958ed0..0150fc5 100644 --- a/franklinwh_agate_client/battery.py +++ b/franklinwh_agate_client/battery.py @@ -125,6 +125,23 @@ def _soc_detail(data: Dict[str, Any]) -> Dict[str, Any]: return detail +def _is_empty_pack(reply: Dict[str, Any], result: Dict[str, Any]) -> bool: + """True when a BMS reply is the all-zero placeholder slot. + + A genuine 0 % pack still reports health/voltage (``batSoh``, + ``batTotalVolt``); the unaddressed ``id: 0`` slot reports literal + zeros with ``result == 0``. Returning ``0.0`` for that slot would + mask the real packs, so the caller keeps probing instead. + """ + if result.get("source") != "batSoc" or result.get("value") != 0: + return False + data = reply.get("dataArea", reply) + if not isinstance(data, dict): + return False + return (data.get("result") == 0 and data.get("batSoh") == 0 + and data.get("batTotalVolt") == 0) + + def get_battery( sn: str, host: str = "10.100.1.1", @@ -175,7 +192,8 @@ def get_battery( continue result = extract_soc(reply) result.update({"cmd": str(cmd), "port": port, "reply": reply}) - if result["value"] is not None: + if result["value"] is not None and not _is_empty_pack( + reply, result): return result last_err = ValueError(f"reply on port {port} cmd {cmd} has no SoC field") raise ConnectionError(f"no battery reply via {host}:{list(ports)}") from last_err @@ -279,7 +297,7 @@ def probe_transport( prefixes (LE16/BE16/LE32), an MQTT CONNECT, and a double-send over one persistent connection. Each entry carries exact ``tx``/``rx`` bytes. """ - from .protocol import decrypt_data + from .protocol import decrypt_wire results: List[Dict[str, Any]] = [] for port in ports: @@ -312,7 +330,10 @@ def probe_transport( except (ConnectionError, socket.error, TimeoutError) as exc: entry["error"] = f"{type(exc).__name__}: {exc}" if entry["rx"]: - decoded = decrypt_data(entry["rx"], radix=key_radix) + try: + decoded = decrypt_wire(entry["rx"], radix=key_radix) + except ValueError: + decoded = "" if decoded: try: entry["reply"] = json.loads(decoded) diff --git a/franklinwh_agate_client/device_local.py b/franklinwh_agate_client/device_local.py index 512fac6..99a5681 100644 --- a/franklinwh_agate_client/device_local.py +++ b/franklinwh_agate_client/device_local.py @@ -1,15 +1,20 @@ """Local (direct-connect) device client. Talks to the aGate **on its own network** after joining the device hotspot — -the ``mode == local`` path of the app (see ``docs/RE_DART_FINDINGS.md`` §8, -§11). Two channels: +the direct-connect path of the app (see ``docs/RE_DART_FINDINGS.md`` §8, +§11, §14). Two channels: * **HTTP** to ``https://IP:PORT/terminal/...`` — the ``/terminal/*`` local endpoint family (hotspot info, aGate info, overall info, MQTT relay, ...). - Scheme/port are *not* confirmed (Open item #3); defaults are candidates. -* **TCP socket** to ``10.100.1.1:18000`` (candidate port — Open item #4) - carrying the encrypted :class:`~franklinwh_agate_client.protocol.CommunicationObject` - envelope with little-endian int16 length framing. + Scheme/port are *not* confirmed live (Open item #3); defaults follow the + app's ``RemoteService`` shape. +* **TCP socket** to ``10.100.1.1:9000`` — verified live 2026-10-16. The + app dials the gateway IP port ``18000`` (``0x4650``), which is closed on + the tested unit; ``9000`` answers the same encrypted + :class:`~franklinwh_agate_client.protocol.CommunicationObject` envelope + sent **raw** (``dart:io _Socket.add``, no length prefix), with socket-id + ``cmdType`` values (``SOCKET_CMD_TYPE``) and ``opt``-seeded payloads. + Replies use the identical framing with id ``request + 1``. """ from __future__ import annotations @@ -27,8 +32,6 @@ from .protocol import ( CommunicationOperate, SequenceNumber, SocketFrameDecoder, - decrypt_data, - decrypt_request, decrypt_wire, direct_connect_request, encrypt_object, @@ -182,7 +185,8 @@ class LocalDeviceClient: ``increaseSN`` counter, serialised to compact JSON, ``encrypt_object``-ed, and sent **raw** (``_Socket.add``, no length prefix). The reply is accumulated chunk by chunk and run through - ``decrypt_data`` until it yields a JSON object. + ``decrypt_wire`` (2nd-comma split, equipNo-derived key) until it + yields a JSON object. """ if envelope is None: envelope = direct_connect_request( diff --git a/franklinwh_agate_client/protocol/framing.py b/franklinwh_agate_client/protocol/framing.py index f4a0910..aa6dcb2 100644 --- a/franklinwh_agate_client/protocol/framing.py +++ b/franklinwh_agate_client/protocol/framing.py @@ -212,10 +212,9 @@ def frame_socket_payload(payload: bytes) -> bytes: class SocketFrameDecoder: """Accumulating socket receiver (``SocketManager._onSocketReceiveData``). - Each incoming chunk is appended to a growable buffer and ``decrypt_data`` - is attempted over the whole buffer (inside try/catch in the app); a - non-empty decoded string that parses as JSON is one complete reply, at - which point the buffer is cleared. Use :meth:`feed` for raw chunks and + Each incoming chunk is appended to a growable buffer and ``decrypt_wire`` + is attempted over the whole buffer; a decoded string that parses as + JSON is one complete reply, at which point the buffer is cleared. Use :meth:`feed` for raw chunks and :meth:`feed_json` when you want parsed envelopes back. """ diff --git a/tests/test_battery.py b/tests/test_battery.py index 1206eed..c6b0184 100644 --- a/tests/test_battery.py +++ b/tests/test_battery.py @@ -59,3 +59,17 @@ class TestBmsSoc(unittest.TestCase): data={"id": 2}) self.assertEqual(env.to_dict()["dataArea"], {"opt": 0, "id": 2}) self.assertEqual(env.cmd_type, 1705) + + def test_empty_pack_slot_skipped(self): + from franklinwh_agate_client.battery import _is_empty_pack + reply = {"dataArea": {"opt": 0, "result": 0, "id": 0, "batSoc": 0, + "batSoh": 0, "batTotalVolt": 0}} + self.assertTrue(_is_empty_pack( + reply, {"value": 0.0, "source": "batSoc"})) + + def test_genuine_zero_pack_kept(self): + from franklinwh_agate_client.battery import _is_empty_pack + reply = {"dataArea": {"opt": 0, "result": 0, "id": 1, "batSoc": 0, + "batSoh": 96, "batTotalVolt": 512}} + self.assertFalse(_is_empty_pack( + reply, {"value": 0.0, "source": "batSoc"}))