Socket-transport cmd space (off_14 ids) from live capture: 1101/1307/1705/1707 + host_probe3
This commit is contained in:
1 parent
2cdc274fe3
commit
213a0518d6
7 files changed
+401
-60
No files matched your search
@@ -4,7 +4,13 @@ Everything here is hardware-independent and covered by unit tests. See
|
||||
``docs/RE_DART_FINDINGS.md`` for the reverse-engineering evidence.
|
||||
"""
|
||||
|
||||
from .commands import CommunicationCmd, CommunicationMode, CommunicationOperate
|
||||
from .commands import (
|
||||
CommunicationCmd,
|
||||
CommunicationMode,
|
||||
CommunicationOperate,
|
||||
SOCKET_CMD_TYPE,
|
||||
socket_cmd_type,
|
||||
)
|
||||
from .crc import (
|
||||
CRC16_INIT,
|
||||
CRC16_POLY,
|
||||
@@ -35,8 +41,12 @@ from .envelope import (
|
||||
direct_connect_request,
|
||||
)
|
||||
from .framing import (
|
||||
BLE_HEADER_LEN,
|
||||
MAGIC_ACK,
|
||||
MAGIC_DATA,
|
||||
MAGIC_MTU,
|
||||
ble_frame_sn,
|
||||
ble_frame_total_len,
|
||||
build_ble_ack_frame,
|
||||
build_ble_data_frame,
|
||||
chunk_for_mtu,
|
||||
@@ -54,6 +64,8 @@ __all__ = [
|
||||
"CommunicationCmd",
|
||||
"CommunicationMode",
|
||||
"CommunicationOperate",
|
||||
"SOCKET_CMD_TYPE",
|
||||
"socket_cmd_type",
|
||||
"CRC16_INIT",
|
||||
"CRC16_POLY",
|
||||
"crc16_modbus",
|
||||
|
||||
@@ -84,3 +84,73 @@ class CommunicationCmd(IntEnum):
|
||||
BREAKER_COM_STATE = 0x32
|
||||
BREAKER_LOAD_STATUS = 0x33
|
||||
BREAKER_IDENTIFY_ME = 0x34
|
||||
|
||||
|
||||
#: ``cmdType`` values on the socket/Bluetooth/local transports.
|
||||
#:
|
||||
#: ``CommunicationCmd::cmd(cmd, mode)`` returns the *second* id field
|
||||
#: (``off_14`` in the pool) for ``socket``/``bluetooth``/``local`` modes and
|
||||
#: throws for ``http`` — verified live: a PCAPdroid capture of the app shows
|
||||
#: ``{"cmdType":1101, ...}`` (``QUERY_DEVICE_INFO`` here, ``off_8`` ``0x00``)
|
||||
#: with the S1-plaintext-first framing (see ``docs/RE_DART_FINDINGS.md``
|
||||
#: §5). The ``off_8`` values above are *not* used on the wire for these
|
||||
#: transports. Entries mapped to ``None`` have no pool id (``0xFFFF…``).
|
||||
SOCKET_CMD_TYPE = {
|
||||
CommunicationCmd.QUERY_DEVICE_INFO: 0x44D,
|
||||
CommunicationCmd.NETWORK_INFO: 0x45D,
|
||||
CommunicationCmd.RESOURCE_ADDRESS: 0x4B5,
|
||||
CommunicationCmd.SUNSPEC_MODBUS: 0x4B5,
|
||||
CommunicationCmd.NETWORK_SWITCH: 0x45F,
|
||||
CommunicationCmd.FEEDING_GRID_POWER: 0x6A5,
|
||||
CommunicationCmd.MODE: 0x57B,
|
||||
CommunicationCmd.MODE_V2: 0x6BD,
|
||||
CommunicationCmd.MODE_SETTING_V2: 0x6BF,
|
||||
CommunicationCmd.SOLAR_INFO: 0x76F,
|
||||
CommunicationCmd.ENERGY_MANAGE_INFO: 0x4B3,
|
||||
CommunicationCmd.GENERATOR_INFO: 0x76D,
|
||||
CommunicationCmd.BLUETOOTH_HEARTBEAT: 0x7D7,
|
||||
CommunicationCmd.SCAN_WIFI_LIST: 0x455,
|
||||
CommunicationCmd.CONNECT_WIFI: 0x457,
|
||||
CommunicationCmd.CONNECTION_STATUS: 0x459,
|
||||
CommunicationCmd.NETWORK_CHANNEL_STATUS: 0x46D,
|
||||
CommunicationCmd.NETWORK_CHANNEL: 0x46B,
|
||||
CommunicationCmd.PAIR_POWER: 0x44F,
|
||||
CommunicationCmd.PROCESS_PAIR_POWER: 0x451,
|
||||
CommunicationCmd.CHECK_BLUETOOTH_PASSWORD: 0x7D5,
|
||||
CommunicationCmd.GO_OFF_GRID_STATUS: 0x6BB,
|
||||
CommunicationCmd.A_POWER_LED_SWITCH: None,
|
||||
CommunicationCmd.DEVICE_DETAIL_INFO: 0x515,
|
||||
CommunicationCmd.DEVICE_APOWER_INFO: 0x51B,
|
||||
CommunicationCmd.DEVICE_ALARMS: 0x725,
|
||||
CommunicationCmd.PE: 0x6A7,
|
||||
CommunicationCmd.BMS: 0x6A9,
|
||||
CommunicationCmd.EMS: 0x6AB,
|
||||
CommunicationCmd.MPPT: 0x6B5,
|
||||
CommunicationCmd.A_HUB: 0x771,
|
||||
CommunicationCmd.CT_TEST_REVERSE: None,
|
||||
CommunicationCmd.DEVICE_DATA: None,
|
||||
CommunicationCmd.LOAD_STATUS: 0x583,
|
||||
CommunicationCmd.LOAD_STATUS_2: 0x587,
|
||||
CommunicationCmd.SMART_LOAD: 0x581,
|
||||
CommunicationCmd.SMART_LOAD_2: 0x585,
|
||||
CommunicationCmd.REBOOT_DEVICE: 0x6B9,
|
||||
CommunicationCmd.DEBUG_MODE: 0x71D,
|
||||
CommunicationCmd.POWER_ON: 0x71F,
|
||||
CommunicationCmd.CHARGE_DEVICE: 0x721,
|
||||
CommunicationCmd.CLEAR_FAULT: 0x6AD,
|
||||
CommunicationCmd.DEVICE_ADDRESS_INFO: 0x4B1,
|
||||
CommunicationCmd.OVERLOAD_CUT_OFF: 0x6A5,
|
||||
CommunicationCmd.WIFI_FREQUECY: 0x46F,
|
||||
CommunicationCmd.BREAKER_COMMISSION_NOTIFY: None,
|
||||
CommunicationCmd.BREAKER_COM_STATE: None,
|
||||
CommunicationCmd.BREAKER_LOAD_STATUS: 0x471,
|
||||
CommunicationCmd.BREAKER_IDENTIFY_ME: 0x473,
|
||||
}
|
||||
|
||||
|
||||
def socket_cmd_type(cmd: CommunicationCmd) -> int:
|
||||
"""Wire ``cmdType`` for *cmd* on the socket transport (``cmd()``)."""
|
||||
value = SOCKET_CMD_TYPE[cmd]
|
||||
if value is None:
|
||||
raise KeyError(f"{cmd} has no socket-transport command id")
|
||||
return int(value)
|
||||
@@ -25,7 +25,7 @@ import json
|
||||
import time
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from .commands import CommunicationCmd, CommunicationOperate
|
||||
from .commands import CommunicationCmd, CommunicationOperate, socket_cmd_type
|
||||
from .crc import crc32
|
||||
|
||||
#: Exact key order used by the app's ``toJson`` (``dataArea`` conditional).
|
||||
@@ -180,12 +180,14 @@ def direct_connect_request(
|
||||
|
||||
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.
|
||||
(``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). Note ``snno``
|
||||
is assigned by ``sendObject`` (see :class:`SequenceNumber`); pass the
|
||||
issued value when re-serialising.
|
||||
"""
|
||||
return CommunicationObject(
|
||||
cmd_type=int(cmd),
|
||||
cmd_type=socket_cmd_type(cmd),
|
||||
equip_no=equip_no,
|
||||
op_type=CommunicationOperate.QUERY,
|
||||
snno=snno,
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
"""Frame builders for the two byte transports.
|
||||
|
||||
* **BLE** — ``communication_center/bluetooth/bluetooth_frame.dart`` (see
|
||||
``docs/RE_DART_FINDINGS.md`` §9). Frame layout and magics are confirmed;
|
||||
the *exact* on-air order of the magic relative to the frame and the MTU
|
||||
continuation scheme still need a live capture.
|
||||
* **Local TCP socket** — ``communication_center/socket`` uses ``int16ToBytes``
|
||||
/ ``bytesToInt16`` (little-endian) framing (see §8). A length-prefixed
|
||||
frame is the working candidate; adjust once a capture is available.
|
||||
``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
|
||||
@@ -20,12 +31,79 @@ from .crc import crc16_hex_string, crc16_modbus, int16_to_le_bytes, le_bytes_to_
|
||||
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)
|
||||
@@ -77,51 +155,40 @@ def magic_in_data(data: bytes) -> Optional[str]:
|
||||
return MAGIC_NAMES.get(bytes(data[:2]))
|
||||
|
||||
|
||||
def sn_in_data(data: bytes, sn: str) -> bool:
|
||||
"""True if the frame (after any magic) ends with the device SN."""
|
||||
sn_bytes = sn.encode("utf-8")
|
||||
return data.endswith(sn_bytes)
|
||||
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, sn: str) -> Optional[bytes]:
|
||||
"""Extract the ``content`` bytes of a data/ack frame.
|
||||
|
||||
Layout after the 2-byte magic is fixed-size except for ``content``, so::
|
||||
|
||||
content = frame[2 : len(frame) - len(sn) - FRAME_TAIL_SIZE]
|
||||
"""
|
||||
body = data[2:] if len(data) >= 2 else data
|
||||
sn_bytes = sn.encode("utf-8")
|
||||
if not body.endswith(sn_bytes):
|
||||
def content_in_data(data: bytes) -> Optional[bytes]:
|
||||
"""Frame ``content`` (everything after the 8-byte header)."""
|
||||
if len(data) < BLE_HEADER_LEN:
|
||||
return None
|
||||
end = len(body) - len(sn_bytes) - _FRAME_TAIL_SIZE
|
||||
if end < 2: # need at least the 4-byte crc slots
|
||||
return None
|
||||
return body[2:end]
|
||||
return bytes(data[BLE_HEADER_LEN:])
|
||||
|
||||
|
||||
def verify_frame_crc(data: bytes) -> bool:
|
||||
"""Recompute the CRC-16 over the frame body and compare slots 2..3."""
|
||||
body = data[2:] if len(data) >= 2 else data
|
||||
if len(body) < 4:
|
||||
"""Recompute the CRC-16 over the frame (zeroed slots) and compare."""
|
||||
if len(data) < BLE_HEADER_LEN:
|
||||
return False
|
||||
candidate = body[:]
|
||||
candidate[2:4] = b"\x00\x00\x00\x00"
|
||||
return bytes.fromhex(crc16_hex_string(bytes(candidate))) == body[2:4]
|
||||
candidate = bytearray(data)
|
||||
candidate[2:4] = b"\x00\x00"
|
||||
return _ble_crc_bytes(bytes(candidate)) == bytes(data[2:4])
|
||||
|
||||
|
||||
def chunk_for_mtu(frame: bytes, mtu: int = 20) -> List[bytes]:
|
||||
"""Split a frame for GATT writes.
|
||||
def chunk_for_mtu(encrypted: bytes, mtu: int = 20) -> List[bytes]:
|
||||
"""Split the encrypted envelope into MTU-sized slices.
|
||||
|
||||
First chunk carries the 2-byte magic; the rest are raw continuation
|
||||
chunks. The app's ``mtuToData`` does something similar — the exact
|
||||
continuation framing (length headers? re-magic?) needs confirmation.
|
||||
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 <= 2:
|
||||
raise ValueError("mtu must exceed the 2-byte magic")
|
||||
if len(frame) <= mtu:
|
||||
return [frame]
|
||||
return [frame[i:i + mtu] for i in range(0, len(frame), mtu)]
|
||||
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:
|
||||
|
||||
Reference in new issue
Block a user