250 lines
8.7 KiB
Python
250 lines
8.7 KiB
Python
"""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
|