Files
franklinwh-agate-client/franklinwh_agate_client/wifi.py
T
kfj001 a87dbe200e 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.
2026-10-13 00:18:23 +00:00

216 lines
7.2 KiB
Python

"""Wi-Fi / device-hotspot handling.
Reconstructed from ``franklinwh2/utils/device_wifi_utils.dart`` (Dart side)
and ``com.alternadom.wifiiot.WifiIotPlugin`` (Java side, ``base.apk``) — see
``docs/RE_DART_FINDINGS.md`` §10, §13.
The app joins the **device SoftAP** so that the phone can talk to the aGate
over its local HTTP/socket interfaces (gateway ``10.100.1.1``).
Credential derivation (confirmed from the disassembly, 20-char SN rule)::
ssid = "AP_" + sn[11:] pwd = sn[8:]
else (invalid length)
ssid = "AP_" + sn pwd = "12345678"
Actual joining is delegated to a :class:`WifiBackend` so this module stays
testable without a radio. On a Linux desktop/laptop use
:class:`NmcliWifiBackend` (NetworkManager); in a container or when joining
manually use :class:`NoOpWifiBackend`.
"""
from __future__ import annotations
import logging
import subprocess
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Optional
log = logging.getLogger(__name__)
#: The aGate SoftAP gateway the phone talks to after joining.
DEVICE_GATEWAY_IP = "10.100.1.1"
#: SN length that triggers the "valid" derivation branch (sn.length == 20).
VALID_SN_LENGTH = 20
FALLBACK_PASSWORD = "12345678"
@dataclass(frozen=True)
class HotspotProfile:
"""A device's hotspot connection parameters."""
ssid: str
password: str
gateway_ip: str = DEVICE_GATEWAY_IP
def derive_hotspot_credentials(sn: str) -> "HotspotProfile":
"""``getDeviceApSsid`` / ``getDeviceApDefaultPwd``.
Offline fallback: for a *20-character* SN the SSID is ``"AP_" + sn[11:]``
and the password is ``sn[8:]``; anything else yields ``"AP_" + sn`` /
``"12345678"`` (the app logs ``"deviceSn is invalid"`` in that case).
The authoritative values come from the live device
(``/terminal/getHotSpotInfo`` + ``/terminal/getHosSpotPassword``).
"""
if len(sn) == VALID_SN_LENGTH:
ssid = "AP_" + sn[11:]
password = sn[8:]
else:
log.warning("deviceSn is invalid (len=%d, expected %d): using full-SN fallback",
len(sn), VALID_SN_LENGTH)
ssid = "AP_" + sn
password = FALLBACK_PASSWORD
return HotspotProfile(ssid=ssid, password=password)
class WifiBackend(ABC):
"""Platform abstraction for joining/leaving a Wi-Fi network."""
@abstractmethod
def join(self, profile: HotspotProfile) -> None:
"""Connect to ``profile``. Raises on failure."""
@abstractmethod
def leave(self) -> None:
"""Disconnect from the device hotspot."""
@abstractmethod
def get_ip(self) -> Optional[str]:
"""Our own IP on the current Wi-Fi network (device-side gateway view)."""
def is_connected(self, profile: HotspotProfile) -> bool:
"""Best-effort: did the join stick?"""
return False
class NoOpWifiBackend(WifiBackend):
"""Assumes the user has already connected (manually or via another tool).
Useful in sandboxes/containers without a radio, and when the host's own
tooling handles the association.
"""
def __init__(self, local_ip: Optional[str] = None) -> None:
self._local_ip = local_ip
self._profile: Optional[HotspotProfile] = None
self.joined: bool = False
def join(self, profile: HotspotProfile) -> None:
self._profile = profile
self.joined = True
def leave(self) -> None:
self._profile = None
self.joined = False
def get_ip(self) -> Optional[str]:
return self._local_ip
def is_connected(self, profile: HotspotProfile) -> bool:
return self.joined and self._profile == profile
class NmcliWifiBackend(WifiBackend):
"""Join via ``nmcli`` (NetworkManager) — for Linux desktops/laptops."""
def __init__(self, nmcli: str = "nmcli", ipcmd: str = "ip") -> None:
self._nmcli = nmcli
self._ipcmd = ipcmd
def _run(self, *args: str) -> str:
proc = subprocess.run(
[self._nmcli, *args],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=30,
)
if proc.returncode != 0:
raise RuntimeError(
f"{' '.join(args)} failed: "
f"{proc.stderr.decode('utf-8', 'replace').strip()}"
)
return proc.stdout.decode("utf-8", "replace").strip()
def join(self, profile: HotspotProfile) -> None:
self._run("device", "wifi", "connect", profile.ssid,
"password", profile.password)
def leave(self) -> None:
self._run("device", "wifi", "disconnect")
def get_ip(self) -> Optional[str]:
try:
out = self._run("-g", "IP4.ADDRESS", "device", "show")
except (RuntimeError, subprocess.TimeoutExpired, OSError):
return None
for line in out.splitlines():
addr = line.strip()
if addr.startswith("10.") or addr.startswith("192.168."):
return addr
return None
def is_connected(self, profile: HotspotProfile) -> bool:
try:
out = self._run("-g", "SSID", "device", "wifi", "show")
except (RuntimeError, subprocess.TimeoutExpired, OSError):
return False
return profile.ssid in out.splitlines()
class WifiManager:
"""Facade over a :class:`WifiBackend` (keeps the original stub API)."""
def __init__(self, backend: Optional[WifiBackend] = None) -> None:
self.backend = backend or NoOpWifiBackend()
self.connected = False
# -- new, clean API ----------------------------------------------------
def join_device_hotspot(self, sn: str) -> HotspotProfile:
"""Derive the profile from the SN and join. Returns the profile."""
profile = derive_hotspot_credentials(sn)
self.backend.join(profile)
self.connected = True
return profile
def leave_device_hotspot(self) -> None:
self.backend.leave()
self.connected = False
def local_ip(self) -> Optional[str]:
return self.backend.get_ip()
# -- original stub names (kept for API compatibility) -------------------
def query_connect_wifi(self, ssid: str, password: Optional[str] = None,
sn: Optional[str] = None):
"""``queryConnectWifi`` — join (or rejoin) a network."""
if sn is not None and password is None:
profile = derive_hotspot_credentials(sn)
else:
profile = HotspotProfile(ssid=ssid, password=password or "")
self.backend.join(profile)
self.connected = True
return profile
def wifi_onboarding_add_wifi(self, ssid: str, password: str):
"""``wifiOnboardingAddWifi`` — register a known network."""
self.backend.join(HotspotProfile(ssid=ssid, password=password))
return True
def get_wifi_signal_strength(self) -> Optional[int]:
if not isinstance(self.backend, NmcliWifiBackend):
return None
try:
out = self.backend._run("-g", "SIGNAL", "device", "wifi", "show")
except (RuntimeError, subprocess.TimeoutExpired, OSError):
return None
for line in out.splitlines():
digits = "".join(ch for ch in line if ch.isdigit())
if digits:
return int(digits)
return None