From a87dbe200e34110d4a0ed3672fdad590abef99c3 Mon Sep 17 00:00:00 2001 From: kfj001 Date: Tue, 13 Oct 2026 00:18:23 +0000 Subject: [PATCH] Add recovered protocol stack, Wi-Fi hotspot join, and RE findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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_ 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. --- README.md | 6 + docs/RE_DART_FINDINGS.md | 332 +++++++++++++++++++ franklinwh_agate_client/device_local.py | 256 ++++++++++++++ franklinwh_agate_client/protocol/__init__.py | 78 +++++ franklinwh_agate_client/protocol/commands.py | 86 +++++ franklinwh_agate_client/protocol/crc.py | 56 ++++ franklinwh_agate_client/protocol/crypto.py | 121 +++++++ franklinwh_agate_client/protocol/envelope.py | 131 ++++++++ franklinwh_agate_client/protocol/framing.py | 159 +++++++++ franklinwh_agate_client/wifi.py | 230 +++++++++++-- 10 files changed, 1436 insertions(+), 19 deletions(-) create mode 100644 docs/RE_DART_FINDINGS.md create mode 100644 franklinwh_agate_client/device_local.py create mode 100644 franklinwh_agate_client/protocol/__init__.py create mode 100644 franklinwh_agate_client/protocol/commands.py create mode 100644 franklinwh_agate_client/protocol/crc.py create mode 100644 franklinwh_agate_client/protocol/crypto.py create mode 100644 franklinwh_agate_client/protocol/envelope.py create mode 100644 franklinwh_agate_client/protocol/framing.py diff --git a/README.md b/README.md index c14c3ed..56e879a 100644 --- a/README.md +++ b/README.md @@ -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 +## 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 This library was reverse engineered from the official FranklinWH Family Energy Android app (`com.Franklinwh.FamilyEnergy`). diff --git a/docs/RE_DART_FINDINGS.md b/docs/RE_DART_FINDINGS.md new file mode 100644 index 0000000..f4f49d2 --- /dev/null +++ b/docs/RE_DART_FINDINGS.md @@ -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//.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: }` — 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>` 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`; 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`. diff --git a/franklinwh_agate_client/device_local.py b/franklinwh_agate_client/device_local.py new file mode 100644 index 0000000..2199860 --- /dev/null +++ b/franklinwh_agate_client/device_local.py @@ -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) diff --git a/franklinwh_agate_client/protocol/__init__.py b/franklinwh_agate_client/protocol/__init__.py new file mode 100644 index 0000000..a3ead6d --- /dev/null +++ b/franklinwh_agate_client/protocol/__init__.py @@ -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", +] diff --git a/franklinwh_agate_client/protocol/commands.py b/franklinwh_agate_client/protocol/commands.py new file mode 100644 index 0000000..22c1e80 --- /dev/null +++ b/franklinwh_agate_client/protocol/commands.py @@ -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 diff --git a/franklinwh_agate_client/protocol/crc.py b/franklinwh_agate_client/protocol/crc.py new file mode 100644 index 0000000..27592b7 --- /dev/null +++ b/franklinwh_agate_client/protocol/crc.py @@ -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") diff --git a/franklinwh_agate_client/protocol/crypto.py b/franklinwh_agate_client/protocol/crypto.py new file mode 100644 index 0000000..12ea8c8 --- /dev/null +++ b/franklinwh_agate_client/protocol/crypto.py @@ -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 diff --git a/franklinwh_agate_client/protocol/envelope.py b/franklinwh_agate_client/protocol/envelope.py new file mode 100644 index 0000000..4740d1f --- /dev/null +++ b/franklinwh_agate_client/protocol/envelope.py @@ -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, + ) diff --git a/franklinwh_agate_client/protocol/framing.py b/franklinwh_agate_client/protocol/framing.py new file mode 100644 index 0000000..319cecc --- /dev/null +++ b/franklinwh_agate_client/protocol/framing.py @@ -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 diff --git a/franklinwh_agate_client/wifi.py b/franklinwh_agate_client/wifi.py index c46b7a8..8ae778c 100644 --- a/franklinwh_agate_client/wifi.py +++ b/franklinwh_agate_client/wifi.py @@ -1,23 +1,215 @@ -""" -Wi-Fi Manager for AGATE device network operations. +"""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: - """Manages Wi-Fi connection to AGATE device onboard hotspot.""" - - def __init__(self): + """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 - - def query_connect_wifi(self, ssid, password=None): - """Query/connect to AGATE Wi-Fi.""" - # Reverse engineered operation: queryConnectWifi - pass - - def wifi_onboarding_add_wifi(self, ssid, password): - """Add Wi-Fi network via onboarding.""" - # Reverse engineered operation: wifiOnboardingAddWifi - pass - - def get_wifi_signal_strength(self): - """Get current Wi-Fi signal strength.""" - pass + + # -- 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