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
+1431 -14

No files matched your search

+6
View File
@@ -6,6 +6,12 @@ Python client library for interacting with FranklinWH AGATE energy storage devic
**Gitea:** http://192.168.1.74:30008/WesternTechnologies/franklinwh-agate-client **Gitea:** http://192.168.1.74:30008/WesternTechnologies/franklinwh-agate-client
## Documentation
- **[Dart AOT Reverse-Engineering Findings](docs/RE_DART_FINDINGS.md)** — detailed protocol
analysis recovered from the app's `libapp.so` with [blutter](https://github.com/worawit/blutter)
(transports, wire envelope, command/topic enums, crypto/CRC, BLE framing, hotspot credentials).
## Reverse Engineering Summary ## Reverse Engineering Summary
This library was reverse engineered from the official FranklinWH Family Energy Android app (`com.Franklinwh.FamilyEnergy`). This library was reverse engineered from the official FranklinWH Family Energy Android app (`com.Franklinwh.FamilyEnergy`).
+332
View File
@@ -0,0 +1,332 @@
# FranklinWH aGate — Dart AOT Reverse-Engineering Findings
Target: `split_config.arm64_v8a.apk` → `lib/arm64-v8a/libapp.so` (arm64 Dart AOT snapshot),
plus Android glue in `base.apk`.
## Tooling (installed & working)
- **blutter** (Dart AOT runtime reconstruction) at `/tmp/opencode/blutter`.
- Host is `aarch64` (matches the binary arch).
- Dart snapshot version detected automatically: **3.11.5**.
- Build deps installed via apt: `python3-pyelftools python3-requests cmake ninja-build
build-essential pkg-config libicu-dev libcapstone-dev`.
- Output: `/tmp/opencode/blutter_out/`
- `asm/<package>/<file>.dart` — per-file Dart "assembly" **with symbols, field offsets,
called-function names and inline string literals** (this is the readable form).
- `pp.txt` — object pool incl. **enum instances with their names + raw values**.
- `objs.txt`, `ida_script/`, `blutter_frida.js`.
- **jadx 1.5.6** (Java/Kotlin decompiler) for the `base.apk` DEX glue.
- Java 21 (`OpenJDK 21.0.12.1`, `/usr/bin/java`); CLI at `/usr/local/bin/jadx`,
full install under `/home/node/jadx-1.5.6`
(`bin/jadx`, `bin/jadx-gui`, `lib/jadx-1.5.6-all.jar`).
- Full decompile of `base.apk` at `/tmp/opencode/jadx-out/base/`
(`sources/` — **18,617 `.java` files** — and `resources/` incl. the decoded
`AndroidManifest.xml`). Reproduce:
`jadx -d /tmp/opencode/jadx-out/base /workspaces/typescript-node/base.apk`
- REA MCP's DEX tools (`search_android_classes`, `inspect_android_class`, …) need
`REA_JADX_MCP_JAR` → a **jadx-headless-mcp 0.7.1** JAR. That JAR is *not* shipped in
the jadx 1.5.6 distribution and is not configured yet — not a blocker, grepping the
decompiled `sources/` has covered everything so far.
Reproduce:
```
python3 /tmp/opencode/blutter/blutter.py /tmp/opencode/apk/lib/arm64-v8a /tmp/opencode/blutter_out
```
> Note: blutter yields *annotated disassembly + recovered objects*, not high-level source.
> String literals, class/field names, enum values, and call graphs are reliable; arithmetic
> is read from the disassembly.
---
## 1. Package layout (relevant)
```
communication_center/
core/communication_object.dart # wire envelope
core/communication_cmd.dart # command enum + paramKey
core/communication_mode.dart # transport enum
core/encryption.dart # secret key + payload transform
core/base_communication.dart
mqtt/mqtt_manager.dart # cloud (AWS IoT) transport
mqtt/mqtt_topic_type.dart # cmd -> topic mapping
socket/client_socket.dart, socket_manager.dart # local TCP transport
bluetooth/bluetooth_manager.dart, bluetooth_frame.dart, bluetooth_write_queue.dart
utils/crc16.dart, crc32.dart, utils.dart
franklinwh2/services/communication_service/communication_service.dart # RPC layer
franklinwh2/services/remote_service/remote_interface.dart # HTTP RPC layer
franklinwh2/services/eaton_service/eaton_ble_transport.dart # ESP-svc BLE transport
franklinwh2/utils/device_wifi_utils.dart # hotspot join + creds
esp_provisioning_ble/src/... # ESP protocomm
```
## 2. Transport selector — `CommunicationMode`
| name | raw |
|---|---|
| mqtt | 0 |
| socket | 1 |
| bluetooth | 2 |
| http | 3 |
| local | 4 |
`CommunicationCenter` exposes `connectOrganized`, `sendObjectOrganized`,
`completeFutureConnect/Request`, `timeoutFutureConnect/Request`, `disposeFutureConnect/Request`,
`matchedRequest`. It contains sentinels `"do not support http communication"` and
`"do not support local communication"` (those modes are handled elsewhere).
## 3. Wire envelope — `CommunicationObject`
JSON keys, in the exact insertion/inverse order used by `toJson`:
```
cmdType, equipNo, type, timeStamp, snno, len, crc, dataArea, data
```
Fields present: `_Mint field_8`, `_OneByteString field_10`, `_Mint field_14`
(+ `_Mint`/`_OneByteString` further). `data` is a setter; string const `"00000000"` is a
default (likely `crc`/`dataArea` placeholder). `cmdType` = `CommunicationCmd` raw,
`type` = `CommunicationOperate` raw, `equipNo` = device SN.
- `CommunicationOperate`: `query=0, set=1, command=2, modify=5`.
- `Crc32::calculate` is used in `fromJsonString` (integrity check of the payload).
- `toJsonString()` produces the string that is then transformed (see §5).
## 4. `CommunicationCmd` (raw → name)
```
0x00 queryDeviceInfo 0x13 pairPower 0x26 smartLoad2
0x01 networkInfo 0x14 processPairPower 0x27 rebootDevice
0x02 resourceAddress 0x15 checkBluetoothPassword 0x28 debugMode
0x03 sunspecModbus 0x16 goOffGridStatus 0x29 powerOn
0x04 networkSwitch 0x17 aPowerLedSwitch 0x2a chargeDevice
0x05 feedingGridPower 0x18 deviceDetailInfo 0x2b clearFault
0x06 mode 0x19 deviceApowerInfo 0x2d deviceAddressInfo
0x07 modeV2 0x1a deviceAlarms 0x2e overloadCutOff
0x08 modeSettingV2 0x1c pe 0x2f wifiFrequecy
0x0a solarInfo 0x1d bms 0x30 breakerCommissionNotify
0x0b energyManageInfo 0x1e ems 0x32 breakerComState
0x0c generatorInfo 0x1f mppt 0x33 breakerLoadStatus
0x0d bluetoothHeartbeat 0x20 aHub 0x34 breakerIdentifyMe
0x0e scanWifiList 0x21 ctTestReverse
0x0f connectWifi 0x22 deviceData
0x10 connectionStatus 0x23 loadStatus
0x11 networkChannelStatus 0x24 loadStatus2
0x12 networkChannel 0x25 smartLoad
```
(missing: 0x09, 0x1b, 0x2c, 0x31 — not surfaced as instances.)
## 5. Encryption — `communication_center/core/encryption.dart`
**`generateSecretKey(String equipNo)`** = hex of `(sum(utf8(equipNo)) & 0xFF)` (via
`reduce(sum)` → `&0xFF` → `_toPow2String(16)`), i.e. a 1-byte key rendered as hex.
**`encryptObject(obj, key)`**:
1. `s = obj.toJsonString()`; split on `","`.
2. `a` = UTF-8 of `part[0] + "," + part[1]` (i.e. `{"cmdType":..` and `"equipNo":..`).
3. `b` = UTF-8 of `join(",", parts[2..])` (the rest).
4. `keyInt = int.tryParse(key, radix: …)`.
5. `out[i] = (b[i] + i + len(a) + keyInt) & 0xFF` for all i, then append `a` **unchanged**.
So on the wire the encrypted body is `transform(b) || a` (header/keys appended at the end).
`decryptData` is the inverse.
> This is a bespoke additive/KSA stream; the exact radix of the key parse and the trailing
> `a` handling should be confirmed against a captured frame before trusting it byte-for-byte.
## 6. Checksums & byte order
- **CRC16** (`utils/crc16.dart`): init `0xFFFF`, poly `0xA001` (reflected), i.e.
**CRC-16/MODBUS**; then hi/lo bytes swapped; returned as a **4-hex-digit string**
(`_toPow2String(16)`).
- **CRC32** (`utils/crc32.dart`) used for the JSON envelope integrity.
- `utils.int16ToBytes` / `bytesToInt16` = **little-endian** 16-bit.
## 7. MQTT transport (cloud) — `communication_center/mqtt` + Java bridge
Dart side: manager singleton `MqttManager._defaultManager`; endpoint constant
**`https://a6f63crce3ufk-ats.iot.us-east-2.amazonaws.com`** (AWS IoT custom domain).
Java side (`com.p000franklinwh.mqtt_plugin.MqttPlugin`, verified from the jadx decompile):
- Method channel `com.franklinwh/plugins/mqtt_plugin/method_channel`, events on
`…/event_channel` (name from `MqttStatus`).
- Method surface: **`setup / connect / disconnect / subscribe / unsubscribe / publish`**
(the earlier `init / setEndpoint / …` guess was wrong).
- `setup(regionType, identityPoolId, endPointUrl)`:
- `regionType`: **2 → `us-east-2`, 3 → `us-west-1`, 4 → `us-west-2`** (any other value
is rejected before setup).
- Client = **`AWSIotMqttManager`** (AWS IoT Android SDK, not raw Paho) with
**clientID = random `UUID` per setup**, **keepAlive = 10 s**.
- Credentials: **`CognitoCachingCredentialsProvider`** (unauthenticated Cognito
identity pool `5deecc22-4e7f-43b3-8f1d-e54137220126`) → AWS IoT credentials
provider; MQTT over TLS with temporary credentials issued by AWS IoT — no
app-side client certs.
- Last-will: topic **`my/lwt/topic`**, payload **`"Android client lost connection"`**,
QoS 0.
- `connect` takes no arguments → `mqttManager.connect(credentialsProvider, statusCallback)`.
- `subscribe(topic)` → `subscribeToTopic(topic, QoS 0)`; `publish(topic, message)` →
`publishString(topic, message, QoS 0)`. Both string payloads.
- Event-channel events:
- `subscribe {topic, message}` — `message` = received bytes decoded as UTF-8;
- `connectionStatuesChange {status: <rawValue>}` — sic, "Statues" is the literal
event name; `status` = raw value of the `MqttStatus` mapping of the SDK status.
### Topics — `MqttTopicType` (name=raw → prefix)
```
undefined=0 "" rtC2S=3 "fwh/c2s/rt/" mngS2C=6 "fwh/s2c/mng/"
authC2S=1 "fwh/c2s/auth/" rtS2C=4 "fwh/s2c/rt/" deviceC2S=… "fwh/c2s/device/"
authS2C=2 "fwh/s2c/auth/" mngC2S=5 "fwh/c2s/mng/" deviceS2C "fwh/s2c/device/"
debugC2S/S2C "fwh/…/{c2s,s2c}/debug/"
```
`MqttTopicType.fromCmd(cmd)` selects the topic family for a `CommunicationCmd`.
## 8. Socket transport (local TCP) — `communication_center/socket`
- `SocketManager._defaultManager`, `ClientSocket` (getters `connected`/`closed`), status
enum `SocketStatus`: `closed / connected / connecting`.
- `CommunicationService._connectBeforeRequest` calls
`CommunicationCenter.connectOrganized(host:, port:)` with:
- host = stored host, else **`"10.100.1.1"`** (device hotspot gateway);
- port constant **`0x4650 = 18000`** (candidate local port),
- a `Duration` and retry loop (up to ~2 retries) are involved.
- `BytesToInt16/Int16ToBytes` LE framing; `Socket Manager send object` / `receive object`.
## 9. Bluetooth (BLE) transport — `communication_center/bluetooth`
- `BluetoothManager` (singleton), `BluetoothStatus`: `closed / connected / connecting / interrupted`.
- **Frame magics (2 bytes)**: `0x4A 0xA4` = ACK frame; `0x5A 0xA5` = data frame (triggers `_sendAck`).
- `bluetooth_frame.dart`:
- `createBluetoothFrame(content, ?, value)`: builds `content ++ [00 00 00 00] ++ int16LE(totalLen) ++ int16LE(value) ++ sn`,
computes `Crc16::calculate` over that, hex→2 bytes, writes them into the `00 00 00 00`
placeholder (indices 2..3). `totalLen = len(content)+6+len(sn)`.
- helpers: `magicEquals`, `magicInData`, `snInData`, `contentInData`, `mtuToData` (MTU chunking).
- Exact field order still to be confirmed against a live `btsnoop` capture.
- Heartbeat = Dart timer (`startBluetoothHeartbeat`, `cancelBluetoothHeartbeat`,
`_bluetoothHeartbeat`, `[Bluetooth] Heartbeat Timeout`), command `bluetoothHeartbeat` (0x0d).
### ESP provisioning (WiFi onboarding over BLE) — `esp_provisioning_ble`
- `/terminal`-style endpoints (ESP protocomm): **`prov-session`, `prov-scan`, `prov-config`, `custom-data`**.
- `EspProv` handles `EstablishSessionStatus`; `security.dart` (sec0) & `security1.dart` (sec1):
ECDH (Curve25519) → shared key, verification, `abcd1234` present as a PoP, AES-CTR (crypt.dart: key/iv/crypt).
- Service UUID `021a9004-0382-4aea-bff4-6b3f1c5adfb4` is used by
`franklinwh2/services/eaton_service/eaton_ble_transport.dart` (Eaton/EatonBreaker path).
The provisioning GATT characteristics are referenced indirectly; confirm via a device scan.
## 10. Local hot-spot credential derivation — `franklinwh2/utils/device_wifi_utils.dart`
`DeviceWifiUtils` (static):
- `getDeviceApSsid(sn)`:
- if `sn.length == 20` → `"AP_" + sn.substring(11)`; else log `"deviceSn is invalid"` →
`"AP_" + sn`.
- `getDeviceApDefaultPwd(sn)`:
- if `sn.length == 20` → `sn.substring(8)`; else → `"12345678"`.
- `connectDeviceWifiWithSSID(ssid, pwd)` → `wifi_iot` `connect`; `disconnectDeviceWifi` → `disconnect`;
`forceWifiUsage` toggles the process binding; caches last ssid/pwd in statics.
> The length check is `field_7 == 0x28` on a Smi length ⇒ `length == 20`.
> Derived values are the **offline fallback**; the authoritative values come from
> `/terminal/getHotSpotInfo` and `/terminal/getHosSpotPassword`.
## 11. HTTP layer — local vs cloud
Every operation has a **local** (`/terminal/…`) and **cloud** (`/manage/…`) route, selected by
mode. Local base is `https://IP:PORT` (string literal), device IP in `deviceIp`, plus four
`X-Device-*` headers (`-IP`, `-Referer`, `-Requested-With`, `-Accept-Language`) located in
the `base.apk` DEX string pool — class binding in progress, see §13.
**Local device endpoints (subset, `/terminal/`)**:
```
/terminal/getHotSpotInfo /terminal/getHotSpotInfo/v2 /terminal/getHosSpotPassword
/terminal/obtainAgateInfo /terminal/updateHotSpotInfo
/terminal/sendMqtt /terminal/getDeviceInfoV2 /terminal/getDeviceCompositeInfo
/terminal/queryGeneratorData /terminal/selectDeviceOverallInfo
/terminal/getGatewaySystemSetting /terminal/system/getSystemSetting
/terminal/initialize/installerOktaLogin /terminal/initialize/mfa/*
/terminal/mfa/* /terminal/getPersonalInfo /terminal/updatePersonalInfo
```
**Cloud**: `https://e-api.franklinwh.com/api/appapi`; `https://energy.franklinwh.com/{hes-gateway,
hes-tariff-collection, api-energy/*, api-smart/*, api-user/mfa/*, api-vpp-portal}`;
`https://api.franklin-wh.com:10443/api/appapi`; `https://sunrun.okta.com/oauth2/default`.
`RemoteInterface` returns `Stream<HttpResultEntity<…>>` per endpoint. `CommunicationService`
exposes typed RPCs: `queryDeviceInfo`, `queryAPowerData`, `queryCircuitsInfo`,
`queryModes1/2`, `querySmartLoadInfo`, `queryNewSmartLoadInfo`, `queryCTTestReverse`,
`queryAHubDetailInfo`, `queryEmsInfoBySocket`, `queryLoadStatusInfo`, `queryNewLoadStatusInfo`,
`queryOverloadCutOff`, `queryDebugModeInfo`, `queryChargePower`, `queryGoOffGridStatus`, plus
`_request(cmd, sn, operate, {arguments, mode})` as the core dispatch.
## 12. Eaton breaker / UDP keys
`eaton_service/eaton_udp_keys.dart` + `eaton_breaker_service.dart` deal with
`primary/secondary Unicast/Multicast UDP keys` (`etn_udp_keys_…`), versioned (`VER1`), keyed
storage, status strings (`Success`, `Failure to store UDP Keys`, `UDP Key Format error`).
## 13. Java glue (`base.apk`) — JADX decompile
`/tmp/opencode/jadx-out/base/sources` (18,617 files; see Tooling). The Java layer is
**platform plumbing only** — no device-protocol logic; the protocol lives entirely in
the Dart AOT layer. (The MQTT bridge is covered in §7.)
### `com.franklinwh.franklinwh2.method_channel.MethodHandlerManager`
Singleton holding a `LinkedHashMap<String, BaseMethodHandler>`; the constructor calls
`registerHandlers()`, which registers exactly five handlers on the
`com.FranklinWH.FamilyEnergy/method_channel` channel: `OpenSystemWifiSetting`,
`NetworkInfoCompat`, `BuildConfig`, `MigrateData`, `ShareInfoToOtherApp`
(all under `method_channel/method_handlers/`). All UI/system helpers — opening the
system Wi-Fi settings, network state, build config, DB migration, Android share sheet.
No device-communication surface.
### `com.alternadom.wifiiot.WifiIotPlugin` (the `wifi_iot` plugin)
Thin wrapper over Android `WifiManager` — this is how the app joins the device SoftAP.
Full method surface (26, from the Kotlin string dispatch):
```
connect, findAndConnect, disconnect, isConnected, setEnabled,
getIP, getSSID, getBSSID, getFrequency, getCurrentSignalStrength,
loadWifiList, registerWifiNetwork, removeWifiNetwork, isRegisteredWifiNetwork,
setSSIDHidden, isSSIDHidden, getClientList,
SoftAP: setWiFiAPEnabled, getWiFiAPState, isWiFiAPEnabled, getWiFiAPSSID,
setWiFiAPSSID, getWiFiAPPreSharedKey, setWiFiAPPreSharedKey,
forceWifiUsage, showWritePermissionSettings
```
Location-permission request codes: `65655435` (load list), `65655436` (listen),
`65655437` (find & connect), `65655438` (isConnected). Nothing protocol-specific.
### `X-Device-*` headers — located in DEX, class binding in progress
`X-Device-IP`, `X-Device-Referer`, `X-Device-Requested-With`, `X-Device-Accept-Language`
exist as **adjacent entries in the `classes5.dex` string pool** (i.e. one constant
map of 4 headers) but do **not** appear as literals in any decompiled class (the only
jadx decompile errors were in unrelated `com.google.android.material.shadow.*`). They
are absent from the Dart `libapp.so` string pool too. So a Kotlin class in
`classes5.dex` sets them at runtime — consistent with cloud HTTP requests carrying a
device-IP header (cloud → device proxying). Identifying the referencing class via a
direct DEX string-reference scan is in progress (workaround while REA's DEX tools
stay unavailable without `REA_JADX_MCP_JAR`).
---
## Open items
**Resolved by the JADX decompile of `base.apk`** (see §7, §13): `MqttPlugin` internals
(actual method names `setup/connect/…`, `AWSIotMqttManager` + Cognito credentials,
keepAlive 10 s, LWT, QoS 0, event names), `MethodHandlerManager` handler list,
`WifiIotPlugin` method surface.
Still open:
1. Exact BLE frame field order & the 2-byte magic semantics (confirm with `btsnoop`).
2. Exact `encryptObject` radix + trailing-header handling.
3. Local HTTP port + TLS behavior for `https://IP:PORT` (device self-signed cert?).
4. Local socket port confirmation (`18000` candidate) and whether local RPC uses
socket vs `/terminal` HTTP vs local MQTT.
5. Which class in `classes5.dex` sets the `X-Device-*` headers and their exact values
(string-pool location confirmed; class binding in progress — §13).
6. (Tooling, optional) Enable REA's DEX tools: obtain a **jadx-headless-mcp 0.7.1** JAR
and set `REA_JADX_MCP_JAR`.
+256
View File
@@ -0,0 +1,256 @@
"""Local (direct-connect) device client.
Talks to the aGate **on its own network** after joining the device hotspot —
the ``mode == local`` path of the app (see ``docs/RE_DART_FINDINGS.md`` §8,
§11). Two channels:
* **HTTP** to ``https://IP:PORT/terminal/...`` — the ``/terminal/*`` local
endpoint family (hotspot info, aGate info, overall info, MQTT relay, ...).
Scheme/port are *not* confirmed (Open item #3); defaults are candidates.
* **TCP socket** to ``10.100.1.1:18000`` (candidate port — Open item #4)
carrying the encrypted :class:`~franklinwh_agate_client.protocol.CommunicationObject`
envelope with little-endian int16 length framing.
"""
from __future__ import annotations
import json
import logging
import socket
from typing import Any, Dict, Optional
import requests
from .protocol import (
CommunicationCmd,
CommunicationObject,
CommunicationOperate,
SocketFrameDecoder,
decrypt_object,
direct_connect_request,
encrypt_object,
frame_socket_payload,
generate_secret_key,
)
from .wifi import DEVICE_GATEWAY_IP
log = logging.getLogger(__name__)
DEFAULT_HOST = DEVICE_GATEWAY_IP
#: Candidate local port constant observed in the connect path (0x4650).
DEFAULT_LOCAL_PORT = 18000
#: ``X-Device-*`` headers located in ``base.apk`` (classes5.dex). Exact
#: values are not yet confirmed (Open item #5) — override as needed.
X_DEVICE_IP = "X-Device-IP"
X_DEVICE_REFERER = "X-Device-Referer"
X_DEVICE_REQUESTED_WITH = "X-Device-Requested-With"
X_DEVICE_ACCEPT_LANGUAGE = "X-Device-Accept-Language"
class LocalDeviceError(RuntimeError):
"""Raised when the device answers with an error envelope."""
class LocalDeviceClient:
"""Direct-connect client for one aGate device.
:param sn: device serial number (drives hotspot creds + secret key).
:param host: device IP on its hotspot network (default gateway 10.100.1.1).
:param port: HTTP port of the device's ``/terminal`` API (candidate 18000).
:param scheme: ``https`` (device self-signed cert expected) or ``http``.
:param socket_port: local TCP socket port (candidate 18000).
:param verify: TLS verification for the device API (device likely serves a
self-signed cert, so ``False`` is the practical default).
:param key_radix: key parse radix for the envelope cipher (16 default;
32 is the alternate candidate seen in the disassembly).
:param timeout: per-request timeout in seconds.
"""
def __init__(
self,
sn: str,
host: str = DEFAULT_HOST,
port: int = DEFAULT_LOCAL_PORT,
scheme: str = "https",
verify: Optional[bool] = False,
socket_port: Optional[int] = None,
key_radix: int = 16,
timeout: float = 10.0,
extra_headers: Optional[Dict[str, str]] = None,
session: Optional[requests.Session] = None,
) -> None:
self.sn = sn
self.host = host
self.port = int(port)
self.scheme = scheme
self.base_url = f"{scheme}://{host}:{port}"
self.socket_host = host
self.socket_port = int(socket_port if socket_port is not None else DEFAULT_LOCAL_PORT)
self.key_radix = int(key_radix)
self.timeout = timeout
self.key = generate_secret_key(sn)
self._session = session or requests.Session()
self._session.headers.update(
{X_DEVICE_IP: host, **(extra_headers or {})}
)
self._session.verify = bool(verify)
if not verify:
import urllib3
urllib3.disable_warnings(
category=urllib3.exceptions.InsecureRequestWarning
)
# ------------------------------------------------------------------ HTTP
def request(
self,
path: str,
method: str = "GET",
params: Optional[Dict[str, Any]] = None,
json_body: Optional[Any] = None,
) -> Any:
"""Raw request against ``/terminal`` (or any absolute path)."""
url = path if path.startswith(("http://", "https://")) else self.base_url + path
resp = self._session.request(
method, url, params=params, json=json_body, timeout=self.timeout
)
resp.raise_for_status()
ctype = resp.headers.get("Content-Type", "")
if "json" in ctype or resp.text.lstrip()[:1] in "{[":
return resp.json()
return resp.text
# -- local endpoint family (docs/RE_DART_FINDINGS.md §11) --------------
def get_hotspot_info(self) -> Any:
"""``/terminal/getHotSpotInfo`` — authoritative SSID/pwd."""
return self.request("/terminal/getHotSpotInfo")
def get_hotspot_info_v2(self) -> Any:
return self.request("/terminal/getHotSpotInfo/v2")
def get_hotspot_password(self) -> Any:
"""``/terminal/getHosSpotPassword`` (sic)."""
return self.request("/terminal/getHosSpotPassword")
def obtain_agate_info(self) -> Any:
"""``/terminal/obtainAgateInfo``."""
return self.request("/terminal/obtainAgateInfo")
def get_device_info_v2(self) -> Any:
return self.request("/terminal/getDeviceInfoV2")
def get_device_composite_info(self) -> Any:
return self.request("/terminal/getDeviceCompositeInfo")
def select_device_overall_info(self) -> Any:
return self.request("/terminal/selectDeviceOverallInfo")
def query_generator_data(self) -> Any:
return self.request("/terminal/queryGeneratorData")
def get_gateway_system_setting(self) -> Any:
return self.request("/terminal/getGatewaySystemSetting")
def get_system_setting(self) -> Any:
return self.request("/terminal/system/getSystemSetting")
def send_mqtt(self, payload: Any) -> Any:
"""``/terminal/sendMqtt`` — relay an MQTT payload over local HTTP."""
return self.request("/terminal/sendMqtt", method="POST", json_body=payload)
def update_hotspot_info(self, payload: Dict[str, Any]) -> Any:
return self.request("/terminal/updateHotSpotInfo", method="POST",
json_body=payload)
# -------------------------------------------------------------- sockets
def send_rpc(
self,
cmd: CommunicationCmd,
equip_no: Optional[str] = None,
operate: CommunicationOperate = CommunicationOperate.QUERY,
data: str = "",
envelope: Optional[CommunicationObject] = None,
) -> Dict[str, Any]:
"""Send one envelope over the local socket and return the parsed reply.
The request is the app's envelope: compact JSON -> ``encrypt_object``
-> int16LE length framing. The reply is deframed, decrypted (the
unencrypted tail is located via ``cmdType``/``equipNo``) and parsed
back into a JSON object.
"""
if envelope is None:
envelope = direct_connect_request(
cmd, equip_no or self.sn, data=data
)
return self.send_envelope(envelope)
def send_envelope(self, envelope: CommunicationObject) -> Dict[str, Any]:
wire = encrypt_object(envelope.to_json_string(), self.key,
radix=self.key_radix)
raw = self._socket_roundtrip(frame_socket_payload(wire))
reply = decrypt_object(
raw,
self.key,
cmd_type=envelope.cmd_type,
equip_no=envelope.equip_no,
radix=self.key_radix,
)
return json.loads(reply)
def _socket_roundtrip(self, framed: bytes,
timeout: Optional[float] = None) -> bytes:
timeout = timeout if timeout is not None else self.timeout
with socket.create_connection(
(self.socket_host, self.socket_port), timeout=timeout
) as sock:
sock.sendall(framed)
decoder = SocketFrameDecoder()
while True:
chunk = sock.recv(65536)
if not chunk:
break
frames = decoder.feed(chunk)
if frames:
return frames[0]
raise ConnectionError(
f"no reply frame from {self.socket_host}:{self.socket_port}"
)
# ------------------------------------------------------- convenience RPCs
def query_device_info(self) -> Dict[str, Any]:
return self.send_rpc(CommunicationCmd.QUERY_DEVICE_INFO)
def query_bms_info(self) -> Dict[str, Any]:
return self.send_rpc(CommunicationCmd.BMS)
def query_mppt_info(self) -> Dict[str, Any]:
return self.send_rpc(CommunicationCmd.MPPT)
def query_pe_info(self) -> Dict[str, Any]:
return self.send_rpc(CommunicationCmd.PE)
def query_generator(self) -> Dict[str, Any]:
return self.send_rpc(CommunicationCmd.GENERATOR_INFO)
def query_ems_info(self) -> Dict[str, Any]:
return self.send_rpc(CommunicationCmd.EMS)
def query_a_power(self) -> Dict[str, Any]:
return self.send_rpc(CommunicationCmd.DEVICE_APOWER_INFO)
def query_warning_list(self) -> Dict[str, Any]:
return self.send_rpc(CommunicationCmd.DEVICE_ALARMS)
def query_current_net_type(self) -> Dict[str, Any]:
return self.send_rpc(CommunicationCmd.NETWORK_INFO)
def query_sn_array(self) -> Dict[str, Any]:
return self.send_rpc(CommunicationCmd.RESOURCE_ADDRESS)
def query_modes(self) -> Dict[str, Any]:
"""``_queryModesByDirectConnect`` family (``mode`` cmd)."""
return self.send_rpc(CommunicationCmd.MODE)
def query_modes_v2(self) -> Dict[str, Any]:
return self.send_rpc(CommunicationCmd.MODE_V2)
@@ -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
+206 -14
View File
@@ -1,23 +1,215 @@
"""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`.
""" """
Wi-Fi Manager for AGATE device network operations.
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: class WifiManager:
"""Manages Wi-Fi connection to AGATE device onboard hotspot.""" """Facade over a :class:`WifiBackend` (keeps the original stub API)."""
def __init__(self): def __init__(self, backend: Optional[WifiBackend] = None) -> None:
self.backend = backend or NoOpWifiBackend()
self.connected = False self.connected = False
def query_connect_wifi(self, ssid, password=None): # -- new, clean API ----------------------------------------------------
"""Query/connect to AGATE Wi-Fi.""" def join_device_hotspot(self, sn: str) -> HotspotProfile:
# Reverse engineered operation: queryConnectWifi """Derive the profile from the SN and join. Returns the profile."""
pass profile = derive_hotspot_credentials(sn)
self.backend.join(profile)
self.connected = True
return profile
def wifi_onboarding_add_wifi(self, ssid, password): def leave_device_hotspot(self) -> None:
"""Add Wi-Fi network via onboarding.""" self.backend.leave()
# Reverse engineered operation: wifiOnboardingAddWifi self.connected = False
pass
def get_wifi_signal_strength(self): def local_ip(self) -> Optional[str]:
"""Get current Wi-Fi signal strength.""" return self.backend.get_ip()
pass
# -- 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