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
@@ -15,15 +15,25 @@ from .crc import (
|
||||
le_bytes_to_int16,
|
||||
)
|
||||
from .crypto import (
|
||||
ALT_KEY_RADIX,
|
||||
DEFAULT_KEY_RADIX,
|
||||
MARKER,
|
||||
decrypt_data,
|
||||
decrypt_object,
|
||||
decrypt_request,
|
||||
encrypt_object,
|
||||
generate_secret_key,
|
||||
header_len_for,
|
||||
split_envelope,
|
||||
)
|
||||
from .envelope import KEY_ORDER, PLACEHOLDER, CommunicationObject, direct_connect_request
|
||||
from .envelope import (
|
||||
KEY_ORDER,
|
||||
SNNO_MAX,
|
||||
SNNO_MIN,
|
||||
PLACEHOLDER,
|
||||
CommunicationObject,
|
||||
SequenceNumber,
|
||||
direct_connect_request,
|
||||
)
|
||||
from .framing import (
|
||||
MAGIC_ACK,
|
||||
MAGIC_DATA,
|
||||
@@ -52,15 +62,20 @@ __all__ = [
|
||||
"int16_to_le_bytes",
|
||||
"le_bytes_to_int16",
|
||||
"DEFAULT_KEY_RADIX",
|
||||
"ALT_KEY_RADIX",
|
||||
"MARKER",
|
||||
"generate_secret_key",
|
||||
"split_envelope",
|
||||
"encrypt_object",
|
||||
"decrypt_object",
|
||||
"decrypt_request",
|
||||
"decrypt_data",
|
||||
"header_len_for",
|
||||
"KEY_ORDER",
|
||||
"SNNO_MIN",
|
||||
"SNNO_MAX",
|
||||
"PLACEHOLDER",
|
||||
"CommunicationObject",
|
||||
"SequenceNumber",
|
||||
"direct_connect_request",
|
||||
"MAGIC_ACK",
|
||||
"MAGIC_DATA",
|
||||
|
||||
@@ -1,46 +1,64 @@
|
||||
"""Envelope encryption — ``communication_center/core/encryption.dart``.
|
||||
"""Envelope cipher — ``communication_center/core/encryption.dart``.
|
||||
|
||||
Reconstructed from the blutter disassembly; see
|
||||
``docs/RE_DART_FINDINGS.md`` §5 and Open items #2 / #5.
|
||||
``docs/RE_DART_FINDINGS.md`` §5.
|
||||
|
||||
Wire form of an envelope::
|
||||
Request wire form of an envelope::
|
||||
|
||||
s = compact JSON, e.g. {"cmdType":0,"equipNo":"SN",...,"data":""}
|
||||
a = UTF-8( parts[0] + "," + parts[1] ) # {"cmdType":..,"equipNo":..
|
||||
b = UTF-8( ",".join(parts[2:]) ) # the rest of the JSON
|
||||
key_int = int.tryParse(key, radix) or 0
|
||||
wire = bytes( (b[i] + i + len(a) + key_int) & 0xFF ) || a
|
||||
s = compact JSON, e.g. {"cmdType":25,"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
|
||||
wire = a || bytes((S2[i] + i + len(a) + n) & 0xFF)
|
||||
|
||||
i.e. everything after the second comma is transformed byte-wise (position-
|
||||
dependent additive stream) and the two leading JSON parts are appended
|
||||
**unchanged** at the end.
|
||||
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.
|
||||
|
||||
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::
|
||||
|
||||
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:
|
||||
|
||||
* ``generateSecretKey`` = ``sum(utf8(sn)) & 0xFF`` rendered as lowercase hex
|
||||
with **no** zero padding (Dart ``_toPow2String(16)``).
|
||||
* The key is parsed with ``int.tryParse`` (null -> 0) with a ``radix``
|
||||
argument; a literal ``#0x20`` (32) is materialised right before the call in
|
||||
both encrypt and decrypt paths. Hex is self-consistent with the key
|
||||
encoding, so **radix 16 is the default** here — pass ``radix=32`` to try the
|
||||
other candidate. Confirm against a live capture.
|
||||
* 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.
|
||||
* ``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).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Optional, Tuple
|
||||
|
||||
#: Default key radix — see module docstring for the 16-vs-32 note.
|
||||
DEFAULT_KEY_RADIX = 16
|
||||
#: Key radix used by both ``encryptObject`` and ``decryptData`` (literal 0x20).
|
||||
DEFAULT_KEY_RADIX = 32
|
||||
|
||||
#: Alternate candidate observed next to the ``tryParse`` call in the asm.
|
||||
ALT_KEY_RADIX = 32
|
||||
#: Response separator byte (``0x58``, ASCII ``X``).
|
||||
MARKER = 0x58
|
||||
|
||||
|
||||
def generate_secret_key(equip_no: str) -> str:
|
||||
"""``generateSecretKey(equipNo)``: sum of UTF-8 bytes, ``& 0xFF``, hex.
|
||||
|
||||
Example: ``generate_secret_key("01234567890123456789")`` is the lowercase
|
||||
hex of ``(sum of those 20 bytes) & 0xFF`` (1- or 2-digit string).
|
||||
Example: ``generate_secret_key("A03X2516013210060005") == "1c"``.
|
||||
"""
|
||||
total = sum(equip_no.encode("utf-8")) & 0xFF
|
||||
return format(total, "x")
|
||||
@@ -55,37 +73,43 @@ def _parse_key(key: str, radix: int) -> int:
|
||||
|
||||
|
||||
def split_envelope(json_str: str) -> Tuple[bytes, bytes]:
|
||||
"""Split the JSON into ``(a, b)`` as the app does (UTF-8 encoded)."""
|
||||
"""Split the JSON into ``(a, b)`` as the app does (UTF-8 encoded).
|
||||
|
||||
``a`` is the plaintext header ``parts[0] + "," + parts[1] + ","``
|
||||
(trailing comma included); ``b`` is the remaining tail.
|
||||
"""
|
||||
parts = json_str.split(",")
|
||||
a = (parts[0] + "," + parts[1]).encode("utf-8")
|
||||
a = (parts[0] + "," + parts[1] + ",").encode("utf-8")
|
||||
b = ",".join(parts[2:]).encode("utf-8")
|
||||
return a, b
|
||||
|
||||
|
||||
def encrypt_object(json_str: str, key: str,
|
||||
radix: int = DEFAULT_KEY_RADIX) -> bytes:
|
||||
"""Encrypt an envelope JSON string into wire bytes (``transform(b) || a``)."""
|
||||
def encrypt_object(
|
||||
json_str: str, key: str, radix: int = DEFAULT_KEY_RADIX
|
||||
) -> bytes:
|
||||
"""Encrypt an envelope JSON string into wire bytes (``a || crypt(b)``)."""
|
||||
a, b = split_envelope(json_str)
|
||||
key_int = _parse_key(key, radix)
|
||||
transformed = bytes(
|
||||
(byte + i + len(a) + key_int) & 0xFF for i, byte in enumerate(b)
|
||||
)
|
||||
return transformed + a
|
||||
return bytes(a) + transformed
|
||||
|
||||
|
||||
def header_len_for(cmd_type: int, equip_no: str) -> int:
|
||||
"""``len(a)`` for an envelope with the given ``cmdType`` / ``equipNo``.
|
||||
|
||||
Derived from the *known* key order (``cmdType``, ``equipNo`` first) and
|
||||
compact JSON — this is what a receiver can use to locate the unencrypted
|
||||
tail of an incoming frame.
|
||||
compact JSON — this is what a receiver that knows the serial uses to
|
||||
locate the end of the unencrypted header of an incoming *request* frame.
|
||||
"""
|
||||
part0 = '"cmdType":%d' % int(cmd_type)
|
||||
part1 = '"equipNo":"%s"' % equip_no
|
||||
return len(part0) + 1 + len(part1)
|
||||
# parts[0] + "," + parts[1] + "," — plus the leading "{" of the JSON.
|
||||
return len(("{" + part0).encode("utf-8")) + 1 + len(part1.encode("utf-8")) + 1
|
||||
|
||||
|
||||
def decrypt_object(
|
||||
def decrypt_request(
|
||||
wire: bytes,
|
||||
key: str,
|
||||
header_len: Optional[int] = None,
|
||||
@@ -95,27 +119,71 @@ def decrypt_object(
|
||||
) -> str:
|
||||
"""Inverse of :func:`encrypt_object` -> original JSON string.
|
||||
|
||||
``header_len`` is ``len(a)`` (the unencrypted tail). When omitted it is
|
||||
derived from ``cmd_type`` + ``equip_no`` via :func:`header_len_for` —
|
||||
both must be known in that case.
|
||||
Splits the wire at the known plaintext-header length (device side —
|
||||
the device knows its own serial). ``header_len`` is ``len(a)``; when
|
||||
omitted it is derived from ``cmd_type`` + ``equip_no``.
|
||||
"""
|
||||
if header_len is None:
|
||||
if cmd_type is None or equip_no is None:
|
||||
raise ValueError(
|
||||
"header_len (or cmd_type + equip_no) is required to split the "
|
||||
"wire buffer"
|
||||
"header_len (or cmd_type + equip_no) is required to split "
|
||||
"the wire buffer"
|
||||
)
|
||||
header_len = header_len_for(cmd_type, equip_no)
|
||||
a = wire[len(wire) - header_len:] if header_len else b""
|
||||
b = wire[: len(wire) - header_len] if header_len else bytes(wire)
|
||||
a = wire[:header_len]
|
||||
b = wire[header_len:]
|
||||
key_int = _parse_key(key, radix)
|
||||
plain_b = bytes(
|
||||
(byte - i - len(a) - key_int) & 0xFF for i, byte in enumerate(b)
|
||||
)
|
||||
a_str = a.decode("utf-8")
|
||||
b_str = plain_b.decode("utf-8")
|
||||
if not a_str:
|
||||
return b_str
|
||||
if not b_str:
|
||||
return a_str
|
||||
return a_str + "," + b_str
|
||||
return (a + plain_b).decode("utf-8")
|
||||
|
||||
|
||||
# Backwards-compatible alias (device-side request split).
|
||||
decrypt_object = decrypt_request
|
||||
|
||||
|
||||
def decrypt_data(wire: bytes, radix: int = DEFAULT_KEY_RADIX) -> str:
|
||||
"""``decryptData`` — decode one accumulated *response* buffer.
|
||||
|
||||
Faithful port of the app's routine: find the first two ``0x58`` bytes,
|
||||
take ``s`` as everything before the second one, probe ``s + "}"`` for
|
||||
``equipNo`` to derive the key, decode the tail after the marker, and
|
||||
return ``s + "," + tail``. Returns ``""`` on any failure (markers
|
||||
missing, probe not JSON, decode error) — the caller keeps accumulating.
|
||||
"""
|
||||
try:
|
||||
raw = bytes(wire)
|
||||
i = raw.find(bytes((MARKER,)))
|
||||
if i < 0:
|
||||
return ""
|
||||
j = raw.find(bytes((MARKER,)), i + 1)
|
||||
if j < 0:
|
||||
return ""
|
||||
try:
|
||||
s = raw[:j].decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return ""
|
||||
try:
|
||||
probe = json.loads(s + "}")
|
||||
except ValueError:
|
||||
return ""
|
||||
if not isinstance(probe, dict):
|
||||
return ""
|
||||
equip_no = probe.get("equipNo", "")
|
||||
key = generate_secret_key(str(equip_no))
|
||||
key_int = _parse_key(key, radix)
|
||||
prefix = s + ","
|
||||
prefix_len = len(prefix.encode("utf-8"))
|
||||
tail = raw[j + 1:]
|
||||
plain = bytes(
|
||||
(byte - k - prefix_len - key_int) & 0xFF
|
||||
for k, byte in enumerate(tail)
|
||||
)
|
||||
try:
|
||||
tail_str = plain.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return ""
|
||||
return prefix + tail_str
|
||||
except Exception:
|
||||
return ""
|
||||
@@ -1,20 +1,22 @@
|
||||
"""Wire envelope — ``CommunicationObject``.
|
||||
|
||||
JSON key order (exact, from ``toJson`` in ``core/communication_object.dart``):
|
||||
``cmdType, equipNo, type, timeStamp, snno, len, crc, dataArea, data``.
|
||||
Recovered from ``core/communication_object.dart`` (blutter disassembly):
|
||||
|
||||
* ``toJson`` inserts keys in the exact order
|
||||
``cmdType, equipNo, type, timeStamp, snno, len, crc`` and then appends
|
||||
``dataArea`` **only when the payload is present and non-empty**.
|
||||
There is **no** ``data`` key on the wire.
|
||||
* The constructor takes ``(cmdType, equipNo, {data})``; it zeroes the
|
||||
``type`` / ``snno`` fields, stamps ``timeStamp`` with the current time in
|
||||
**whole seconds**, and routes ``data`` through the ``data=`` setter, which
|
||||
recomputes ``len`` (UTF-8 byte length of the compact JSON of ``data``)
|
||||
and ``crc`` (``Crc32.calculate`` rendered as **uppercase** hex, no
|
||||
padding). Null/empty data yields ``len == 0``, ``crc == ""`` and no
|
||||
``dataArea`` key.
|
||||
* ``sendObject`` (socket path) overwrites ``snno`` with ``increaseSN()`` —
|
||||
a 1-based counter that wraps at 9999 — before serialising.
|
||||
|
||||
See ``docs/RE_DART_FINDINGS.md`` §3.
|
||||
|
||||
Confirmed:
|
||||
* ``cmdType`` = :class:`CommunicationCmd` raw value
|
||||
* ``equipNo`` = device serial number
|
||||
* ``type`` = :class:`CommunicationOperate` raw value
|
||||
* compact JSON (no whitespace) — matches Dart ``json.encode``
|
||||
|
||||
Candidates (not yet confirmed byte-for-byte, see Open items):
|
||||
* defaults of ``crc`` / ``dataArea`` — the pool contains the string constant
|
||||
``"00000000"`` used as a placeholder default;
|
||||
* ``len`` semantics (byte length of the payload).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -24,8 +26,9 @@ import time
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from .commands import CommunicationCmd, CommunicationOperate
|
||||
from .crc import crc32
|
||||
|
||||
#: Exact key order used by the app's ``toJson``.
|
||||
#: Exact key order used by the app's ``toJson`` (``dataArea`` conditional).
|
||||
KEY_ORDER = (
|
||||
"cmdType",
|
||||
"equipNo",
|
||||
@@ -35,13 +38,50 @@ KEY_ORDER = (
|
||||
"len",
|
||||
"crc",
|
||||
"dataArea",
|
||||
"data",
|
||||
)
|
||||
|
||||
#: Placeholder default observed in the object pool.
|
||||
#: First sequence number issued by ``increaseSN`` (counter starts at 0).
|
||||
SNNO_MIN = 1
|
||||
#: Counter wraps back to 1 past this value (``> 9999`` check in ``increaseSN``).
|
||||
SNNO_MAX = 9999
|
||||
|
||||
#: Serial placeholder the ctor substitutes for an empty ``equipNo``.
|
||||
PLACEHOLDER = "00000000"
|
||||
|
||||
|
||||
def _now_seconds() -> int:
|
||||
"""``DateTime`` micros ``~/ 1000 ~/ 1000`` — whole seconds."""
|
||||
return int(time.time())
|
||||
|
||||
|
||||
def _compact_json(value: Any) -> str:
|
||||
"""Dart ``jsonEncode`` equivalent for our ASCII payloads (compact)."""
|
||||
return json.dumps(value, separators=(",", ":"))
|
||||
|
||||
|
||||
def _crc_of_data(data: Dict[str, Any]) -> str:
|
||||
"""``calculateCrc``: CRC-32 of the compact JSON, UPPERCASE hex, unpadded."""
|
||||
return format(crc32(_compact_json(data).encode("utf-8")), "X")
|
||||
|
||||
|
||||
def _len_of_data(data: Dict[str, Any]) -> int:
|
||||
"""``calculateDataLength``: UTF-8 byte length of the compact JSON."""
|
||||
return len(_compact_json(data).encode("utf-8"))
|
||||
|
||||
|
||||
class SequenceNumber:
|
||||
"""``BaseCommunicationManager`` 1..9999 counter (``increaseSN``)."""
|
||||
|
||||
def __init__(self, start: int = 0) -> None:
|
||||
self._value = int(start)
|
||||
|
||||
def next(self) -> int:
|
||||
self._value += 1
|
||||
if self._value > SNNO_MAX:
|
||||
self._value = SNNO_MIN
|
||||
return self._value
|
||||
|
||||
|
||||
class CommunicationObject:
|
||||
"""One request/response envelope travelling over any transport."""
|
||||
|
||||
@@ -50,28 +90,38 @@ class CommunicationObject:
|
||||
cmd_type: int,
|
||||
equip_no: str,
|
||||
op_type: int = CommunicationOperate.QUERY,
|
||||
snno: Optional[str] = None,
|
||||
snno: int = 0,
|
||||
time_stamp: Optional[int] = None,
|
||||
length: Optional[int] = None,
|
||||
crc: str = PLACEHOLDER,
|
||||
data_area: str = PLACEHOLDER,
|
||||
data: str = "",
|
||||
data: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
self.cmd_type = int(cmd_type)
|
||||
self.equip_no = str(equip_no)
|
||||
# Empty serial falls back to the "00000000" placeholder (ctor check).
|
||||
self.equip_no = str(equip_no) if equip_no else PLACEHOLDER
|
||||
self.op_type = int(op_type)
|
||||
self.snno = snno if snno is not None else str(equip_no)
|
||||
self.time_stamp = int(time_stamp if time_stamp is not None
|
||||
else int(time.time() * 1000))
|
||||
self.length = int(length) if length is not None else 0
|
||||
self.crc = str(crc)
|
||||
self.data_area = str(data_area)
|
||||
self.data = str(data)
|
||||
self.snno = int(snno)
|
||||
self.time_stamp = int(
|
||||
time_stamp if time_stamp is not None else _now_seconds()
|
||||
)
|
||||
self.data: Optional[Dict[str, Any]] = None
|
||||
self.length = 0
|
||||
self.crc = ""
|
||||
self.set_data(data)
|
||||
|
||||
# ------------------------------------------------------------------ data
|
||||
def set_data(self, data: Optional[Dict[str, Any]]) -> None:
|
||||
"""``data=`` setter: stores the payload, recomputes ``len``/``crc``."""
|
||||
self.data = dict(data) if data is not None else None
|
||||
if self.data:
|
||||
self.length = _len_of_data(self.data)
|
||||
self.crc = _crc_of_data(self.data)
|
||||
else:
|
||||
self.length = 0
|
||||
self.crc = ""
|
||||
|
||||
# ------------------------------------------------------------------ json
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Fields in the app's exact key order."""
|
||||
return {
|
||||
"""Fields in the app's exact key order (``dataArea`` conditional)."""
|
||||
ordered: Dict[str, Any] = {
|
||||
"cmdType": self.cmd_type,
|
||||
"equipNo": self.equip_no,
|
||||
"type": self.op_type,
|
||||
@@ -79,28 +129,34 @@ class CommunicationObject:
|
||||
"snno": self.snno,
|
||||
"len": self.length,
|
||||
"crc": self.crc,
|
||||
"dataArea": self.data_area,
|
||||
"data": self.data,
|
||||
}
|
||||
if self.data:
|
||||
ordered["dataArea"] = self.data
|
||||
return ordered
|
||||
|
||||
def to_json_string(self) -> str:
|
||||
"""Compact JSON string — the input of ``encrypt_object``."""
|
||||
return json.dumps(self.to_dict(), separators=(",", ":"))
|
||||
return _compact_json(self.to_dict())
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, raw: Dict[str, Any]) -> "CommunicationObject":
|
||||
"""Tolerant parse (missing keys fall back to placeholders)."""
|
||||
return cls(
|
||||
"""Tolerant parse (missing keys fall back to ctor defaults)."""
|
||||
obj = cls(
|
||||
cmd_type=raw.get("cmdType", 0),
|
||||
equip_no=raw.get("equipNo", ""),
|
||||
equip_no=raw.get("equipNo", "") or "",
|
||||
op_type=raw.get("type", 0),
|
||||
snno=raw.get("snno") if raw.get("snno") is not None else None,
|
||||
snno=raw.get("snno", 0) or 0,
|
||||
time_stamp=raw.get("timeStamp"),
|
||||
length=raw.get("len"),
|
||||
crc=raw.get("crc", PLACEHOLDER),
|
||||
data_area=raw.get("dataArea", PLACEHOLDER),
|
||||
data=raw.get("data", ""),
|
||||
data=None,
|
||||
)
|
||||
data_area = raw.get("dataArea")
|
||||
if isinstance(data_area, dict):
|
||||
obj.set_data(data_area)
|
||||
else:
|
||||
# Preserve observed len/crc when no payload key is present.
|
||||
obj.length = int(raw.get("len", 0) or 0)
|
||||
obj.crc = str(raw.get("crc", ""))
|
||||
return obj
|
||||
|
||||
@classmethod
|
||||
def from_json_string(cls, s: str) -> "CommunicationObject":
|
||||
@@ -109,23 +165,29 @@ class CommunicationObject:
|
||||
def __repr__(self) -> str: # pragma: no cover - debugging aid
|
||||
return (
|
||||
f"CommunicationObject(cmdType={self.cmd_type:#04x}, "
|
||||
f"equipNo={self.equip_no!r}, type={self.op_type})"
|
||||
f"equipNo={self.equip_no!r}, type={self.op_type}, "
|
||||
f"snno={self.snno})"
|
||||
)
|
||||
|
||||
|
||||
def direct_connect_request(
|
||||
cmd: CommunicationCmd,
|
||||
equip_no: str,
|
||||
data: str = "",
|
||||
data: Optional[Dict[str, Any]] = None,
|
||||
snno: int = 0,
|
||||
) -> CommunicationObject:
|
||||
"""Build a *direct-connect* query envelope.
|
||||
|
||||
Mirrors the Dart ``_query*ByDirectConnect`` family
|
||||
(``docs/RE_DART_FINDINGS.md`` — relevant files / RPC layer).
|
||||
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.
|
||||
"""
|
||||
return CommunicationObject(
|
||||
cmd_type=int(cmd),
|
||||
equip_no=equip_no,
|
||||
op_type=CommunicationOperate.QUERY,
|
||||
snno=snno,
|
||||
data=data,
|
||||
)
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from .crc import crc16_hex_string, crc16_modbus, int16_to_le_bytes, le_bytes_to_int16
|
||||
@@ -130,30 +131,51 @@ def magicEquals(a: bytes, b: bytes) -> bool:
|
||||
|
||||
# --------------------------------------------------------------------- socket
|
||||
def frame_socket_payload(payload: bytes) -> bytes:
|
||||
"""Length-prefixed local-socket frame (candidate — §8).
|
||||
"""Local-socket frame: the app sends the encrypted envelope **raw**.
|
||||
|
||||
``int16LE(len(payload)) || payload`` — the app's socket layer frames with
|
||||
the same little-endian int16 helpers.
|
||||
``ClientSocket.send`` forwards its argument straight to
|
||||
``dart:io _Socket.add`` with no length prefix, markers, or handshake
|
||||
(see §8); ``SocketManager._sendData`` only clears the receive buffer
|
||||
afterwards. This helper is therefore the identity (kept so call sites
|
||||
read like the app).
|
||||
"""
|
||||
if len(payload) > 0xFFFF:
|
||||
raise ValueError("socket payload exceeds the int16 length field")
|
||||
return int16_to_le_bytes(len(payload)) + bytes(payload)
|
||||
return bytes(payload)
|
||||
|
||||
|
||||
class SocketFrameDecoder:
|
||||
"""Reassembles length-prefixed socket frames from a byte stream."""
|
||||
"""Accumulating socket receiver (``SocketManager._onSocketReceiveData``).
|
||||
|
||||
Each incoming chunk is appended to a growable buffer and ``decrypt_data``
|
||||
is attempted over the whole buffer (inside try/catch in the app); a
|
||||
non-empty decoded string that parses as JSON is one complete reply, at
|
||||
which point the buffer is cleared. Use :meth:`feed` for raw chunks and
|
||||
:meth:`feed_json` when you want parsed envelopes back.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._buf = bytearray()
|
||||
|
||||
def feed(self, data: bytes) -> List[bytes]:
|
||||
"""Append *data*; return a list of complete payloads (may be empty)."""
|
||||
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
|
||||
|
||||
self._buf.extend(data)
|
||||
out: List[bytes] = []
|
||||
while len(self._buf) >= 2:
|
||||
length = le_bytes_to_int16(bytes(self._buf[:2]))
|
||||
if len(self._buf) < 2 + length:
|
||||
break
|
||||
out.append(bytes(self._buf[2:2 + length]))
|
||||
del self._buf[2 + length]
|
||||
decoded = decrypt_data(bytes(self._buf))
|
||||
if not decoded:
|
||||
return []
|
||||
try:
|
||||
json.loads(decoded)
|
||||
except ValueError:
|
||||
return []
|
||||
del self._buf[:]
|
||||
return [decoded]
|
||||
|
||||
# Backwards-compatible alias for the old length-prefixed probing API.
|
||||
def feed_frames(self, data: bytes) -> List[bytes]:
|
||||
"""Append *data*; return complete **raw wire** buffers (legacy)."""
|
||||
self._buf.extend(data)
|
||||
if not self._buf:
|
||||
return []
|
||||
out = [bytes(self._buf)]
|
||||
del self._buf[:]
|
||||
return out
|
||||
Reference in new issue
Block a user