"""Frame builders for the two byte transports. * **BLE** — ``communication_center/bluetooth/bluetooth_frame.dart`` (see ``docs/RE_DART_FINDINGS.md`` §9). On-air layout, settled from ``createBluetoothFrame``:: magic[2] ++ crc[2] ++ int16LE(totalLen) ++ int16LE(sn) ++ content * ``totalLen = 6 + len(content)`` (2 crc-slot + 2 len + 2 sn bytes). * CRC-16/MODBUS over the buffer with ``00 00`` in the crc slots, rendered as the app's swapped-lowercase 4-hex string; each hex *pair* is then run through ``int.parse(pair, radix: 32)`` (literal ``0x20`` — quirky but explicit) and written into slots 2..3. * Magics: ``5A A5`` data (default), ``4A A4`` ack, ``2A A2`` MTU announce. * The encrypted envelope (same bytes as the socket path) is split into MTU-sized slices when longer than the negotiated MTU; every slice goes out as its own frame with its own ``_increaseBluetoothSN()`` value. The receiver dedups by sn, accumulates ``content`` parts and runs the same ``decryptData`` over the concatenation. * **Local TCP socket** — raw encrypted bytes, no framing (see §8). """ 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 #: First two bytes of a received **ACK** frame. MAGIC_ACK = b"\x4a\xa4" #: First two bytes of a **data** frame (receiving one triggers the app's ack). MAGIC_DATA = b"\x5a\xa5" #: Magic of the MTU-announce frame (``_sendMtu`` sends ``mtuToData()``). MAGIC_MTU = b"\x2a\xa2" MAGIC_NAMES = { MAGIC_ACK[0:2]: "ack", MAGIC_DATA[0:2]: "data", MAGIC_MTU[0:2]: "mtu", } #: On-air header length: magic(2) + crc(2) + totalLen(2) + sn(2). BLE_HEADER_LEN = 8 def _ble_crc_bytes(body_with_zero_slots: bytes) -> bytes: """CRC slot bytes the way ``createBluetoothFrame`` derives them. CRC-16/MODBUS over the frame with ``00 00`` slots, swapped-lowercase 4-hex string, then ``int.parse(pair, radix: 32)`` per pair (the disassembly materialises ``0x20`` for the radix). Values can exceed one byte in theory; the GATT write path must narrow them, so mask here (assumption — confirm against a capture). """ hexstr = crc16_hex_string(body_with_zero_slots) return bytes(int(hexstr[i:i + 2], 32) & 0xFF for i in (0, 2)) def create_ble_frame( content: bytes, sn: int, magic: bytes = MAGIC_DATA, ) -> bytes: """``createBluetoothFrame(content, magic, sn)`` — full on-air frame. Layout:: magic ++ crc[2] ++ int16LE(6 + len(content)) ++ int16LE(sn) ++ content """ total = 6 + len(content) buf = bytearray( bytes(magic[:2]) + b"\x00\x00" + int16_to_le_bytes(total) + int16_to_le_bytes(sn) + bytes(content) ) buf[2:4] = _ble_crc_bytes(bytes(buf)) return bytes(buf) def build_ble_data_frame(content: bytes, sn: int) -> bytes: """Full on-air data frame (magic ``0x5A 0xA5``).""" return create_ble_frame(content, sn, MAGIC_DATA) def build_ble_ack_frame(content: bytes, sn: int) -> bytes: """Full on-air ACK frame (magic ``0x4A 0xA4``).""" return create_ble_frame(content, sn, MAGIC_ACK) def magic_in_data(data: bytes) -> Optional[str]: """Return ``"ack"`` / ``"data"`` / ``"mtu"`` if known; else ``None``.""" return MAGIC_NAMES.get(bytes(data[:2])) def ble_frame_sn(data: bytes) -> int: """``sn`` field of a frame (int16LE at offset 6).""" return le_bytes_to_int16(data[6:8]) def ble_frame_total_len(data: bytes) -> int: """``totalLen`` field of a frame (int16LE at offset 4).""" return le_bytes_to_int16(data[4:6]) # In createBluetoothFrame the CRC placeholder sits at byte offsets 2..5 of the # frame body (content ++ [00 00 00 00] ++ int16LE(totalLen) ++ int16LE(value) ++ sn). _CRC_SLOTS = slice(2, 4) _FRAME_TAIL_SIZE = 4 + 2 + 2 # crc(4) + totalLen(2) + value(2) def create_ble_frame(content: bytes, value: int, sn: str) -> bytes: """``createBluetoothFrame(content, ?, value)`` — BLE frame *body* (no magic). Layout:: content ++ [crcHi crcLo 00 00] ++ int16LE(totalLen) ++ int16LE(value) ++ sn * ``totalLen = len(content) + 6 + len(sn)`` (Dart source). * The CRC is computed over the buffer with a ``00 00 00 00`` placeholder; ``Crc16::calculate`` yields a 4-hex-digit (hi/lo-swapped) string whose two bytes are written into placeholder indices 2..3. """ sn_bytes = sn.encode("utf-8") total_len = len(content) + 6 + len(sn_bytes) buf = bytearray( content + b"\x00\x00\x00\x00" + int16_to_le_bytes(total_len) + int16_to_le_bytes(value) + sn_bytes ) crc_hex = crc16_hex_string(bytes(buf)) buf[_CRC_SLOTS] = bytes.fromhex(crc_hex) return bytes(buf) def build_ble_data_frame(content: bytes, value: int, sn: str) -> bytes: """Full on-air data frame: ``0x5A 0xA5`` magic + frame body.""" return MAGIC_DATA + create_ble_frame(content, value, sn) def build_ble_ack_frame(content: bytes, value: int, sn: str) -> bytes: """Full on-air ACK frame: ``0x4A 0xA4`` magic + frame body. The exact ACK ``content``/``value`` the app emits are not confirmed — pass whatever the capture shows (typically the SN or an empty body). """ return MAGIC_ACK + create_ble_frame(content, value, sn) def magic_in_data(data: bytes) -> Optional[str]: """Return ``"ack"`` / ``"data"`` if *data* starts with a known magic.""" return MAGIC_NAMES.get(bytes(data[:2])) def sn_in_data(data: bytes, sn: int) -> bool: """True if the frame's ``sn`` field equals *sn*.""" if len(data) < BLE_HEADER_LEN: return False return ble_frame_sn(data) == int(sn) def content_in_data(data: bytes) -> Optional[bytes]: """Frame ``content`` (everything after the 8-byte header).""" if len(data) < BLE_HEADER_LEN: return None return bytes(data[BLE_HEADER_LEN:]) def verify_frame_crc(data: bytes) -> bool: """Recompute the CRC-16 over the frame (zeroed slots) and compare.""" if len(data) < BLE_HEADER_LEN: return False candidate = bytearray(data) candidate[2:4] = b"\x00\x00" return _ble_crc_bytes(bytes(candidate)) == bytes(data[2:4]) def chunk_for_mtu(encrypted: bytes, mtu: int = 20) -> List[bytes]: """Split the encrypted envelope into MTU-sized slices. Mirrors the ``sendObject`` over-length branch (``ceil(len/mtu)`` slices via ``sublist``): every slice is sent as its own BLE frame with its own ``_increaseBluetoothSN()`` value (see :func:`create_ble_frame`). """ if mtu <= 0: raise ValueError("mtu must be positive") return [bytes(encrypted[i:i + mtu]) for i in range(0, len(encrypted), mtu)] or [b""] def magicEquals(a: bytes, b: bytes) -> bool: """``magicEquals`` — first two bytes equal.""" return bytes(a[:2]) == bytes(b[:2]) # --------------------------------------------------------------------- socket def frame_socket_payload(payload: bytes) -> bytes: """Local-socket frame: the app sends the encrypted envelope **raw**. ``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). """ return bytes(payload) class SocketFrameDecoder: """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[str]: """Append *data*; return a list of complete decoded JSON strings.""" from .crypto import decrypt_wire # local import: cheap, no cycles self._buf.extend(data) try: decoded = decrypt_wire(bytes(self._buf)) except ValueError: 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