Radix 16 (live-verified), universal reply splitter, golden capture vectors, host_probe6

This commit is contained in:
kfj001 committed 2026-10-16 02:50:23 +00:00
1 parent 0cd6b34a2e
commit 62e0fd649b
10 files changed
+415 -79

No files matched your search

+33 -12
View File
@@ -161,14 +161,20 @@ unpadded. E.g. SN `A03X2516013210060005` → byte sum `1052 & 0xFF = 28` → `"1
2. `S1 = parts[0] + "," + parts[1] + ","` (header, *with* trailing comma); 2. `S1 = parts[0] + "," + parts[1] + ","` (header, *with* trailing comma);
`a = UTF-8(S1)`. `a = UTF-8(S1)`.
3. `S2 = join(",", parts[2..])` (the rest); `b = UTF-8(S2)`. 3. `S2 = join(",", parts[2..])` (the rest); `b = UTF-8(S2)`.
4. `keyInt = int.tryParse(key, radix: 32)` (literal `0x20` at the call site; 4. `keyInt = int.tryParse(key)` (default radix **16** — two live frames
null → 0). decrypt cleanly only with radix 16 and then verify byte-exact on
`len`/CRC; an earlier radix-32 reading of a nearby `0x20` literal is
superseded).
5. `out[i] = (b[i] + i + len(a) + keyInt) & 0xFF` for all i (so each tail 5. `out[i] = (b[i] + i + len(a) + keyInt) & 0xFF` for all i (so each tail
byte is shifted by its *absolute* JSON index plus the key). byte is shifted by its *absolute* JSON index plus the key).
6. Result = `a ++ out` — the plaintext header goes on the wire **first**, 6. Result = `a ++ out` — the plaintext header goes on the wire **first**,
transformed tail after (`addAll` onto the header copy). transformed tail after (`addAll` onto the header copy).
**`decryptData(bytes)`** (single arg, the accumulated receive buffer): **`decryptData(bytes)`** (single arg, the accumulated receive buffer):
as recovered from the disassembly (two-`0x58`-marker scan, `s + "}"`
equipNo probe, `s + "," + T` return). NOTE (2026-10-16): live socket
replies decrypt with the *request-side* split instead (see below); the
marker model is kept for the unverified Bluetooth path.
1. `i = indexWhere(byte == 0x58)`, `j = indexWhere(byte == 0x58, start: i+1)`; 1. `i = indexWhere(byte == 0x58)`, `j = indexWhere(byte == 0x58, start: i+1)`;
either missing → return `""`. either missing → return `""`.
2. `s = utf8(wire[0..j])`; `probe = jsonDecode(s + "}")`, null → `""`. 2. `s = utf8(wire[0..j])`; `probe = jsonDecode(s + "}")`, null → `""`.
@@ -273,17 +279,32 @@ Full TCP sweep of `10.100.1.1` (ports 1–65535): exactly **two** ports open —
for *every* probe (TLS ClientHello, HTTP GET, MQTT CONNECT, exact-spec for *every* probe (TLS ClientHello, HTTP GET, MQTT CONNECT, exact-spec
envelopes raw/split/length-prefixed, greeting-first reads). envelopes raw/split/length-prefixed, greeting-first reads).
### Update (2026-10-15) — socket cmd space + live capture ### Update (2026-10-16) — binary capture fully decoded
All pre-2026-10-15 probes used `off_8` ids (`25/0/29/30`) and were dropped Raw `.bin` payloads (no text mangling) decrypt completely offline with
silently — the socket transport speaks the `off_14` id space (§4), so the radix 16 (`tests/fixtures/*.bin` are golden vectors):
device saw unknown commands. A PCAPdroid capture of the working app shows
real socket frames: `{"cmdType":1101,…}` (queryDeviceInfo, empty SN) and - Discovery request (153 B): `{"cmdType":1101,"equipNo":"00000000",…,
`{"cmdType":1102,"equipNo":"1006005A03X25160132",…}` (922 B). Open: "dataArea":{"opt":0,"minProtocolVer":"V1.00.00"}}`, key `"80"`/n=128.
dst IP:port + direction per frame (need capture metadata / full `.pcap`), - Device-info reply (1154 B): `cmdType` **1102**, `equipNo`
the meaning of `1102` (no pool cmd maps to `0x44E`), and the 17-char `10060005A03X25160132` (the unit's IBG SN — label SN
`equipNo` (sub-device? battery unit? reply convention?). Current probes `A03X2516013210060005` shares its trailing 12 chars), `len` 1024 exact,
use socket-space ids (`1101/1307/1705/1707`) via `scripts/host_probe3.py`. CRC exact, `dataArea.result == 0` with the full version inventory
(`FHP_NUM: 2`, two `FHP_SN`, two `BMS_SN`, `protocolVer V1.12.36`).
Encrypted with the **gateway** key (`"1c"`/n=28) while addressed to the
IBG SN — the gateway is the crypto endpoint; sub-unit addressing rides
inside.
Reply rules learned: same `S1 || transform(S2)` framing both directions;
reply id observed as request + 1 (`1101 → 1102`); universal split at the
2nd comma needs no ids up front (`decrypt_wire`). The remaining unknown
is the live endpoint (dst IP:port + direction per capture).
Superseded notes: pre-2026-10-16 probes additionally used the wrong key
radix (32 instead of 16) and mostly omitted the `opt`-seeded payload, so
every silent run is fully explained by bytes, with no need for exotic
causes. Current probes use socket-space ids + radix 16 + `opt` payloads
via `scripts/host_probe6.py`.
## 14. Direct-connect flow (manual AP join → return to app) ## 14. Direct-connect flow (manual AP join → return to app)
+16 -8
View File
@@ -125,16 +125,18 @@ def get_battery(
sn: str, sn: str,
host: str = "10.100.1.1", host: str = "10.100.1.1",
ports: Tuple[int, ...] = SOCKET_PORT_CANDIDATES, ports: Tuple[int, ...] = SOCKET_PORT_CANDIDATES,
key_radix: int = 32, key_radix: int = 16,
timeout: float = 5.0, timeout: float = 5.0,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Query the aggregate charge percentage over the local TCP socket. """Query the aggregate charge percentage over the local TCP socket.
Mirrors the app's ``queryAPowerData`` first (``DEVICE_APOWER_INFO`` with Mirrors the app's ``queryAPowerData`` first (``DEVICE_APOWER_INFO`` with
``data={"update": 2}``), then ``BMS`` and ``EMS`` as fallbacks, probing ``data={"opt": 0, "update": 2}``), then ``BMS`` and ``EMS`` as fallbacks,
each candidate port. Returns the ``extract_soc`` dict plus probing each candidate port. Like the app's direct-connect flow, a
``cmd``/``port``/``reply`` context. Raises the last socket error if no discovery query (``QUERY_DEVICE_INFO``, empty serial) opens each port's
port answers. sequence — its reply also confirms two-way crypto before the battery
read. Returns the ``extract_soc`` dict plus ``cmd``/``port``/``reply``
context. Raises the last socket error if no port answers.
""" """
last_err: Optional[Exception] = None last_err: Optional[Exception] = None
for port in ports: for port in ports:
@@ -142,6 +144,12 @@ def get_battery(
sn=sn, host=host, socket_port=port, sn=sn, host=host, socket_port=port,
key_radix=key_radix, timeout=timeout, key_radix=key_radix, timeout=timeout,
) )
try:
client.send_rpc(CommunicationCmd.QUERY_DEVICE_INFO,
equip_no="",
data={"minProtocolVer": "V1.00.00"})
except Exception as exc: # discovery is best-effort; keep going
log.debug("port %d discovery failed: %s", port, exc)
for cmd, data in ( for cmd, data in (
(CommunicationCmd.DEVICE_APOWER_INFO, {"update": 2}), (CommunicationCmd.DEVICE_APOWER_INFO, {"update": 2}),
(CommunicationCmd.BMS, None), (CommunicationCmd.BMS, None),
@@ -182,7 +190,7 @@ def probe_device(
sn: str, sn: str,
host: str = "10.100.1.1", host: str = "10.100.1.1",
ports: Tuple[int, ...] = SOCKET_PORT_CANDIDATES, ports: Tuple[int, ...] = SOCKET_PORT_CANDIDATES,
key_radix: int = 32, key_radix: int = 16,
timeout: float = 5.0, timeout: float = 5.0,
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
"""Send every :data:`PROBE_VARIANTS` envelope; return exact TX/RX bytes.""" """Send every :data:`PROBE_VARIANTS` envelope; return exact TX/RX bytes."""
@@ -251,7 +259,7 @@ def probe_transport(
sn: str, sn: str,
host: str = "10.100.1.1", host: str = "10.100.1.1",
ports: Tuple[int, ...] = (9000,), ports: Tuple[int, ...] = (9000,),
key_radix: int = 32, key_radix: int = 16,
timeout: float = 5.0, timeout: float = 5.0,
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
"""Transport-level probes against one service port. """Transport-level probes against one service port.
@@ -323,7 +331,7 @@ def main(argv: Optional[List[str]] = None) -> int:
"--ports", default=",".join(map(str, SOCKET_PORT_CANDIDATES)), "--ports", default=",".join(map(str, SOCKET_PORT_CANDIDATES)),
help="comma-separated socket ports to probe", help="comma-separated socket ports to probe",
) )
parser.add_argument("--key-radix", type=int, default=32, choices=(16, 32)) parser.add_argument("--key-radix", type=int, default=16, choices=(16, 32))
parser.add_argument("--timeout", type=float, default=5.0) parser.add_argument("--timeout", type=float, default=5.0)
parser.add_argument("--show-hotspot", action="store_true", parser.add_argument("--show-hotspot", action="store_true",
help="print derived AP SSID/password and exit") help="print derived AP SSID/password and exit")
+16 -9
View File
@@ -29,6 +29,7 @@ from .protocol import (
SocketFrameDecoder, SocketFrameDecoder,
decrypt_data, decrypt_data,
decrypt_request, decrypt_request,
decrypt_wire,
direct_connect_request, direct_connect_request,
encrypt_object, encrypt_object,
frame_socket_payload, frame_socket_payload,
@@ -64,8 +65,8 @@ class LocalDeviceClient:
:param socket_port: local TCP socket port (candidate 18000). :param socket_port: local TCP socket port (candidate 18000).
:param verify: TLS verification for the device API (device likely serves a :param verify: TLS verification for the device API (device likely serves a
self-signed cert, so ``False`` is the practical default). self-signed cert, so ``False`` is the practical default).
:param key_radix: key parse radix for the envelope cipher (32, per the :param key_radix: key parse radix for the envelope cipher (16 —
``int.tryParse(key, radix: 32)`` call in both directions). default ``int.tryParse`` radix, verified against live captures).
:param timeout: per-request timeout in seconds. :param timeout: per-request timeout in seconds.
""" """
@@ -77,7 +78,7 @@ class LocalDeviceClient:
scheme: str = "https", scheme: str = "https",
verify: Optional[bool] = False, verify: Optional[bool] = False,
socket_port: Optional[int] = None, socket_port: Optional[int] = None,
key_radix: int = 32, key_radix: int = 16,
timeout: float = 10.0, timeout: float = 10.0,
extra_headers: Optional[Dict[str, str]] = None, extra_headers: Optional[Dict[str, str]] = None,
session: Optional[requests.Session] = None, session: Optional[requests.Session] = None,
@@ -185,7 +186,7 @@ class LocalDeviceClient:
""" """
if envelope is None: if envelope is None:
envelope = direct_connect_request( envelope = direct_connect_request(
cmd, equip_no or self.sn, data=data, cmd, self.sn if equip_no is None else equip_no, data=data,
snno=self._sn_seq.next(), snno=self._sn_seq.next(),
) )
return self.send_envelope(envelope) return self.send_envelope(envelope)
@@ -194,8 +195,9 @@ class LocalDeviceClient:
wire = encrypt_object(envelope.to_json_string(), self.key, wire = encrypt_object(envelope.to_json_string(), self.key,
radix=self.key_radix) radix=self.key_radix)
raw = self._socket_roundtrip(frame_socket_payload(wire)) raw = self._socket_roundtrip(frame_socket_payload(wire))
reply = decrypt_data(raw, radix=self.key_radix) try:
if not reply: reply = decrypt_wire(raw, radix=self.key_radix)
except ValueError:
raise ConnectionError( raise ConnectionError(
f"undecodable reply ({len(raw)} bytes) from " f"undecodable reply ({len(raw)} bytes) from "
f"{self.socket_host}:{self.socket_port}" f"{self.socket_host}:{self.socket_port}"
@@ -224,7 +226,10 @@ class LocalDeviceClient:
) )
reply: Optional[Dict[str, Any]] = None reply: Optional[Dict[str, Any]] = None
if reply_raw: if reply_raw:
decoded = decrypt_data(reply_raw, radix=self.key_radix) try:
decoded = decrypt_wire(reply_raw, radix=self.key_radix)
except ValueError:
decoded = ""
if decoded: if decoded:
try: try:
reply = json.loads(decoded) reply = json.loads(decoded)
@@ -268,11 +273,13 @@ class LocalDeviceClient:
break break
if collect_only: if collect_only:
return bytes(received) return bytes(received)
if decrypt_data(bytes(received), radix=self.key_radix): try:
return bytes(received) decrypt_wire(bytes(received), radix=self.key_radix)
except ValueError:
raise ConnectionError( raise ConnectionError(
f"no reply frame from {self.socket_host}:{self.socket_port}" f"no reply frame from {self.socket_host}:{self.socket_port}"
) )
return bytes(received)
# ------------------------------------------------------- convenience RPCs # ------------------------------------------------------- convenience RPCs
def query_device_info(self) -> Dict[str, Any]: def query_device_info(self) -> Dict[str, Any]:
@@ -26,10 +26,12 @@ from .crypto import (
decrypt_data, decrypt_data,
decrypt_object, decrypt_object,
decrypt_request, decrypt_request,
decrypt_wire,
encrypt_object, encrypt_object,
generate_secret_key, generate_secret_key,
header_len_for, header_len_for,
split_envelope, split_envelope,
split_wire,
) )
from .envelope import ( from .envelope import (
KEY_ORDER, KEY_ORDER,
@@ -81,6 +83,8 @@ __all__ = [
"decrypt_object", "decrypt_object",
"decrypt_request", "decrypt_request",
"decrypt_data", "decrypt_data",
"decrypt_wire",
"split_wire",
"header_len_for", "header_len_for",
"KEY_ORDER", "KEY_ORDER",
"SNNO_MIN", "SNNO_MIN",
+72 -23
View File
@@ -5,42 +5,42 @@ Reconstructed from the blutter disassembly; see
Request wire form of an envelope:: Request wire form of an envelope::
s = compact JSON, e.g. {"cmdType":25,"equipNo":"SN",...} s = compact JSON, e.g. {"cmdType":1101,"equipNo":"SN",...}
S1 = parts[0] + "," + parts[1] + "," # quartet header, trailing comma S1 = parts[0] + "," + parts[1] + "," # quartet header, trailing comma
S2 = ",".join(parts[2:]) # the rest of the JSON S2 = ",".join(parts[2:]) # the rest of the JSON
a = UTF-8(S1) # sent PLAINTEXT first a = UTF-8(S1) # sent PLAINTEXT first
n = int.tryParse(key, radix: 32) or 0 n = int.tryParse(key) or 0 # default radix 16
wire = a || bytes((S2[i] + i + len(a) + n) & 0xFF) wire = a || bytes((S2[i] + i + len(a) + n) & 0xFF)
i.e. the two leading JSON parts go on the wire **unchanged first**, and the i.e. the two leading JSON parts go on the wire **unchanged first**, and the
tail is transformed byte-wise with a position-dependent additive stream tail is transformed byte-wise with a position-dependent additive stream
(index ``i`` is relative to the tail; ``i + len(a)`` is the absolute JSON (index ``i`` is relative to the tail; ``i + len(a)`` is the absolute JSON
index). The receiver that knows the serial can compute ``len(a)`` up front index). Both directions use this same framing (verified live: a 1154-byte
(``header_len_for``) and split the wire deterministically. device reply decrypts with the identical split — see ``decrypt_wire``).
Responses use a different framing (``decrypt_data``): the wire holds the .. note:: An earlier revision claimed radix 32 from a ``0x20`` literal
plaintext JSON prefix ``s`` (which itself contains the serial's ``X``), next to the ``tryParse`` call sites. Two independent live frames (an
a literal ``0x58`` separator, then the encoded tail:: app discovery request and a device info reply) decrypt cleanly only
with radix 16 (``int("80", 16) == 128``, ``int("1c", 16) == 28``) and
then verify byte-exact (``len``/uppercase CRC-32). Ground truth wins:
the ``0x20`` belongs to a neighbouring call.
wire = UTF-8(s) || 0x58 || encode(tail) Confirmed from the disassembly *and live traffic*:
``decrypt_data`` locates the *second* ``0x58`` byte ``j``, takes
``s = UTF-8(wire[:j])``, probes ``s + "}"`` as JSON to recover ``equipNo``
(and hence the key), decodes the tail with the same stream in reverse, and
returns ``s + "," + tail_plain``. Anything unexpected yields ``""`` (the
app's ``decryptData`` returns ``""`` on marker/parse failure and the socket
layer keeps accumulating bytes).
Confirmed from the disassembly:
* ``generateSecretKey`` = ``sum(utf8(sn)) & 0xFF`` rendered as lowercase hex * ``generateSecretKey`` = ``sum(utf8(sn)) & 0xFF`` rendered as lowercase hex
with **no** zero padding (Dart ``_toPow2String(16)``). with **no** zero padding (Dart ``_toPow2String(16)``). E.g. ``"1c"`` for
* The key is parsed with ``int.tryParse(key, radix: 32)`` — the literal the gateway SN, ``"80"`` for the empty/placeholder SN.
``0x20`` (32) is materialised right before the call in both the encrypt * The key is parsed with ``int.tryParse(key)`` — default radix **16**,
and decrypt paths. verified against two live frames (``tests/fixtures/*.bin``).
* ``encryptObject(object, key)`` takes the object in the first slot and the * ``encryptObject(object, key)`` takes the object in the first slot and the
key in the second; its result is ``a ++ transform(b)`` (plaintext header key in the second; its result is ``a ++ transform(b)`` (plaintext header
first — ``addAll(transformed)`` onto the header copy). first — ``addAll(transformed)`` onto the header copy).
* Replies use the same ``S1 || transform(S2)`` framing (the device info
reply decrypts identically). The reply ``cmdType`` observed live is the
request id + 1 (``1101`` → ``1102``); replies are therefore parsed with
:func:`decrypt_wire`, which locates the header without knowing the ids
up front. ``decrypt_data`` (``0x58``-marker model) is kept for the
Bluetooth path, which is unverified live.
""" """
from __future__ import annotations from __future__ import annotations
@@ -48,8 +48,10 @@ from __future__ import annotations
import json import json
from typing import Optional, Tuple from typing import Optional, Tuple
#: Key radix used by both ``encryptObject`` and ``decryptData`` (literal 0x20). #: Key radix used by the envelope cipher (default ``tryParse`` radix).
DEFAULT_KEY_RADIX = 32 #: Verified live: ``int("80", 16) == 128`` and ``int("1c", 16) == 28``
#: decode two independent captured frames byte-exact.
DEFAULT_KEY_RADIX = 16
#: Response separator byte (``0x58``, ASCII ``X``). #: Response separator byte (``0x58``, ASCII ``X``).
MARKER = 0x58 MARKER = 0x58
@@ -143,6 +145,53 @@ def decrypt_request(
decrypt_object = decrypt_request decrypt_object = decrypt_request
def split_wire(wire: bytes) -> Tuple[bytes, bytes]:
"""Split any envelope wire into ``(a, b)`` at the 2nd comma.
The header ``S1 = parts[0] + "," + parts[1] + ","`` is always ASCII
with no commas inside, so the first two commas on the wire are its
structural boundaries — no ids or serials needed up front. Works for
requests and replies alike.
"""
raw = bytes(wire)
first = raw.find(b",")
if first < 0:
raise ValueError("no header comma in wire buffer")
second = raw.find(b",", first + 1)
if second < 0:
raise ValueError("truncated header in wire buffer")
return raw[: second + 1], raw[second + 1:]
def decrypt_wire(wire: bytes, radix: int = DEFAULT_KEY_RADIX) -> str:
"""Decode one complete envelope wire (request or reply) to JSON.
Splits via :func:`split_wire`, recovers ``equipNo`` from the
``a + "}"`` probe (exactly how the receiver learns which key to use),
and reverses the additive stream. Raises ``ValueError`` on any
failure (use :func:`decrypt_data`-style ``""`` handling when
accumulating).
"""
a, b = split_wire(wire)
try:
# ``a`` ends with the header's trailing comma — drop it so the
# probe is valid JSON (mirrors the ``s + "}"`` equipNo probe).
probe = json.loads(a[:-1].decode("utf-8") + "}")
except (UnicodeDecodeError, ValueError) as exc:
raise ValueError(f"header probe is not JSON: {exc}") from exc
if not isinstance(probe, dict):
raise ValueError("header probe is not an object")
key = generate_secret_key(str(probe.get("equipNo", "")))
key_int = _parse_key(key, radix)
plain_b = bytes(
(byte - i - len(a) - key_int) & 0xFF for i, byte in enumerate(b)
)
try:
return (a + plain_b).decode("utf-8")
except UnicodeDecodeError as exc:
raise ValueError(f"tail is not UTF-8: {exc}") from exc
def decrypt_data(wire: bytes, radix: int = DEFAULT_KEY_RADIX) -> str: def decrypt_data(wire: bytes, radix: int = DEFAULT_KEY_RADIX) -> str:
"""``decryptData`` — decode one accumulated *response* buffer. """``decryptData`` — decode one accumulated *response* buffer.
+4 -3
View File
@@ -224,11 +224,12 @@ class SocketFrameDecoder:
def feed(self, data: bytes) -> List[str]: def feed(self, data: bytes) -> List[str]:
"""Append *data*; return a list of complete decoded JSON strings.""" """Append *data*; return a list of complete decoded JSON strings."""
from .crypto import decrypt_data # local import: cheap, no cycles from .crypto import decrypt_wire # local import: cheap, no cycles
self._buf.extend(data) self._buf.extend(data)
decoded = decrypt_data(bytes(self._buf)) try:
if not decoded: decoded = decrypt_wire(bytes(self._buf))
except ValueError:
return [] return []
try: try:
json.loads(decoded) json.loads(decoded)
+194
View File
@@ -0,0 +1,194 @@
#!/usr/bin/env python3
"""Host probe v6 — verified-against-capture wire format (stdlib only).
Locked by two live frames (tests/fixtures/*.bin in the repo):
* key radix 16 (``int("80",16)=128``, ``int("1c",16)=28``);
* ``S1 || transform(S2)`` both directions; reply id = request id + 1
observed (1101 -> 1102);
* socket payloads seeded with ``"opt": 0``;
* replies parsed by splitting at the 2nd comma (no ids needed up front).
Run on the SoftAP-joined host:
python3 host_probe6.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]
SOC_KEYS = ("currentBatterySoc", "soc", "batterySoc", "vppSoc")
def secret_key(sn):
return format(sum(sn.encode()) & 0xFF, "x")
def build_envelope(sn, cmd, args=None, snno=None, ts=None, equip=None):
if snno is None:
SNNO[0] = (SNNO[0] % 9999) + 1
snno = SNNO[0]
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,
"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, 16)
return a + bytes((b[i] + i + len(a) + n) & 0xFF
for i in range(len(b)))
def decrypt_wire(wire):
first = wire.find(b",")
second = wire.find(b",", first + 1)
if first < 0 or second < 0:
return ""
a, b = wire[:second + 1], wire[second + 1:]
try:
probe = json.loads(a[:-1].decode() + "}")
except Exception:
return ""
if not isinstance(probe, dict):
return ""
n = int(secret_key(str(probe.get("equipNo", ""))), 16)
try:
return (a + bytes((t - i - len(a) - n) & 0xFF
for i, t in enumerate(b))).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 walk(obj, key, found):
if isinstance(obj, dict):
for k, v in obj.items():
if k == key:
found.append(v)
walk(v, key, found)
elif isinstance(obj, list):
for item in obj:
walk(item, key, found)
return found
def extract_soc(reply):
data = reply.get("dataArea", reply)
if isinstance(data, str):
try:
data = json.loads(data)
except ValueError:
data = {}
for key in SOC_KEYS:
hits = walk(reply, key, [])
for hit in hits:
if isinstance(hit, bool):
continue
try:
return float(hit), key
except (TypeError, ValueError):
continue
arrs = walk(data if isinstance(data, dict) else {}, "socArray", [])
for arr in arrs:
if isinstance(arr, list) and arr:
try:
nums = [float(v) for v in arr
if not isinstance(v, bool)]
if nums:
return sum(nums) / len(nums), "socArray(avg)"
except (TypeError, ValueError):
continue
return None, None
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_wire(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()[:400] or "(empty)"))
if reply is not None:
print(" reply: " + json.dumps(reply, default=str)[:3000])
if isinstance(reply, dict):
val, src = extract_soc(reply)
print(" soc: %s (source=%s)" % (val, src))
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 16)" % (args.sn, key))
variants = [
("discovery/1101+empty", QUERY_INFO,
{"minProtocolVer": "V1.00.00"}, ""),
("queryInfo/1101+SN", QUERY_INFO,
{"minProtocolVer": "V1.00.00"}, None),
("apower/1307+SN", APOWER, {"update": 2}, None),
("apower/1307+IBG", APOWER, {"update": 2},
"10060005A03X25160132"),
("bms/1705+SN", BMS, None, None),
("ems/1707+SN", 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("discovery") else 0.0):
pass
if __name__ == "__main__":
main()
Binary file not shown.
+2
View File
@@ -0,0 +1,2 @@
{"cmdType":1101,"equipNo":"00000000",Ç ÌåÜÙÐ#(#'Úóëòõïïð÷úò÷ðç9568ìýùð;5?ô␍ ù;K=ýÿ%+
MK_M.`TQ,ndfk3*'jgmPsqwshus^n|-F/d@>ABADE8”•
+74 -24
View File
@@ -6,6 +6,7 @@ No radio/hardware needed. Vectors mirror the blutter disassembly of
""" """
import json import json
import os
import unittest import unittest
from franklinwh_agate_client.protocol import ( from franklinwh_agate_client.protocol import (
@@ -17,12 +18,14 @@ from franklinwh_agate_client.protocol import (
SocketFrameDecoder, SocketFrameDecoder,
decrypt_data, decrypt_data,
decrypt_request, decrypt_request,
decrypt_wire,
direct_connect_request, direct_connect_request,
encrypt_object, encrypt_object,
frame_socket_payload, frame_socket_payload,
generate_secret_key, generate_secret_key,
header_len_for, header_len_for,
split_envelope, split_envelope,
split_wire,
) )
from franklinwh_agate_client.protocol.crypto import MARKER from franklinwh_agate_client.protocol.crypto import MARKER
@@ -62,9 +65,9 @@ class TestSecretKey(unittest.TestCase):
def test_real_sn_key(self): def test_real_sn_key(self):
self.assertEqual(generate_secret_key(SN), "1c") self.assertEqual(generate_secret_key(SN), "1c")
def test_key_radix_is_32(self): def test_key_radix_is_16(self):
self.assertEqual(DEFAULT_KEY_RADIX, 32) self.assertEqual(DEFAULT_KEY_RADIX, 16)
self.assertEqual(int(generate_secret_key(SN), 32), 44) self.assertEqual(int(generate_secret_key(SN), 16), 28)
def test_empty_sn_key(self): def test_empty_sn_key(self):
self.assertEqual(generate_secret_key(""), "0") self.assertEqual(generate_secret_key(""), "0")
@@ -148,7 +151,7 @@ class TestCipher(unittest.TestCase):
def test_stream_is_absolute_index_plus_n(self): def test_stream_is_absolute_index_plus_n(self):
a, b = split_envelope(_apower_json()) a, b = split_envelope(_apower_json())
n = int(generate_secret_key(SN), 32) n = int(generate_secret_key(SN), 16)
wire = encrypt_object(_apower_json(), generate_secret_key(SN)) wire = encrypt_object(_apower_json(), generate_secret_key(SN))
tail = wire[len(a):] tail = wire[len(a):]
for i, byte in enumerate(b): for i, byte in enumerate(b):
@@ -163,16 +166,10 @@ class TestCipher(unittest.TestCase):
body, body,
) )
def test_decrypt_response_roundtrip(self): def test_wire_roundtrip(self):
body = _apower_json() body = _apower_json()
cut = body.index('"type"') wire = encrypt_object(body, generate_secret_key(SN))
s, tail = body[:cut - 1], body[cut:] self.assertEqual(decrypt_wire(wire), body)
n = int(generate_secret_key(SN), 32)
prefix_len = len((s + ",").encode("utf-8"))
enc = bytes((byte + k + prefix_len + n) & 0xFF
for k, byte in enumerate(tail.encode("utf-8")))
resp = s.encode("utf-8") + bytes((MARKER,)) + enc
self.assertEqual(decrypt_data(resp), body)
def test_decrypt_data_failures_are_empty(self): def test_decrypt_data_failures_are_empty(self):
self.assertEqual(decrypt_data(b""), "") self.assertEqual(decrypt_data(b""), "")
@@ -189,22 +186,75 @@ class TestSocketFraming(unittest.TestCase):
self.assertEqual(frame_socket_payload(payload), payload) self.assertEqual(frame_socket_payload(payload), payload)
def test_decoder_accumulates_until_json(self): def test_decoder_accumulates_until_json(self):
body = _apower_json() wire = open(os.path.join(FIXTURES, "discovery_req.bin"),
cut = body.index('"type"') "rb").read()
s, tail = body[:cut - 1], body[cut:]
n = int(generate_secret_key(SN), 32)
prefix_len = len((s + ",").encode("utf-8"))
enc = bytes((byte + k + prefix_len + n) & 0xFF
for k, byte in enumerate(tail.encode("utf-8")))
resp = s.encode("utf-8") + bytes((MARKER,)) + enc
dec = SocketFrameDecoder() dec = SocketFrameDecoder()
# first half: incomplete -> nothing yet # first half: incomplete -> nothing yet
self.assertEqual(dec.feed(resp[:len(resp) // 2]), []) self.assertEqual(dec.feed(wire[:len(wire) // 2]), [])
# rest: one complete JSON string # rest: one complete JSON string
out = dec.feed(resp[len(resp) // 2:]) out = dec.feed(wire[len(wire) // 2:])
self.assertEqual(len(out), 1) self.assertEqual(len(out), 1)
self.assertEqual(json.loads(out[0])["equipNo"], SN) self.assertEqual(json.loads(out[0])["equipNo"], "00000000")
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
FIXTURES = os.path.join(os.path.dirname(__file__), "fixtures")
class TestLiveCaptures(unittest.TestCase):
"""Golden vectors: two real frames (app request + device reply)."""
def test_key_radix_is_16(self):
self.assertEqual(DEFAULT_KEY_RADIX, 16)
self.assertEqual(generate_secret_key("00000000"), "80")
self.assertEqual(int("80", 16), 128)
self.assertEqual(int(generate_secret_key(SN), 16), 28)
def test_discovery_request_vector(self):
wire = open(os.path.join(FIXTURES, "discovery_req.bin"), "rb").read()
self.assertEqual(len(wire), 153)
a, b = split_wire(wire)
self.assertEqual(a, b'{"cmdType":1101,"equipNo":"00000000",')
self.assertEqual(
decrypt_wire(wire),
'{"cmdType":1101,"equipNo":"00000000","type":0,'
'"timeStamp":1792117904,"snno":1,"len":37,"crc":"A474C16F",'
'"dataArea":{"opt":0,"minProtocolVer":"V1.00.00"}}',
)
def test_device_info_reply_vector(self):
wire = open(os.path.join(FIXTURES, "device_info_reply.bin"),
"rb").read()
self.assertEqual(len(wire), 1154)
a, b = split_wire(wire)
self.assertEqual(
a, b'{"cmdType":1102,"equipNo":"10060005A03X25160132",')
reply = json.loads(decrypt_wire(wire))
self.assertEqual(reply["cmdType"], 1102)
self.assertEqual(reply["equipNo"], "10060005A03X25160132")
self.assertEqual(reply["len"], 1024)
data = reply["dataArea"]
self.assertEqual(data["opt"], 0)
self.assertEqual(data["result"], 0)
self.assertEqual(data["IBG_SN"], "10060005A03X25160132")
self.assertEqual(data["FHP_NUM"], 2)
self.assertEqual(len(data["FHP_SN"]), 2)
self.assertEqual(len(data["BMS_SN"]), 2)
# len/crc integrity, as the device checks them
import zlib
compact = json.dumps(data, separators=(",", ":"),
ensure_ascii=False)
self.assertEqual(reply["len"], len(compact.encode("utf-8")))
self.assertEqual(
reply["crc"], format(zlib.crc32(compact.encode()), "X"))
def test_split_wire_errors(self):
with self.assertRaises(ValueError):
split_wire(b"no commas here")
with self.assertRaises(ValueError):
split_wire(b'{"cmdType":1101')
with self.assertRaises(ValueError):
decrypt_wire(b"not json at all")