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,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,
)