- 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
194 lines
6.4 KiB
Python
194 lines
6.4 KiB
Python
"""Wire envelope — ``CommunicationObject``.
|
|
|
|
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.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
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`` (``dataArea`` conditional).
|
|
KEY_ORDER = (
|
|
"cmdType",
|
|
"equipNo",
|
|
"type",
|
|
"timeStamp",
|
|
"snno",
|
|
"len",
|
|
"crc",
|
|
"dataArea",
|
|
)
|
|
|
|
#: 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."""
|
|
|
|
def __init__(
|
|
self,
|
|
cmd_type: int,
|
|
equip_no: str,
|
|
op_type: int = CommunicationOperate.QUERY,
|
|
snno: int = 0,
|
|
time_stamp: Optional[int] = None,
|
|
data: Optional[Dict[str, Any]] = None,
|
|
) -> None:
|
|
self.cmd_type = int(cmd_type)
|
|
# 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 = 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 (``dataArea`` conditional)."""
|
|
ordered: Dict[str, Any] = {
|
|
"cmdType": self.cmd_type,
|
|
"equipNo": self.equip_no,
|
|
"type": self.op_type,
|
|
"timeStamp": self.time_stamp,
|
|
"snno": self.snno,
|
|
"len": self.length,
|
|
"crc": self.crc,
|
|
}
|
|
if self.data:
|
|
ordered["dataArea"] = self.data
|
|
return ordered
|
|
|
|
def to_json_string(self) -> str:
|
|
"""Compact JSON string — the input of ``encrypt_object``."""
|
|
return _compact_json(self.to_dict())
|
|
|
|
@classmethod
|
|
def from_dict(cls, raw: Dict[str, Any]) -> "CommunicationObject":
|
|
"""Tolerant parse (missing keys fall back to ctor defaults)."""
|
|
obj = cls(
|
|
cmd_type=raw.get("cmdType", 0),
|
|
equip_no=raw.get("equipNo", "") or "",
|
|
op_type=raw.get("type", 0),
|
|
snno=raw.get("snno", 0) or 0,
|
|
time_stamp=raw.get("timeStamp"),
|
|
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":
|
|
return cls.from_dict(json.loads(s))
|
|
|
|
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"snno={self.snno})"
|
|
)
|
|
|
|
|
|
def direct_connect_request(
|
|
cmd: CommunicationCmd,
|
|
equip_no: str,
|
|
data: Optional[Dict[str, Any]] = None,
|
|
snno: int = 0,
|
|
) -> CommunicationObject:
|
|
"""Build a *direct-connect* query envelope.
|
|
|
|
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,
|
|
)
|