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
+418 -82

No files matched your search

+16 -8
View File
@@ -125,16 +125,18 @@ def get_battery(
sn: str,
host: str = "10.100.1.1",
ports: Tuple[int, ...] = SOCKET_PORT_CANDIDATES,
key_radix: int = 32,
key_radix: int = 16,
timeout: float = 5.0,
) -> Dict[str, Any]:
"""Query the aggregate charge percentage over the local TCP socket.
Mirrors the app's ``queryAPowerData`` first (``DEVICE_APOWER_INFO`` with
``data={"update": 2}``), then ``BMS`` and ``EMS`` as fallbacks, probing
each candidate port. Returns the ``extract_soc`` dict plus
``cmd``/``port``/``reply`` context. Raises the last socket error if no
port answers.
``data={"opt": 0, "update": 2}``), then ``BMS`` and ``EMS`` as fallbacks,
probing each candidate port. Like the app's direct-connect flow, a
discovery query (``QUERY_DEVICE_INFO``, empty serial) opens each port's
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
for port in ports:
@@ -142,6 +144,12 @@ def get_battery(
sn=sn, host=host, socket_port=port,
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 (
(CommunicationCmd.DEVICE_APOWER_INFO, {"update": 2}),
(CommunicationCmd.BMS, None),
@@ -182,7 +190,7 @@ def probe_device(
sn: str,
host: str = "10.100.1.1",
ports: Tuple[int, ...] = SOCKET_PORT_CANDIDATES,
key_radix: int = 32,
key_radix: int = 16,
timeout: float = 5.0,
) -> List[Dict[str, Any]]:
"""Send every :data:`PROBE_VARIANTS` envelope; return exact TX/RX bytes."""
@@ -251,7 +259,7 @@ def probe_transport(
sn: str,
host: str = "10.100.1.1",
ports: Tuple[int, ...] = (9000,),
key_radix: int = 32,
key_radix: int = 16,
timeout: float = 5.0,
) -> List[Dict[str, Any]]:
"""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)),
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("--show-hotspot", action="store_true",
help="print derived AP SSID/password and exit")
+19 -12
View File
@@ -29,6 +29,7 @@ from .protocol import (
SocketFrameDecoder,
decrypt_data,
decrypt_request,
decrypt_wire,
direct_connect_request,
encrypt_object,
frame_socket_payload,
@@ -64,8 +65,8 @@ class LocalDeviceClient:
:param socket_port: local TCP socket port (candidate 18000).
:param verify: TLS verification for the device API (device likely serves a
self-signed cert, so ``False`` is the practical default).
:param key_radix: key parse radix for the envelope cipher (32, per the
``int.tryParse(key, radix: 32)`` call in both directions).
:param key_radix: key parse radix for the envelope cipher (16 —
default ``int.tryParse`` radix, verified against live captures).
:param timeout: per-request timeout in seconds.
"""
@@ -77,7 +78,7 @@ class LocalDeviceClient:
scheme: str = "https",
verify: Optional[bool] = False,
socket_port: Optional[int] = None,
key_radix: int = 32,
key_radix: int = 16,
timeout: float = 10.0,
extra_headers: Optional[Dict[str, str]] = None,
session: Optional[requests.Session] = None,
@@ -185,7 +186,7 @@ class LocalDeviceClient:
"""
if envelope is None:
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(),
)
return self.send_envelope(envelope)
@@ -194,8 +195,9 @@ class LocalDeviceClient:
wire = encrypt_object(envelope.to_json_string(), self.key,
radix=self.key_radix)
raw = self._socket_roundtrip(frame_socket_payload(wire))
reply = decrypt_data(raw, radix=self.key_radix)
if not reply:
try:
reply = decrypt_wire(raw, radix=self.key_radix)
except ValueError:
raise ConnectionError(
f"undecodable reply ({len(raw)} bytes) from "
f"{self.socket_host}:{self.socket_port}"
@@ -224,7 +226,10 @@ class LocalDeviceClient:
)
reply: Optional[Dict[str, Any]] = None
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:
try:
reply = json.loads(decoded)
@@ -268,11 +273,13 @@ class LocalDeviceClient:
break
if collect_only:
return bytes(received)
if decrypt_data(bytes(received), radix=self.key_radix):
return bytes(received)
raise ConnectionError(
f"no reply frame from {self.socket_host}:{self.socket_port}"
)
try:
decrypt_wire(bytes(received), radix=self.key_radix)
except ValueError:
raise ConnectionError(
f"no reply frame from {self.socket_host}:{self.socket_port}"
)
return bytes(received)
# ------------------------------------------------------- convenience RPCs
def query_device_info(self) -> Dict[str, Any]:
@@ -26,10 +26,12 @@ from .crypto import (
decrypt_data,
decrypt_object,
decrypt_request,
decrypt_wire,
encrypt_object,
generate_secret_key,
header_len_for,
split_envelope,
split_wire,
)
from .envelope import (
KEY_ORDER,
@@ -81,6 +83,8 @@ __all__ = [
"decrypt_object",
"decrypt_request",
"decrypt_data",
"decrypt_wire",
"split_wire",
"header_len_for",
"KEY_ORDER",
"SNNO_MIN",
+72 -23
View File
@@ -5,42 +5,42 @@ Reconstructed from the blutter disassembly; see
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
S2 = ",".join(parts[2:]) # the rest of the JSON
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)
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
(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
(``header_len_for``) and split the wire deterministically.
index). Both directions use this same framing (verified live: a 1154-byte
device reply decrypts with the identical split — see ``decrypt_wire``).
Responses use a different framing (``decrypt_data``): the wire holds the
plaintext JSON prefix ``s`` (which itself contains the serial's ``X``),
a literal ``0x58`` separator, then the encoded tail::
.. note:: An earlier revision claimed radix 32 from a ``0x20`` literal
next to the ``tryParse`` call sites. Two independent live frames (an
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)
``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:
Confirmed from the disassembly *and live traffic*:
* ``generateSecretKey`` = ``sum(utf8(sn)) & 0xFF`` rendered as lowercase hex
with **no** zero padding (Dart ``_toPow2String(16)``).
* The key is parsed with ``int.tryParse(key, radix: 32)`` — the literal
``0x20`` (32) is materialised right before the call in both the encrypt
and decrypt paths.
with **no** zero padding (Dart ``_toPow2String(16)``). E.g. ``"1c"`` for
the gateway SN, ``"80"`` for the empty/placeholder SN.
* The key is parsed with ``int.tryParse(key)`` — default radix **16**,
verified against two live frames (``tests/fixtures/*.bin``).
* ``encryptObject(object, key)`` takes the object in the first slot and the
key in the second; its result is ``a ++ transform(b)`` (plaintext header
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
@@ -48,8 +48,10 @@ from __future__ import annotations
import json
from typing import Optional, Tuple
#: Key radix used by both ``encryptObject`` and ``decryptData`` (literal 0x20).
DEFAULT_KEY_RADIX = 32
#: Key radix used by the envelope cipher (default ``tryParse`` radix).
#: 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``).
MARKER = 0x58
@@ -143,6 +145,53 @@ def 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:
"""``decryptData`` — decode one accumulated *response* buffer.
+4 -3
View File
@@ -224,11 +224,12 @@ class SocketFrameDecoder:
def feed(self, data: bytes) -> List[str]:
"""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)
decoded = decrypt_data(bytes(self._buf))
if not decoded:
try:
decoded = decrypt_wire(bytes(self._buf))
except ValueError:
return []
try:
json.loads(decoded)