From bdc98484a92afbee5b714eb6ce16d91974a64853 Mon Sep 17 00:00:00 2001 From: kfj001 Date: Fri, 16 Oct 2026 00:55:50 +0000 Subject: [PATCH] Seed socket payloads with opt (communicate factory) + host_probe4 --- docs/BATTERY_POC.md | 5 + docs/RE_DART_FINDINGS.md | 7 +- franklinwh_agate_client/protocol/envelope.py | 15 +- scripts/host_probe4.py | 147 +++++++++++++++++++ tests/test_protocol.py | 24 ++- 5 files changed, 180 insertions(+), 18 deletions(-) create mode 100644 scripts/host_probe4.py diff --git a/docs/BATTERY_POC.md b/docs/BATTERY_POC.md index 551f47c..2e0d921 100644 --- a/docs/BATTERY_POC.md +++ b/docs/BATTERY_POC.md @@ -69,6 +69,11 @@ 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. diff --git a/docs/RE_DART_FINDINGS.md b/docs/RE_DART_FINDINGS.md index 21a9976..e0a5bd5 100644 --- a/docs/RE_DART_FINDINGS.md +++ b/docs/RE_DART_FINDINGS.md @@ -106,7 +106,12 @@ Null/empty payload ⇒ `len == 0`, `crc == ""`, no `dataArea` key. - The `communicate` factory (`0xc9264c`) builds the `data` map from the caller's `arguments` (e.g. `queryAPowerData` passes `{"update": 2}` with cmd `deviceApowerInfo`/`0x19`, operate `query`) and calls the ctor with - a named `data:` argument. + a named `data:` argument. On socket mode it **always seeds the map with + `"opt"`** (the operation type — `0` for query) before merging arguments, + so the app's apower query sends + `dataArea = {"opt": 0, "update": 2}` and its device-info query sends + `{"opt": 0, "minProtocolVer": "V1.00.00"}` (empty/non-query args still + yield `{"opt": 0}`). This matches the live capture sizes (§8). ## 4. `CommunicationCmd` (raw → name) diff --git a/franklinwh_agate_client/protocol/envelope.py b/franklinwh_agate_client/protocol/envelope.py index f6728cd..fff4c26 100644 --- a/franklinwh_agate_client/protocol/envelope.py +++ b/franklinwh_agate_client/protocol/envelope.py @@ -182,14 +182,21 @@ def direct_connect_request( ``CommunicationService._query*ByDirectConnect`` family (``docs/RE_DART_FINDINGS.md`` §3). The wire ``cmdType`` is the *socket-transport* id (``CommunicationCmd::cmd`` for socket mode — - e.g. ``DEVICE_APOWER_INFO`` → ``1307``, verified live). Note ``snno`` - is assigned by ``sendObject`` (see :class:`SequenceNumber`); pass the - issued value when re-serialising. + e.g. ``DEVICE_APOWER_INFO`` → ``1307``, verified live). On socket + mode the factory always seeds the payload with ``"opt"`` (the + operation type, ``0`` for query) and merges the caller's arguments + after it — e.g. the app's ``queryAPowerData`` sends + ``dataArea = {"opt": 0, "update": 2}``. Note ``snno`` is assigned by + ``sendObject`` (see :class:`SequenceNumber`); pass the issued value + when re-serialising. """ + payload: Dict[str, Any] = {"opt": int(CommunicationOperate.QUERY)} + if data: + payload.update(data) return CommunicationObject( cmd_type=socket_cmd_type(cmd), equip_no=equip_no, op_type=CommunicationOperate.QUERY, snno=snno, - data=data, + data=payload, ) diff --git a/scripts/host_probe4.py b/scripts/host_probe4.py new file mode 100644 index 0000000..072a6ad --- /dev/null +++ b/scripts/host_probe4.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +"""Host probe v4 — socket ids + ``opt``-seeded payloads (stdlib only). + +Two corrections vs v3, both from disassembly + live capture: +* wire ``cmdType`` is the socket id (1101/1307/1705/1707); +* the ``communicate`` factory seeds every socket payload with + ``"opt": 0`` first (operation type), then merges arguments, e.g. + apower → ``{"opt": 0, "update": 2}``; queryDeviceInfo → + ``{"opt": 0, "minProtocolVer": "V1.00.00"}``. + +Run on the SoftAP-joined host: + python3 host_probe4.py --sn A03X2516013210060005 --ports 9000 +""" + +import argparse +import json +import socket +import time +import zlib + +APOWER, QUERY_INFO, BMS, EMS = 0x51B, 0x44D, 0x6A9, 0x6AB +SNNO = [0] + + +def secret_key(sn): + return format(sum(sn.encode()) & 0xFF, "x") + + +def build_envelope(sn, cmd, args=None, snno=None, ts=None, equip=None): + SNNO[0] = (SNNO[0] % 9999) + 1 + data = {"opt": 0} + if args: + data.update(args) + compact = json.dumps(data, separators=(",", ":")) + env = {"cmdType": cmd, "equipNo": equip if equip is not None else sn, + "type": 0, + "timeStamp": ts if ts is not None else int(time.time()), + "snno": snno if snno is not None else SNNO[0], + "len": len(compact.encode()), + "crc": format(zlib.crc32(compact.encode()), "X"), + "dataArea": data} + return json.dumps(env, separators=(",", ":")) + + +def encrypt(json_str, key): + parts = json_str.split(",") + a = (parts[0] + "," + parts[1] + ",").encode() + b = ",".join(parts[2:]).encode() + n = int(key, 32) + return a + bytes((b[i] + i + len(a) + n) & 0xFF + for i in range(len(b))) + + +def decrypt_data(wire, radix=32): + try: + i = wire.find(b"\x58") + if i < 0: + return "" + j = wire.find(b"\x58", i + 1) + if j < 0: + return "" + s = wire[:j].decode() + probe = json.loads(s + "}") + if not isinstance(probe, dict): + return "" + n = int(secret_key(str(probe.get("equipNo", ""))), radix) + prefix = s + "," + tail = wire[j + 1:] + pl = len(prefix.encode()) + return prefix + bytes((t - k - pl - n) & 0xFF + for k, t in enumerate(tail)).decode() + except Exception: + return "" + + +def recv_all(sock, idle=8.0): + out = bytearray() + sock.settimeout(idle) + while True: + try: + chunk = sock.recv(65536) + except (socket.timeout, TimeoutError): + break + if not chunk: + break + out.extend(chunk) + return bytes(out) + + +def run(label, host, port, tx, timeout, pre_read=0.0): + rx_pre, rx, err, reply = b"", b"", None, None + try: + with socket.create_connection((host, port), + timeout=timeout) as sock: + if pre_read: + rx_pre = recv_all(sock, idle=pre_read) + if tx: + sock.sendall(tx) + rx = recv_all(sock) + if rx: + dec = decrypt_data(rx) + reply = json.loads(dec) if dec else {"_raw": rx.hex()} + except (ConnectionError, socket.error, TimeoutError) as exc: + err = "%s: %s" % (type(exc).__name__, exc) + print("--- %s -> %s:%d" % (label, host, port)) + print(" tx (%d B): %s" % (len(tx), tx.hex() or "(none)")) + if rx_pre: + print(" rx_pre (%d B): %s" % (len(rx_pre), rx_pre.hex())) + print(" rx (%d B): %s" % (len(rx), rx.hex() or "(empty)")) + if reply is not None: + print(" reply: " + json.dumps(reply, default=str)[:3000]) + if err: + print(" error: %s" % err) + return rx + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--sn", required=True) + ap.add_argument("--host", default="10.100.1.1") + ap.add_argument("--ports", default="9000") + ap.add_argument("--timeout", type=float, default=5.0) + args = ap.parse_args() + key = secret_key(args.sn) + print("secret key for %s: %s (radix 32)" % (args.sn, key)) + variants = [ + ("queryInfo+minProto+SN", QUERY_INFO, {"minProtocolVer": "V1.00.00"}, + None), + ("queryInfo+minProto+empty", QUERY_INFO, {"minProtocolVer": "V1.00.00"}, + ""), + ("apower+opt+update2", APOWER, {"update": 2}, None), + ("bms+opt", BMS, None, None), + ("ems+opt", EMS, None, None), + ] + for port in [int(p) for p in args.ports.split(",") if p.strip()]: + for label, cmd, data, equip in variants: + tx = encrypt(build_envelope( + args.sn, cmd, data, + equip=args.sn if equip is None else equip or "00000000", + ), key) + if run(label, args.host, port, tx, args.timeout, + pre_read=4.0 if "empty" not in label else 0.0): + return + + +if __name__ == "__main__": + main() diff --git a/tests/test_protocol.py b/tests/test_protocol.py index e4ca669..cfb109c 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -81,30 +81,28 @@ class TestEnvelope(unittest.TestCase): "crc", "dataArea"], ) self.assertNotIn("data", d) + # socket payload is always seeded with the operation type first + self.assertEqual(d["dataArea"], {"opt": 0, "update": 2}) - def test_no_data_means_seven_keys(self): + def test_no_data_still_carries_opt(self): env = direct_connect_request(CommunicationCmd.BMS, SN, data=None) d = env.to_dict() - self.assertEqual( - list(d), - ["cmdType", "equipNo", "type", "timeStamp", "snno", "len", "crc"], - ) - self.assertEqual(env.length, 0) - self.assertEqual(env.crc, "") + self.assertIn("dataArea", d) + self.assertEqual(d["dataArea"], {"opt": 0}) + self.assertEqual(env.length, len(b'{"opt":0}')) + self.assertTrue(env.crc) - def test_empty_map_means_no_data_area(self): + def test_empty_map_still_carries_opt(self): env = direct_connect_request(CommunicationCmd.BMS, SN, data={}) - self.assertNotIn("dataArea", env.to_dict()) - self.assertEqual(env.length, 0) - self.assertEqual(env.crc, "") + self.assertEqual(env.to_dict()["dataArea"], {"opt": 0}) def test_len_crc_auto_uppercase(self): env = direct_connect_request( CommunicationCmd.DEVICE_APOWER_INFO, SN, data={"update": 2}) - self.assertEqual(env.length, len(b'{"update":2}')) + self.assertEqual(env.length, len(b'{"opt":0,"update":2}')) import zlib self.assertEqual( - env.crc, format(zlib.crc32(b'{"update":2}'), "X")) + env.crc, format(zlib.crc32(b'{"opt":0,"update":2}'), "X")) self.assertEqual(env.crc, env.crc.upper()) def test_int_fields(self):