Correct wire format to disassembly: raw S1||crypt(S2), radix 32, int snno/seconds, decryptData response parser, --probe
- envelope: 7 fixed keys + conditional dataArea, no data key; int
type/snno, seconds timestamp, data setter auto len/crc (CRC32 upper);
SequenceNumber 1..9999 (increaseSN)
- crypto: encrypt is plaintext-header-first a||transform(b), key radix 32
at both tryParse sites; decrypt_data ports decryptData (dual-0x58
markers, s+'}' equipNo probe, s+','+T return); decrypt_request kept
for the device-side known-header split
- socket: raw send (no length prefix), accumulate-and-try receive
- battery: queryAPowerData shape (data={'update':2}), radix default 32,
--probe prints exact TX/RX bytes per variant; device_local.send_probe
- docs: RE_DART_FINDINGS ss3/5/8 + open items, BATTERY_POC host recipe
- tests: 19 new protocol tests, suite 44/44 green
This commit is contained in:
1 parent
2438a04ed5
commit
9e69f07636
10 files changed
+744
-182
No files matched your search
@@ -25,8 +25,10 @@ from .protocol import (
|
||||
CommunicationCmd,
|
||||
CommunicationObject,
|
||||
CommunicationOperate,
|
||||
SequenceNumber,
|
||||
SocketFrameDecoder,
|
||||
decrypt_object,
|
||||
decrypt_data,
|
||||
decrypt_request,
|
||||
direct_connect_request,
|
||||
encrypt_object,
|
||||
frame_socket_payload,
|
||||
@@ -62,8 +64,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 (16 default;
|
||||
32 is the alternate candidate seen in the disassembly).
|
||||
:param key_radix: key parse radix for the envelope cipher (32, per the
|
||||
``int.tryParse(key, radix: 32)`` call in both directions).
|
||||
:param timeout: per-request timeout in seconds.
|
||||
"""
|
||||
|
||||
@@ -75,7 +77,7 @@ class LocalDeviceClient:
|
||||
scheme: str = "https",
|
||||
verify: Optional[bool] = False,
|
||||
socket_port: Optional[int] = None,
|
||||
key_radix: int = 16,
|
||||
key_radix: int = 32,
|
||||
timeout: float = 10.0,
|
||||
extra_headers: Optional[Dict[str, str]] = None,
|
||||
session: Optional[requests.Session] = None,
|
||||
@@ -90,6 +92,7 @@ class LocalDeviceClient:
|
||||
self.key_radix = int(key_radix)
|
||||
self.timeout = timeout
|
||||
self.key = generate_secret_key(sn)
|
||||
self._sn_seq = SequenceNumber()
|
||||
self._session = session or requests.Session()
|
||||
self._session.headers.update(
|
||||
{X_DEVICE_IP: host, **(extra_headers or {})}
|
||||
@@ -169,19 +172,21 @@ class LocalDeviceClient:
|
||||
cmd: CommunicationCmd,
|
||||
equip_no: Optional[str] = None,
|
||||
operate: CommunicationOperate = CommunicationOperate.QUERY,
|
||||
data: str = "",
|
||||
data: Optional[Dict[str, Any]] = None,
|
||||
envelope: Optional[CommunicationObject] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Send one envelope over the local socket and return the parsed reply.
|
||||
|
||||
The request is the app's envelope: compact JSON -> ``encrypt_object``
|
||||
-> int16LE length framing. The reply is deframed, decrypted (the
|
||||
unencrypted tail is located via ``cmdType``/``equipNo``) and parsed
|
||||
back into a JSON object.
|
||||
Mirrors ``sendObject``: the envelope's ``snno`` is stamped from the
|
||||
``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.
|
||||
"""
|
||||
if envelope is None:
|
||||
envelope = direct_connect_request(
|
||||
cmd, equip_no or self.sn, data=data
|
||||
cmd, equip_no or self.sn, data=data,
|
||||
snno=self._sn_seq.next(),
|
||||
)
|
||||
return self.send_envelope(envelope)
|
||||
|
||||
@@ -189,30 +194,82 @@ 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_object(
|
||||
raw,
|
||||
self.key,
|
||||
cmd_type=envelope.cmd_type,
|
||||
equip_no=envelope.equip_no,
|
||||
radix=self.key_radix,
|
||||
)
|
||||
reply = decrypt_data(raw, radix=self.key_radix)
|
||||
if not reply:
|
||||
raise ConnectionError(
|
||||
f"undecodable reply ({len(raw)} bytes) from "
|
||||
f"{self.socket_host}:{self.socket_port}"
|
||||
)
|
||||
return json.loads(reply)
|
||||
|
||||
def send_probe(
|
||||
self,
|
||||
cmd: CommunicationCmd,
|
||||
data: Optional[Dict[str, Any]] = None,
|
||||
timeout: Optional[float] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Send one envelope, return exact TX/RX bytes plus any parsed reply.
|
||||
|
||||
Never raises on a device-level failure: ``reply`` is the parsed JSON
|
||||
(or ``None``), ``reply_raw`` the exact bytes received (``b""`` when
|
||||
the device accepted the frame but answered with zero bytes).
|
||||
"""
|
||||
envelope = direct_connect_request(
|
||||
cmd, self.sn, data=data, snno=self._sn_seq.next(),
|
||||
)
|
||||
wire = encrypt_object(envelope.to_json_string(), self.key,
|
||||
radix=self.key_radix)
|
||||
reply_raw = self._socket_roundtrip(
|
||||
frame_socket_payload(wire), timeout=timeout, collect_only=True,
|
||||
)
|
||||
reply: Optional[Dict[str, Any]] = None
|
||||
if reply_raw:
|
||||
decoded = decrypt_data(reply_raw, radix=self.key_radix)
|
||||
if decoded:
|
||||
try:
|
||||
reply = json.loads(decoded)
|
||||
except ValueError:
|
||||
reply = None
|
||||
return {
|
||||
"cmd": str(cmd),
|
||||
"envelope": envelope.to_dict(),
|
||||
"tx": wire,
|
||||
"reply_raw": reply_raw,
|
||||
"reply": reply,
|
||||
}
|
||||
|
||||
def _socket_roundtrip(self, framed: bytes,
|
||||
timeout: Optional[float] = None) -> bytes:
|
||||
timeout: Optional[float] = None,
|
||||
collect_only: bool = False) -> bytes:
|
||||
timeout = timeout if timeout is not None else self.timeout
|
||||
received = bytearray()
|
||||
with socket.create_connection(
|
||||
(self.socket_host, self.socket_port), timeout=timeout
|
||||
) as sock:
|
||||
sock.settimeout(timeout)
|
||||
sock.sendall(framed)
|
||||
decoder = SocketFrameDecoder()
|
||||
while True:
|
||||
chunk = sock.recv(65536)
|
||||
try:
|
||||
chunk = sock.recv(65536)
|
||||
except socket.timeout:
|
||||
break
|
||||
if not chunk:
|
||||
break
|
||||
frames = decoder.feed(chunk)
|
||||
if frames:
|
||||
return frames[0]
|
||||
received.extend(chunk)
|
||||
if collect_only:
|
||||
# Probe mode: drain what the device sends, then stop on
|
||||
# the short post-reply idle timeout. A rejected frame
|
||||
# is met with zero bytes + close; a real reply arrives
|
||||
# as buffered bytes first.
|
||||
sock.settimeout(0.5)
|
||||
continue
|
||||
if decoder.feed(chunk):
|
||||
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}"
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user