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:
1 parent
c2d261fb34
commit
a87dbe200e
10 files changed
+1432
-15
No files matched your search
@@ -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`).
|
||||
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
+207
-15
@@ -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:
|
||||
"""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
|
||||
|
||||
def query_connect_wifi(self, ssid, password=None):
|
||||
"""Query/connect to AGATE Wi-Fi."""
|
||||
# Reverse engineered operation: queryConnectWifi
|
||||
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 wifi_onboarding_add_wifi(self, ssid, password):
|
||||
"""Add Wi-Fi network via onboarding."""
|
||||
# Reverse engineered operation: wifiOnboardingAddWifi
|
||||
pass
|
||||
def leave_device_hotspot(self) -> None:
|
||||
self.backend.leave()
|
||||
self.connected = False
|
||||
|
||||
def get_wifi_signal_strength(self):
|
||||
"""Get current Wi-Fi signal strength."""
|
||||
pass
|
||||
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
|
||||
Reference in new issue
Block a user