Add recovered protocol stack, Wi-Fi hotspot join, and RE findings

Reconstruct the app's Dart-layer behaviour (blutter over libapp.so,
jadx over base.apk — see docs/RE_DART_FINDINGS.md) as working Python,
replacing the library stubs with a real local-connect stack.

protocol/ (new package, hardware-independent):
- commands: CommunicationMode / CommunicationOperate / CommunicationCmd
  enums with the raw values recovered from the Dart object pool
- envelope: CommunicationObject with the app's exact JSON key order
- crypto: SN-derived secret key and the position-dependent additive
  cipher (encrypt/decrypt; radix 16 default, 32 documented alternate)
- crc: CRC-16/MODBUS with the app's hi/lo swap + 4-hex string form,
  CRC32 envelope check, little-endian int16 helpers
- framing: BLE frame build/parse (0x5AA5 data / 0x4AA4 ACK magics,
  CRC slots, MTU chunking) and local-socket int16LE length framing
  plus a streaming SocketFrameDecoder

wifi.py (rewritten):
- derive_hotspot_credentials(): the AP_<sn> SSID/password rules
  (20-char SN branch, full-SN fallback with the app's warning log)
- WifiBackend abstraction: NmcliWifiBackend for Linux hosts via
  nmcli, NoOpWifiBackend for radio-less containers
- WifiManager keeps the original stub method names for API compat
  and adds join_device_hotspot(sn) / leave_device_hotspot() / local_ip()

device_local.py (new): LocalDeviceClient for the mode==local path —
the /terminal/* HTTP endpoint family (hotspot info, aGate info,
overall info, MQTT relay, ...) and direct-connect socket RPC
(envelope -> cipher -> length frame -> decrypt -> JSON) with
convenience query_* RPCs.

docs/RE_DART_FINDINGS.md (new): the full findings — transport
selector, wire envelope, crypto, CRCs, MQTT (AWS IoT + Cognito),
socket, BLE + ESP provisioning, hotspot credential derivation,
local/cloud HTTP layers, and the Java glue — plus the open items;
README now links to it.

Unconfirmed candidates (local port 18000, cipher key radix, BLE
field order, X-Device-* header values) are flagged in the docs and
kept configurable in code.
This commit is contained in:
kfj001 committed 2026-10-13 00:18:23 +00:00
1 parent c2d261fb34
commit a87dbe200e
10 files changed
+1436 -19

No files matched your search

@@ -0,0 +1,78 @@
"""Pure-protocol building blocks recovered from the app's Dart AOT snapshot.
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 .crc import (
CRC16_INIT,
CRC16_POLY,
crc16_hex_string,
crc16_modbus,
crc32,
int16_to_le_bytes,
le_bytes_to_int16,
)
from .crypto import (
ALT_KEY_RADIX,
DEFAULT_KEY_RADIX,
decrypt_object,
encrypt_object,
generate_secret_key,
header_len_for,
split_envelope,
)
from .envelope import KEY_ORDER, PLACEHOLDER, CommunicationObject, direct_connect_request
from .framing import (
MAGIC_ACK,
MAGIC_DATA,
build_ble_ack_frame,
build_ble_data_frame,
chunk_for_mtu,
content_in_data,
create_ble_frame,
frame_socket_payload,
magicEquals,
magic_in_data,
sn_in_data,
verify_frame_crc,
)
from .framing import SocketFrameDecoder
__all__ = [
"CommunicationCmd",
"CommunicationMode",
"CommunicationOperate",
"CRC16_INIT",
"CRC16_POLY",
"crc16_modbus",
"crc16_hex_string",
"crc32",
"int16_to_le_bytes",
"le_bytes_to_int16",
"DEFAULT_KEY_RADIX",
"ALT_KEY_RADIX",
"generate_secret_key",
"split_envelope",
"encrypt_object",
"decrypt_object",
"header_len_for",
"KEY_ORDER",
"PLACEHOLDER",
"CommunicationObject",
"direct_connect_request",
"MAGIC_ACK",
"MAGIC_DATA",
"create_ble_frame",
"build_ble_data_frame",
"build_ble_ack_frame",
"magic_in_data",
"magicEquals",
"sn_in_data",
"content_in_data",
"verify_frame_crc",
"chunk_for_mtu",
"frame_socket_payload",
"SocketFrameDecoder",
]
@@ -0,0 +1,86 @@
"""Transport selector, operation codes and command ids.
Recovered from the object pool (``pp.txt``) of ``libapp.so`` — see
``docs/RE_DART_FINDINGS.md`` §2, §4.
"""
from __future__ import annotations
from enum import IntEnum
class CommunicationMode(IntEnum):
"""Transport selector (raw values as in the Dart enum)."""
MQTT = 0
SOCKET = 1
BLUETOOTH = 2
HTTP = 3
LOCAL = 4
class CommunicationOperate(IntEnum):
"""``type`` field of the wire envelope."""
QUERY = 0
SET = 1
COMMAND = 2
MODIFY = 5
class CommunicationCmd(IntEnum):
"""``cmdType`` field of the wire envelope.
Gaps 0x09, 0x1B, 0x2C, 0x31 are not surfaced as instances in the pool.
``WIFI_FREQUECY`` keeps the app's spelling.
"""
QUERY_DEVICE_INFO = 0x00
NETWORK_INFO = 0x01
RESOURCE_ADDRESS = 0x02
SUNSPEC_MODBUS = 0x03
NETWORK_SWITCH = 0x04
FEEDING_GRID_POWER = 0x05
MODE = 0x06
MODE_V2 = 0x07
MODE_SETTING_V2 = 0x08
SOLAR_INFO = 0x0A
ENERGY_MANAGE_INFO = 0x0B
GENERATOR_INFO = 0x0C
BLUETOOTH_HEARTBEAT = 0x0D
SCAN_WIFI_LIST = 0x0E
CONNECT_WIFI = 0x0F
CONNECTION_STATUS = 0x10
NETWORK_CHANNEL_STATUS = 0x11
NETWORK_CHANNEL = 0x12
PAIR_POWER = 0x13
PROCESS_PAIR_POWER = 0x14
CHECK_BLUETOOTH_PASSWORD = 0x15
GO_OFF_GRID_STATUS = 0x16
A_POWER_LED_SWITCH = 0x17
DEVICE_DETAIL_INFO = 0x18
DEVICE_APOWER_INFO = 0x19
DEVICE_ALARMS = 0x1A
PE = 0x1C
BMS = 0x1D
EMS = 0x1E
MPPT = 0x1F
A_HUB = 0x20
CT_TEST_REVERSE = 0x21
DEVICE_DATA = 0x22
LOAD_STATUS = 0x23
LOAD_STATUS_2 = 0x24
SMART_LOAD = 0x25
SMART_LOAD_2 = 0x26
REBOOT_DEVICE = 0x27
DEBUG_MODE = 0x28
POWER_ON = 0x29
CHARGE_DEVICE = 0x2A
CLEAR_FAULT = 0x2B
DEVICE_ADDRESS_INFO = 0x2D
OVERLOAD_CUT_OFF = 0x2E
WIFI_FREQUECY = 0x2F # sic
BREAKER_COMMISSION_NOTIFY = 0x30
BREAKER_COM_STATE = 0x32
BREAKER_LOAD_STATUS = 0x33
BREAKER_IDENTIFY_ME = 0x34
+56
View File
@@ -0,0 +1,56 @@
"""CRC helpers recovered from ``communication_center/utils/crc16.dart`` / ``crc32.dart``.
See ``docs/RE_DART_FINDINGS.md`` §6.
* CRC16 = **CRC-16/MODBUS** (init ``0xFFFF``, reflected poly ``0xA001`` — the
disassembly shows the literal constant ``40961``).
* The app's ``Crc16::calculate`` returns the CRC with **hi/lo bytes swapped**,
rendered as a **4 lowercase hex digits** string.
* CRC32 (zlib/IEEE) is used for the JSON envelope integrity check.
"""
from __future__ import annotations
import zlib
CRC16_INIT = 0xFFFF
CRC16_POLY = 0xA001 # reflected 0x8005 (CRC-16/MODBUS)
def crc16_modbus(data: bytes) -> int:
"""CRC-16/MODBUS over *data* (init 0xFFFF, reflected poly 0xA001)."""
crc = CRC16_INIT
for byte in data:
crc ^= byte
for _ in range(8):
if crc & 1:
crc = (crc >> 1) ^ CRC16_POLY
else:
crc >>= 1
return crc & 0xFFFF
def crc16_hex_string(data: bytes) -> str:
"""The app's ``Crc16::calculate`` equivalent.
CRC-16/MODBUS, hi/lo bytes swapped, 4 lowercase hex digits, e.g.
``crc16_modbus(b"123456789") == 0x4B37`` -> swapped ``0x374B`` -> ``"374b"``.
"""
crc = crc16_modbus(data)
swapped = ((crc & 0xFF) << 8) | (crc >> 8)
return f"{swapped:04x}"
def crc32(data: bytes) -> int:
"""CRC-32 (IEEE/zlib) — envelope integrity check."""
return zlib.crc32(data) & 0xFFFFFFFF
def int16_to_le_bytes(value: int) -> bytes:
"""``utils.int16ToBytes`` — little-endian 16-bit."""
return (value & 0xFFFF).to_bytes(2, "little")
def le_bytes_to_int16(data: bytes) -> int:
"""``utils.bytesToInt16`` — little-endian 16-bit."""
return int.from_bytes(data[:2], "little")
+121
View File
@@ -0,0 +1,121 @@
"""Envelope encryption — ``communication_center/core/encryption.dart``.
Reconstructed from the blutter disassembly; see
``docs/RE_DART_FINDINGS.md`` §5 and Open items #2 / #5.
Wire form of an envelope::
s = compact JSON, e.g. {"cmdType":0,"equipNo":"SN",...,"data":""}
a = UTF-8( parts[0] + "," + parts[1] ) # {"cmdType":..,"equipNo":..
b = UTF-8( ",".join(parts[2:]) ) # the rest of the JSON
key_int = int.tryParse(key, radix) or 0
wire = bytes( (b[i] + i + len(a) + key_int) & 0xFF ) || a
i.e. everything after the second comma is transformed byte-wise (position-
dependent additive stream) and the two leading JSON parts are appended
**unchanged** at the end.
Confirmed from the disassembly:
* ``generateSecretKey`` = ``sum(utf8(sn)) & 0xFF`` rendered as lowercase hex
with **no** zero padding (Dart ``_toPow2String(16)``).
* The key is parsed with ``int.tryParse`` (null -> 0) with a ``radix``
argument; a literal ``#0x20`` (32) is materialised right before the call in
both encrypt and decrypt paths. Hex is self-consistent with the key
encoding, so **radix 16 is the default** here — pass ``radix=32`` to try the
other candidate. Confirm against a live capture.
"""
from __future__ import annotations
from typing import Optional, Tuple
#: Default key radix — see module docstring for the 16-vs-32 note.
DEFAULT_KEY_RADIX = 16
#: Alternate candidate observed next to the ``tryParse`` call in the asm.
ALT_KEY_RADIX = 32
def generate_secret_key(equip_no: str) -> str:
"""``generateSecretKey(equipNo)``: sum of UTF-8 bytes, ``& 0xFF``, hex.
Example: ``generate_secret_key("01234567890123456789")`` is the lowercase
hex of ``(sum of those 20 bytes) & 0xFF`` (1- or 2-digit string).
"""
total = sum(equip_no.encode("utf-8")) & 0xFF
return format(total, "x")
def _parse_key(key: str, radix: int) -> int:
"""Mirror of Dart ``int.tryParse(key, radix: r)`` -> null -> 0."""
try:
return int(key, radix)
except (TypeError, ValueError):
return 0
def split_envelope(json_str: str) -> Tuple[bytes, bytes]:
"""Split the JSON into ``(a, b)`` as the app does (UTF-8 encoded)."""
parts = json_str.split(",")
a = (parts[0] + "," + parts[1]).encode("utf-8")
b = ",".join(parts[2:]).encode("utf-8")
return a, b
def encrypt_object(json_str: str, key: str,
radix: int = DEFAULT_KEY_RADIX) -> bytes:
"""Encrypt an envelope JSON string into wire bytes (``transform(b) || a``)."""
a, b = split_envelope(json_str)
key_int = _parse_key(key, radix)
transformed = bytes(
(byte + i + len(a) + key_int) & 0xFF for i, byte in enumerate(b)
)
return transformed + a
def header_len_for(cmd_type: int, equip_no: str) -> int:
"""``len(a)`` for an envelope with the given ``cmdType`` / ``equipNo``.
Derived from the *known* key order (``cmdType``, ``equipNo`` first) and
compact JSON — this is what a receiver can use to locate the unencrypted
tail of an incoming frame.
"""
part0 = '"cmdType":%d' % int(cmd_type)
part1 = '"equipNo":"%s"' % equip_no
return len(part0) + 1 + len(part1)
def decrypt_object(
wire: bytes,
key: str,
header_len: Optional[int] = None,
radix: int = DEFAULT_KEY_RADIX,
cmd_type: Optional[int] = None,
equip_no: Optional[str] = None,
) -> str:
"""Inverse of :func:`encrypt_object` -> original JSON string.
``header_len`` is ``len(a)`` (the unencrypted tail). When omitted it is
derived from ``cmd_type`` + ``equip_no`` via :func:`header_len_for` —
both must be known in that case.
"""
if header_len is None:
if cmd_type is None or equip_no is None:
raise ValueError(
"header_len (or cmd_type + equip_no) is required to split the "
"wire buffer"
)
header_len = header_len_for(cmd_type, equip_no)
a = wire[len(wire) - header_len:] if header_len else b""
b = wire[: len(wire) - header_len] if header_len else bytes(wire)
key_int = _parse_key(key, radix)
plain_b = bytes(
(byte - i - len(a) - key_int) & 0xFF for i, byte in enumerate(b)
)
a_str = a.decode("utf-8")
b_str = plain_b.decode("utf-8")
if not a_str:
return b_str
if not b_str:
return a_str
return a_str + "," + b_str
@@ -0,0 +1,131 @@
"""Wire envelope — ``CommunicationObject``.
JSON key order (exact, from ``toJson`` in ``core/communication_object.dart``):
``cmdType, equipNo, type, timeStamp, snno, len, crc, dataArea, data``.
See ``docs/RE_DART_FINDINGS.md`` §3.
Confirmed:
* ``cmdType`` = :class:`CommunicationCmd` raw value
* ``equipNo`` = device serial number
* ``type`` = :class:`CommunicationOperate` raw value
* compact JSON (no whitespace) — matches Dart ``json.encode``
Candidates (not yet confirmed byte-for-byte, see Open items):
* defaults of ``crc`` / ``dataArea`` — the pool contains the string constant
``"00000000"`` used as a placeholder default;
* ``len`` semantics (byte length of the payload).
"""
from __future__ import annotations
import json
import time
from typing import Any, Dict, Optional
from .commands import CommunicationCmd, CommunicationOperate
#: Exact key order used by the app's ``toJson``.
KEY_ORDER = (
"cmdType",
"equipNo",
"type",
"timeStamp",
"snno",
"len",
"crc",
"dataArea",
"data",
)
#: Placeholder default observed in the object pool.
PLACEHOLDER = "00000000"
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: Optional[str] = None,
time_stamp: Optional[int] = None,
length: Optional[int] = None,
crc: str = PLACEHOLDER,
data_area: str = PLACEHOLDER,
data: str = "",
) -> None:
self.cmd_type = int(cmd_type)
self.equip_no = str(equip_no)
self.op_type = int(op_type)
self.snno = snno if snno is not None else str(equip_no)
self.time_stamp = int(time_stamp if time_stamp is not None
else int(time.time() * 1000))
self.length = int(length) if length is not None else 0
self.crc = str(crc)
self.data_area = str(data_area)
self.data = str(data)
# ------------------------------------------------------------------ json
def to_dict(self) -> Dict[str, Any]:
"""Fields in the app's exact key order."""
return {
"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,
"dataArea": self.data_area,
"data": self.data,
}
def to_json_string(self) -> str:
"""Compact JSON string — the input of ``encrypt_object``."""
return json.dumps(self.to_dict(), separators=(",", ":"))
@classmethod
def from_dict(cls, raw: Dict[str, Any]) -> "CommunicationObject":
"""Tolerant parse (missing keys fall back to placeholders)."""
return cls(
cmd_type=raw.get("cmdType", 0),
equip_no=raw.get("equipNo", ""),
op_type=raw.get("type", 0),
snno=raw.get("snno") if raw.get("snno") is not None else None,
time_stamp=raw.get("timeStamp"),
length=raw.get("len"),
crc=raw.get("crc", PLACEHOLDER),
data_area=raw.get("dataArea", PLACEHOLDER),
data=raw.get("data", ""),
)
@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})"
)
def direct_connect_request(
cmd: CommunicationCmd,
equip_no: str,
data: str = "",
) -> CommunicationObject:
"""Build a *direct-connect* query envelope.
Mirrors the Dart ``_query*ByDirectConnect`` family
(``docs/RE_DART_FINDINGS.md`` — relevant files / RPC layer).
"""
return CommunicationObject(
cmd_type=int(cmd),
equip_no=equip_no,
op_type=CommunicationOperate.QUERY,
data=data,
)
+159
View File
@@ -0,0 +1,159 @@
"""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.
"""
from __future__ import annotations
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_NAMES = {
MAGIC_ACK[0:2]: "ack",
MAGIC_DATA[0:2]: "data",
}
# 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: 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 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):
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]
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:
return False
candidate = body[:]
candidate[2:4] = b"\x00\x00\x00\x00"
return bytes.fromhex(crc16_hex_string(bytes(candidate))) == body[2:4]
def chunk_for_mtu(frame: bytes, mtu: int = 20) -> List[bytes]:
"""Split a frame for GATT writes.
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.
"""
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)]
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:
"""Length-prefixed local-socket frame (candidate — §8).
``int16LE(len(payload)) || payload`` — the app's socket layer frames with
the same little-endian int16 helpers.
"""
if len(payload) > 0xFFFF:
raise ValueError("socket payload exceeds the int16 length field")
return int16_to_le_bytes(len(payload)) + bytes(payload)
class SocketFrameDecoder:
"""Reassembles length-prefixed socket frames from a byte stream."""
def __init__(self) -> None:
self._buf = bytearray()
def feed(self, data: bytes) -> List[bytes]:
"""Append *data*; return a list of complete payloads (may be empty)."""
self._buf.extend(data)
out: List[bytes] = []
while len(self._buf) >= 2:
length = le_bytes_to_int16(bytes(self._buf[:2]))
if len(self._buf) < 2 + length:
break
out.append(bytes(self._buf[2:2 + length]))
del self._buf[2 + length]
return out