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.
216 lines
7.2 KiB
Python
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
|