"""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, socket_cmd_type 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). The wire ``cmdType`` is the *socket-transport* id (``CommunicationCmd::cmd`` for socket mode — e.g. ``DEVICE_APOWER_INFO`` → ``1307``, verified live). On socket mode the factory always seeds the payload with ``"opt"`` (the operation type, ``0`` for query) and merges the caller's arguments after it — e.g. the app's ``queryAPowerData`` sends ``dataArea = {"opt": 0, "update": 2}``. Note ``snno`` is assigned by ``sendObject`` (see :class:`SequenceNumber`); pass the issued value when re-serialising. """ payload: Dict[str, Any] = {"opt": int(CommunicationOperate.QUERY)} if data: payload.update(data) return CommunicationObject( cmd_type=socket_cmd_type(cmd), equip_no=equip_no, op_type=CommunicationOperate.QUERY, snno=snno, data=payload, )