From 213a0518d65229437799a2b59f0f0d720842614a Mon Sep 17 00:00:00 2001 From: kfj001 Date: Fri, 16 Oct 2026 00:14:42 +0000 Subject: [PATCH] Socket-transport cmd space (off_14 ids) from live capture: 1101/1307/1705/1707 + host_probe3 --- docs/RE_DART_FINDINGS.md | 50 +++++-- franklinwh_agate_client/protocol/__init__.py | 14 +- franklinwh_agate_client/protocol/commands.py | 70 +++++++++ franklinwh_agate_client/protocol/envelope.py | 12 +- franklinwh_agate_client/protocol/framing.py | 147 ++++++++++++++----- scripts/host_probe3.py | 144 ++++++++++++++++++ tests/test_protocol.py | 24 ++- 7 files changed, 401 insertions(+), 60 deletions(-) create mode 100644 scripts/host_probe3.py diff --git a/docs/RE_DART_FINDINGS.md b/docs/RE_DART_FINDINGS.md index 18c3a49..21a9976 100644 --- a/docs/RE_DART_FINDINGS.md +++ b/docs/RE_DART_FINDINGS.md @@ -110,6 +110,8 @@ Null/empty payload ⇒ `len == 0`, `crc == ""`, no `dataArea` key. ## 4. `CommunicationCmd` (raw → name) +`off_8` values (the enum's first id field): + ``` 0x00 queryDeviceInfo 0x13 pairPower 0x26 smartLoad2 0x01 networkInfo 0x14 processPairPower 0x27 rebootDevice @@ -132,6 +134,17 @@ Null/empty payload ⇒ `len == 0`, `crc == ""`, no `dataArea` key. ``` (missing: 0x09, 0x1b, 0x2c, 0x31 — not surfaced as instances.) +**Two id spaces.** `CommunicationCmd::cmd(cmd, mode)` (0xc92894) returns the +*second* id field (`off_14` in the pool) for `socket`/`bluetooth`/`local` +modes and throws `"do not support http communication"` for `http`. The +socket-transport ids live in `SOCKET_CMD_TYPE` +(`protocol/commands.py`) — e.g. `queryDeviceInfo → 1101 (0x44D)`, +`deviceApowerInfo → 1307 (0x51B)`, `bms → 1705`, `ems → 1707`. The `off_8` +values above are **not** used on the socket/BLE wire. Verified live: a +PCAPdroid capture of the working app shows +`{"cmdType":1101,"equipNo":"00000000",…}` with the S1-plaintext-first +framing (§5). + ## 5. Encryption — `communication_center/core/encryption.dart` **`generateSecretKey(String equipNo)`** = hex of `(sum(utf8(equipNo)) & 0xFF)` (via @@ -167,6 +180,15 @@ So requests are `S1 || transform(S2)` (split at the computable header length) while responses are `s || 0x58 || transform(tail)` (split at the second `0x58` marker). Both halves share the one additive stream. +Live corroboration (2026-10-15, PCAPdroid capture of the working app): a +request frame opens with the ASCII header +`{"cmdType":1101,"equipNo":"00000000",` — plaintext `S1` first, exactly as +derived, with the socket-space id (§4). A second frame shows +`{"cmdType":1102,"equipNo":"1006005A03X25160132",` (larger payload, +direction under analysis — see §8). Note the captures were exported as +text, so non-UTF-8 bytes were replaced with U+FFFD and only the ASCII +prefixes survive; a binary `.pcap` export is still wanted. + ## 6. Checksums & byte order - **CRC16** (`utils/crc16.dart`): init `0xFFFF`, poly `0xA001` (reflected), i.e. @@ -237,22 +259,26 @@ authS2C=2 "fwh/s2c/auth/" mngC2S=5 "fwh/c2s/mng/" deviceS2C "fwh/s2c/ ### Empirical — live aGate on its SoftAP (2026-10-13, from a client on `10.100.1.0/24`) -Full TCP sweep of `10.100.1.1` (ports 1–65535): exactly **two** ports open — **53** and **9000**. No 80/443, no web UI, and **18000 (`0x4650`) is closed** on this unit. +Full TCP sweep of `10.100.1.1` (ports 1–65535): exactly **two** ports open — **53** and **9000**. No 80/443, no web UI, and **18000 (`0x4650`) is closed** on this unit. No `9000` reference exists anywhere in the app (Dart or Java) — but see the 2026-10-15 update below. - **Port 53** — authoritative, *closed* DNS: well-formed `NXDOMAIN` for everything (even `example.com`), no PTR for `1.1.0.100.in-addr.arpa`, no forwarding (the SoftAP is an island). Serves some local name pattern we haven't identified yet. -- **Port 9000** — a **length-prefixed framing service** (int16LE length), consistent - with the app's socket layer: - - every TLS `ClientHello` gets `UNEXPECTED_EOF` mid-handshake — first two bytes - `16 03` = int16LE `790`, so the decoder waits for a 790-byte frame that never - completes, then closes on idle; - - cleartext HTTP `GET` (`47 45` = `17735`) hangs forever (read timeout). - - Earlier probes are superseded: they used an int16LE length prefix (the - socket layer sends **raw**), the old `transform(b) || a` order (correct - is `a || transform(b)` — §5), a truncated `equipNo`, and default radix - 16. All are corrected in the current PoC; see `BATTERY_POC.md` for the - host probe recipe with the true SN. +- **Port 9000** — accepts TCP, reads one frame, answers zero bytes, closes — + for *every* probe (TLS ClientHello, HTTP GET, MQTT CONNECT, exact-spec + envelopes raw/split/length-prefixed, greeting-first reads). + +### Update (2026-10-15) — socket cmd space + live capture + +All pre-2026-10-15 probes used `off_8` ids (`25/0/29/30`) and were dropped +silently — the socket transport speaks the `off_14` id space (§4), so the +device saw unknown commands. A PCAPdroid capture of the working app shows +real socket frames: `{"cmdType":1101,…}` (queryDeviceInfo, empty SN) and +`{"cmdType":1102,"equipNo":"1006005A03X25160132",…}` (922 B). Open: +dst IP:port + direction per frame (need capture metadata / full `.pcap`), +the meaning of `1102` (no pool cmd maps to `0x44E`), and the 17-char +`equipNo` (sub-device? battery unit? reply convention?). Current probes +use socket-space ids (`1101/1307/1705/1707`) via `scripts/host_probe3.py`. ## 9. Bluetooth (BLE) transport — `communication_center/bluetooth` diff --git a/franklinwh_agate_client/protocol/__init__.py b/franklinwh_agate_client/protocol/__init__.py index b02dd1c..a10eefc 100644 --- a/franklinwh_agate_client/protocol/__init__.py +++ b/franklinwh_agate_client/protocol/__init__.py @@ -4,7 +4,13 @@ Everything here is hardware-independent and covered by unit tests. See ``docs/RE_DART_FINDINGS.md`` for the reverse-engineering evidence. """ -from .commands import CommunicationCmd, CommunicationMode, CommunicationOperate +from .commands import ( + CommunicationCmd, + CommunicationMode, + CommunicationOperate, + SOCKET_CMD_TYPE, + socket_cmd_type, +) from .crc import ( CRC16_INIT, CRC16_POLY, @@ -35,8 +41,12 @@ from .envelope import ( direct_connect_request, ) from .framing import ( + BLE_HEADER_LEN, MAGIC_ACK, MAGIC_DATA, + MAGIC_MTU, + ble_frame_sn, + ble_frame_total_len, build_ble_ack_frame, build_ble_data_frame, chunk_for_mtu, @@ -54,6 +64,8 @@ __all__ = [ "CommunicationCmd", "CommunicationMode", "CommunicationOperate", + "SOCKET_CMD_TYPE", + "socket_cmd_type", "CRC16_INIT", "CRC16_POLY", "crc16_modbus", diff --git a/franklinwh_agate_client/protocol/commands.py b/franklinwh_agate_client/protocol/commands.py index 22c1e80..24a8376 100644 --- a/franklinwh_agate_client/protocol/commands.py +++ b/franklinwh_agate_client/protocol/commands.py @@ -84,3 +84,73 @@ class CommunicationCmd(IntEnum): BREAKER_COM_STATE = 0x32 BREAKER_LOAD_STATUS = 0x33 BREAKER_IDENTIFY_ME = 0x34 + + +#: ``cmdType`` values on the socket/Bluetooth/local transports. +#: +#: ``CommunicationCmd::cmd(cmd, mode)`` returns the *second* id field +#: (``off_14`` in the pool) for ``socket``/``bluetooth``/``local`` modes and +#: throws for ``http`` — verified live: a PCAPdroid capture of the app shows +#: ``{"cmdType":1101, ...}`` (``QUERY_DEVICE_INFO`` here, ``off_8`` ``0x00``) +#: with the S1-plaintext-first framing (see ``docs/RE_DART_FINDINGS.md`` +#: §5). The ``off_8`` values above are *not* used on the wire for these +#: transports. Entries mapped to ``None`` have no pool id (``0xFFFF…``). +SOCKET_CMD_TYPE = { + CommunicationCmd.QUERY_DEVICE_INFO: 0x44D, + CommunicationCmd.NETWORK_INFO: 0x45D, + CommunicationCmd.RESOURCE_ADDRESS: 0x4B5, + CommunicationCmd.SUNSPEC_MODBUS: 0x4B5, + CommunicationCmd.NETWORK_SWITCH: 0x45F, + CommunicationCmd.FEEDING_GRID_POWER: 0x6A5, + CommunicationCmd.MODE: 0x57B, + CommunicationCmd.MODE_V2: 0x6BD, + CommunicationCmd.MODE_SETTING_V2: 0x6BF, + CommunicationCmd.SOLAR_INFO: 0x76F, + CommunicationCmd.ENERGY_MANAGE_INFO: 0x4B3, + CommunicationCmd.GENERATOR_INFO: 0x76D, + CommunicationCmd.BLUETOOTH_HEARTBEAT: 0x7D7, + CommunicationCmd.SCAN_WIFI_LIST: 0x455, + CommunicationCmd.CONNECT_WIFI: 0x457, + CommunicationCmd.CONNECTION_STATUS: 0x459, + CommunicationCmd.NETWORK_CHANNEL_STATUS: 0x46D, + CommunicationCmd.NETWORK_CHANNEL: 0x46B, + CommunicationCmd.PAIR_POWER: 0x44F, + CommunicationCmd.PROCESS_PAIR_POWER: 0x451, + CommunicationCmd.CHECK_BLUETOOTH_PASSWORD: 0x7D5, + CommunicationCmd.GO_OFF_GRID_STATUS: 0x6BB, + CommunicationCmd.A_POWER_LED_SWITCH: None, + CommunicationCmd.DEVICE_DETAIL_INFO: 0x515, + CommunicationCmd.DEVICE_APOWER_INFO: 0x51B, + CommunicationCmd.DEVICE_ALARMS: 0x725, + CommunicationCmd.PE: 0x6A7, + CommunicationCmd.BMS: 0x6A9, + CommunicationCmd.EMS: 0x6AB, + CommunicationCmd.MPPT: 0x6B5, + CommunicationCmd.A_HUB: 0x771, + CommunicationCmd.CT_TEST_REVERSE: None, + CommunicationCmd.DEVICE_DATA: None, + CommunicationCmd.LOAD_STATUS: 0x583, + CommunicationCmd.LOAD_STATUS_2: 0x587, + CommunicationCmd.SMART_LOAD: 0x581, + CommunicationCmd.SMART_LOAD_2: 0x585, + CommunicationCmd.REBOOT_DEVICE: 0x6B9, + CommunicationCmd.DEBUG_MODE: 0x71D, + CommunicationCmd.POWER_ON: 0x71F, + CommunicationCmd.CHARGE_DEVICE: 0x721, + CommunicationCmd.CLEAR_FAULT: 0x6AD, + CommunicationCmd.DEVICE_ADDRESS_INFO: 0x4B1, + CommunicationCmd.OVERLOAD_CUT_OFF: 0x6A5, + CommunicationCmd.WIFI_FREQUECY: 0x46F, + CommunicationCmd.BREAKER_COMMISSION_NOTIFY: None, + CommunicationCmd.BREAKER_COM_STATE: None, + CommunicationCmd.BREAKER_LOAD_STATUS: 0x471, + CommunicationCmd.BREAKER_IDENTIFY_ME: 0x473, +} + + +def socket_cmd_type(cmd: CommunicationCmd) -> int: + """Wire ``cmdType`` for *cmd* on the socket transport (``cmd()``).""" + value = SOCKET_CMD_TYPE[cmd] + if value is None: + raise KeyError(f"{cmd} has no socket-transport command id") + return int(value) diff --git a/franklinwh_agate_client/protocol/envelope.py b/franklinwh_agate_client/protocol/envelope.py index 4caa6b6..f6728cd 100644 --- a/franklinwh_agate_client/protocol/envelope.py +++ b/franklinwh_agate_client/protocol/envelope.py @@ -25,7 +25,7 @@ import json import time from typing import Any, Dict, Optional -from .commands import CommunicationCmd, CommunicationOperate +from .commands import CommunicationCmd, CommunicationOperate, socket_cmd_type from .crc import crc32 #: Exact key order used by the app's ``toJson`` (``dataArea`` conditional). @@ -180,12 +180,14 @@ def direct_connect_request( Mirrors the Dart ``CommunicationObject.communicate`` factory + ``CommunicationService._query*ByDirectConnect`` family - (``docs/RE_DART_FINDINGS.md`` §3). Note ``snno`` is assigned by - ``sendObject`` (see :class:`SequenceNumber`); pass the issued value when - re-serialising. + (``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. """ return CommunicationObject( - cmd_type=int(cmd), + cmd_type=socket_cmd_type(cmd), equip_no=equip_no, op_type=CommunicationOperate.QUERY, snno=snno, diff --git a/franklinwh_agate_client/protocol/framing.py b/franklinwh_agate_client/protocol/framing.py index 01df20e..9c488d0 100644 --- a/franklinwh_agate_client/protocol/framing.py +++ b/franklinwh_agate_client/protocol/framing.py @@ -1,12 +1,23 @@ """Frame builders for the two byte transports. * **BLE** — ``communication_center/bluetooth/bluetooth_frame.dart`` (see - ``docs/RE_DART_FINDINGS.md`` §9). Frame layout and magics are confirmed; - the *exact* on-air order of the magic relative to the frame and the MTU - continuation scheme still need a live capture. -* **Local TCP socket** — ``communication_center/socket`` uses ``int16ToBytes`` - / ``bytesToInt16`` (little-endian) framing (see §8). A length-prefixed - frame is the working candidate; adjust once a capture is available. + ``docs/RE_DART_FINDINGS.md`` §9). On-air layout, settled from + ``createBluetoothFrame``:: + + magic[2] ++ crc[2] ++ int16LE(totalLen) ++ int16LE(sn) ++ content + + * ``totalLen = 6 + len(content)`` (2 crc-slot + 2 len + 2 sn bytes). + * CRC-16/MODBUS over the buffer with ``00 00`` in the crc slots, rendered + as the app's swapped-lowercase 4-hex string; each hex *pair* is then run + through ``int.parse(pair, radix: 32)`` (literal ``0x20`` — quirky but + explicit) and written into slots 2..3. + * Magics: ``5A A5`` data (default), ``4A A4`` ack, ``2A A2`` MTU announce. + * The encrypted envelope (same bytes as the socket path) is split into + MTU-sized slices when longer than the negotiated MTU; every slice goes + out as its own frame with its own ``_increaseBluetoothSN()`` value. + The receiver dedups by sn, accumulates ``content`` parts and runs the + same ``decryptData`` over the concatenation. +* **Local TCP socket** — raw encrypted bytes, no framing (see §8). """ from __future__ import annotations @@ -20,12 +31,79 @@ from .crc import crc16_hex_string, crc16_modbus, int16_to_le_bytes, le_bytes_to_ MAGIC_ACK = b"\x4a\xa4" #: First two bytes of a **data** frame (receiving one triggers the app's ack). MAGIC_DATA = b"\x5a\xa5" +#: Magic of the MTU-announce frame (``_sendMtu`` sends ``mtuToData()``). +MAGIC_MTU = b"\x2a\xa2" MAGIC_NAMES = { MAGIC_ACK[0:2]: "ack", MAGIC_DATA[0:2]: "data", + MAGIC_MTU[0:2]: "mtu", } +#: On-air header length: magic(2) + crc(2) + totalLen(2) + sn(2). +BLE_HEADER_LEN = 8 + + +def _ble_crc_bytes(body_with_zero_slots: bytes) -> bytes: + """CRC slot bytes the way ``createBluetoothFrame`` derives them. + + CRC-16/MODBUS over the frame with ``00 00`` slots, swapped-lowercase + 4-hex string, then ``int.parse(pair, radix: 32)`` per pair (the + disassembly materialises ``0x20`` for the radix). Values can exceed + one byte in theory; the GATT write path must narrow them, so mask + here (assumption — confirm against a capture). + """ + hexstr = crc16_hex_string(body_with_zero_slots) + return bytes(int(hexstr[i:i + 2], 32) & 0xFF for i in (0, 2)) + + +def create_ble_frame( + content: bytes, + sn: int, + magic: bytes = MAGIC_DATA, +) -> bytes: + """``createBluetoothFrame(content, magic, sn)`` — full on-air frame. + + Layout:: + + magic ++ crc[2] ++ int16LE(6 + len(content)) ++ int16LE(sn) ++ content + """ + total = 6 + len(content) + buf = bytearray( + bytes(magic[:2]) + + b"\x00\x00" + + int16_to_le_bytes(total) + + int16_to_le_bytes(sn) + + bytes(content) + ) + buf[2:4] = _ble_crc_bytes(bytes(buf)) + return bytes(buf) + + +def build_ble_data_frame(content: bytes, sn: int) -> bytes: + """Full on-air data frame (magic ``0x5A 0xA5``).""" + return create_ble_frame(content, sn, MAGIC_DATA) + + +def build_ble_ack_frame(content: bytes, sn: int) -> bytes: + """Full on-air ACK frame (magic ``0x4A 0xA4``).""" + return create_ble_frame(content, sn, MAGIC_ACK) + + +def magic_in_data(data: bytes) -> Optional[str]: + """Return ``"ack"`` / ``"data"`` / ``"mtu"`` if known; else ``None``.""" + return MAGIC_NAMES.get(bytes(data[:2])) + + +def ble_frame_sn(data: bytes) -> int: + """``sn`` field of a frame (int16LE at offset 6).""" + return le_bytes_to_int16(data[6:8]) + + +def ble_frame_total_len(data: bytes) -> int: + """``totalLen`` field of a frame (int16LE at offset 4).""" + return le_bytes_to_int16(data[4:6]) + # In createBluetoothFrame the CRC placeholder sits at byte offsets 2..5 of the # frame body (content ++ [00 00 00 00] ++ int16LE(totalLen) ++ int16LE(value) ++ sn). _CRC_SLOTS = slice(2, 4) @@ -77,51 +155,40 @@ def magic_in_data(data: bytes) -> Optional[str]: return MAGIC_NAMES.get(bytes(data[:2])) -def sn_in_data(data: bytes, sn: str) -> bool: - """True if the frame (after any magic) ends with the device SN.""" - sn_bytes = sn.encode("utf-8") - return data.endswith(sn_bytes) +def sn_in_data(data: bytes, sn: int) -> bool: + """True if the frame's ``sn`` field equals *sn*.""" + if len(data) < BLE_HEADER_LEN: + return False + return ble_frame_sn(data) == int(sn) -def content_in_data(data: bytes, sn: str) -> Optional[bytes]: - """Extract the ``content`` bytes of a data/ack frame. - - Layout after the 2-byte magic is fixed-size except for ``content``, so:: - - content = frame[2 : len(frame) - len(sn) - FRAME_TAIL_SIZE] - """ - body = data[2:] if len(data) >= 2 else data - sn_bytes = sn.encode("utf-8") - if not body.endswith(sn_bytes): +def content_in_data(data: bytes) -> Optional[bytes]: + """Frame ``content`` (everything after the 8-byte header).""" + if len(data) < BLE_HEADER_LEN: return None - end = len(body) - len(sn_bytes) - _FRAME_TAIL_SIZE - if end < 2: # need at least the 4-byte crc slots - return None - return body[2:end] + return bytes(data[BLE_HEADER_LEN:]) def verify_frame_crc(data: bytes) -> bool: - """Recompute the CRC-16 over the frame body and compare slots 2..3.""" - body = data[2:] if len(data) >= 2 else data - if len(body) < 4: + """Recompute the CRC-16 over the frame (zeroed slots) and compare.""" + if len(data) < BLE_HEADER_LEN: return False - candidate = body[:] - candidate[2:4] = b"\x00\x00\x00\x00" - return bytes.fromhex(crc16_hex_string(bytes(candidate))) == body[2:4] + candidate = bytearray(data) + candidate[2:4] = b"\x00\x00" + return _ble_crc_bytes(bytes(candidate)) == bytes(data[2:4]) -def chunk_for_mtu(frame: bytes, mtu: int = 20) -> List[bytes]: - """Split a frame for GATT writes. +def chunk_for_mtu(encrypted: bytes, mtu: int = 20) -> List[bytes]: + """Split the encrypted envelope into MTU-sized slices. - First chunk carries the 2-byte magic; the rest are raw continuation - chunks. The app's ``mtuToData`` does something similar — the exact - continuation framing (length headers? re-magic?) needs confirmation. + Mirrors the ``sendObject`` over-length branch (``ceil(len/mtu)`` slices + via ``sublist``): every slice is sent as its own BLE frame with its own + ``_increaseBluetoothSN()`` value (see :func:`create_ble_frame`). """ - if mtu <= 2: - raise ValueError("mtu must exceed the 2-byte magic") - if len(frame) <= mtu: - return [frame] - return [frame[i:i + mtu] for i in range(0, len(frame), mtu)] + if mtu <= 0: + raise ValueError("mtu must be positive") + return [bytes(encrypted[i:i + mtu]) + for i in range(0, len(encrypted), mtu)] or [b""] def magicEquals(a: bytes, b: bytes) -> bool: diff --git a/scripts/host_probe3.py b/scripts/host_probe3.py new file mode 100644 index 0000000..2ffc791 --- /dev/null +++ b/scripts/host_probe3.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +"""Host probe v3 — socket-transport command ids (stdlib only). + +A PCAPdroid capture of the working app showed ``{"cmdType":1101, ...}`` on +the wire: ``CommunicationCmd::cmd`` returns the *second* id field for +socket mode (QUERY_DEVICE_INFO 1101, APOWER 1307, BMS 1705, EMS 1707). +Earlier probes used the small ids (25/0/29/30) and were dropped silently. + +Run on the SoftAP-joined host: + python3 host_probe3.py --sn A03X2516013210060005 --ports 9000 +""" + +import argparse +import json +import socket +import time +import zlib + +# Wire cmdType per CommunicationCmd::cmd (socket mode). +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, data=None, snno=None, ts=None, equip=None): + SNNO[0] = (SNNO[0] % 9999) + 1 + 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": 0, "crc": ""} + if data: + compact = json.dumps(data, separators=(",", ":")) + env["len"] = len(compact.encode()) + env["crc"] = format(zlib.crc32(compact.encode()), "X") + env["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()) + plain = bytes((t - k - pl - n) & 0xFF for k, t in enumerate(tail)) + return prefix + plain.decode() + except Exception: + return "" + + +def recv_all(sock, idle=6.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)[:2000]) + 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/1101+SN", QUERY_INFO, None, None), + ("queryInfo/1101+empty-SN", QUERY_INFO, None, ""), + ("apower/1307+update2", APOWER, {"update": 2}, None), + ("apower/1307+none", APOWER, None, None), + ("bms/1705", BMS, None, None), + ("ems/1707", 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 label.startswith("queryInfo/1101+SN") + else 0.0): + return + + +if __name__ == "__main__": + main() diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 7cf2a16..e4ca669 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -13,6 +13,7 @@ from franklinwh_agate_client.protocol import ( CommunicationCmd, CommunicationObject, SequenceNumber, + socket_cmd_type, SocketFrameDecoder, decrypt_data, decrypt_request, @@ -38,6 +39,25 @@ def _apower_json(snno=1, time_stamp=1792105196): return env.to_json_string() +class TestSocketCmdSpace(unittest.TestCase): + def test_socket_ids(self): + self.assertEqual(socket_cmd_type(CommunicationCmd.QUERY_DEVICE_INFO), 1101) + self.assertEqual(socket_cmd_type(CommunicationCmd.DEVICE_APOWER_INFO), 1307) + self.assertEqual(socket_cmd_type(CommunicationCmd.BMS), 1705) + self.assertEqual(socket_cmd_type(CommunicationCmd.EMS), 1707) + + def test_direct_request_uses_socket_id(self): + env = direct_connect_request(CommunicationCmd.DEVICE_APOWER_INFO, SN) + self.assertEqual(env.cmd_type, 1307) + self.assertTrue(env.to_json_string().startswith('{"cmdType":1307,')) + + def test_discovery_shape_like_capture(self): + env = direct_connect_request(CommunicationCmd.QUERY_DEVICE_INFO, "") + body = env.to_json_string() + self.assertTrue(body.startswith( + '{"cmdType":1101,"equipNo":"00000000",')) + + class TestSecretKey(unittest.TestCase): def test_real_sn_key(self): self.assertEqual(generate_secret_key(SN), "1c") @@ -126,7 +146,7 @@ class TestCipher(unittest.TestCase): def test_header_len_matches(self): a, _ = split_envelope(_apower_json()) - self.assertEqual(header_len_for(0x19, SN), len(a)) + self.assertEqual(header_len_for(1307, SN), len(a)) def test_stream_is_absolute_index_plus_n(self): a, b = split_envelope(_apower_json()) @@ -141,7 +161,7 @@ class TestCipher(unittest.TestCase): wire = encrypt_object(body, generate_secret_key(SN)) self.assertEqual( decrypt_request(wire, generate_secret_key(SN), - cmd_type=0x19, equip_no=SN), + cmd_type=1307, equip_no=SN), body, )