# 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 order used by `toJson`: ``` cmdType, equipNo, type, timeStamp, snno, len, crc[, dataArea] ``` `dataArea` is appended **only when the payload is present and non-empty**; there is **no** `data` key on the wire. `cmdType` = `CommunicationCmd` raw, `type` = `CommunicationOperate` raw (always 0 on the `communicate`-factory path — the ctor zeroes it), `equipNo` = device SN (`"00000000"` fallback when empty), `timeStamp` = whole **seconds** (`micros ~/ 1000 ~/ 1000`), `snno` = `int` sequence (see below, **not** the serial string), `len` = UTF-8 byte length of the compact JSON of the payload, `crc` = `Crc32.calculate(jsonEncode(data))` as **uppercase** unpadded hex. Null/empty payload ⇒ `len == 0`, `crc == ""`, no `dataArea` key. - `CommunicationOperate`: `query=0, set=1, command=2, modify=5`. - `Crc32::calculate` is used in `fromJsonString` (integrity check of the payload). - `toJsonString()` = `jsonEncode(toJson())` — compact JSON, the input of `encryptObject` (see §5). `toString()` likewise returns `toJsonString()`. - `CommunicationObject(cmd, equipNo, {data})` ctor zeroes `type`/`snno`, stamps seconds, and routes `data` through the `data=` setter (which recomputes `len`/`crc`). - `sendObject` stamps `snno = increaseSN()` first: a 1-based counter that wraps to 1 past 9999 (manager field starts at 0, so the first request carries `snno == 1`). - The `communicate` factory (`0xc9264c`) builds the `data` map from the caller's `arguments` (e.g. `queryAPowerData` passes `{"update": 2}` with cmd `deviceApowerInfo`/`0x19`, operate `query`) and calls the ctor with a named `data:` argument. On socket mode it **always seeds the map with `"opt"`** (the operation type — `0` for query) before merging arguments, so the app's apower query sends `dataArea = {"opt": 0, "update": 2}` and its device-info query sends `{"opt": 0, "minProtocolVer": "V1.00.00"}` (empty/non-query args still yield `{"opt": 0}`). This matches the live capture sizes (§8). ## 4. `CommunicationCmd` (raw → name) `off_8` values (the enum's first id field): ``` 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.) **Two id spaces.** `CommunicationCmd::cmd(cmd, mode)` (0xc92894) returns the *second* id field (`off_14` in the pool) for `socket`/`bluetooth`/`local` modes and throws `"do not support http communication"` for `http`. The socket-transport ids live in `SOCKET_CMD_TYPE` (`protocol/commands.py`) — e.g. `queryDeviceInfo → 1101 (0x44D)`, `deviceApowerInfo → 1307 (0x51B)`, `bms → 1705`, `ems → 1707`. The `off_8` values above are **not** used on the socket/BLE wire. Verified live: a PCAPdroid capture of the working app shows `{"cmdType":1101,"equipNo":"00000000",…}` with the S1-plaintext-first framing (§5). ## 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, unpadded. E.g. SN `A03X2516013210060005` → byte sum `1052 & 0xFF = 28` → `"1c"`. **`encryptObject(object, key)`** (object in arg slot 0, key in slot 1): 1. `s = object.toJsonString()`; split on `","`. 2. `S1 = parts[0] + "," + parts[1] + ","` (header, *with* trailing comma); `a = UTF-8(S1)`. 3. `S2 = join(",", parts[2..])` (the rest); `b = UTF-8(S2)`. 4. `keyInt = int.tryParse(key)` (default radix **16** — two live frames decrypt cleanly only with radix 16 and then verify byte-exact on `len`/CRC; an earlier radix-32 reading of a nearby `0x20` literal is superseded). 5. `out[i] = (b[i] + i + len(a) + keyInt) & 0xFF` for all i (so each tail byte is shifted by its *absolute* JSON index plus the key). 6. Result = `a ++ out` — the plaintext header goes on the wire **first**, transformed tail after (`addAll` onto the header copy). **`decryptData(bytes)`** (single arg, the accumulated receive buffer): as recovered from the disassembly (two-`0x58`-marker scan, `s + "}"` equipNo probe, `s + "," + T` return). NOTE (2026-10-16): live socket replies decrypt with the *request-side* split instead (see below); the marker model is kept for the unverified Bluetooth path. 1. `i = indexWhere(byte == 0x58)`, `j = indexWhere(byte == 0x58, start: i+1)`; either missing → return `""`. 2. `s = utf8(wire[0..j])`; `probe = jsonDecode(s + "}")`, null → `""`. 3. `equipNo = probe["equipNo"]`; `key = generateSecretKey(equipNo)`; `keyInt = int.tryParse(key, radix: 32) ?? 0`. 4. `tail = wire[j+1..]`; `out[k] = (tail[k] - k - len(utf8(s + ",")) - keyInt) & 0xFF`. 5. Return `s + "," + utf8(out)` (the `","` re-inserts the comma the `0x58` separator replaced). The caller (`_onSocketReceiveData`) `jsonDecode`s that, builds `CommunicationObject.fromJson`, logs `Socket Manager receive object: …`, and clears the buffer; `""`/parse failures keep accumulating ("receive incomplete data"). So requests are `S1 || transform(S2)` (split at the computable header length); live socket replies use the identical framing and split (`decrypt_wire` locates the header at the 2nd comma — no ids needed up front). Both halves share the one additive stream. Library shape: `protocol/messages.py:EnvelopeCodec` owns the bytes (`encode`/`decode` under one device key); `device_local`, `battery` and `power` implement the business calls on top; `scripts/` are thin CLI wrappers over those calls. Live corroboration (2026-10-15, PCAPdroid capture of the working app): a request frame opens with the ASCII header `{"cmdType":1101,"equipNo":"00000000",` — plaintext `S1` first, exactly as derived, with the socket-space id (§4). A second frame shows `{"cmdType":1102,"equipNo":"1006005A03X25160132",` (larger payload, direction under analysis — see §8). Note the captures were exported as text, so non-UTF-8 bytes were replaced with U+FFFD and only the ASCII prefixes survive; a binary `.pcap` export is still wanted. ## 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. - `sendObject`: stamps `snno = increaseSN()`, logs `Socket Manager send object: `, derives the key with `generateSecretKey(equipNo)`, calls `encryptObject(object, key)`, then `_sendData`, which checks `connected` status and calls `ClientSocket.send` → **`dart:io _Socket.add` with the bytes as-is: raw send, no length prefix, no markers, no handshake** — then clears the receive buffer. - Receive: socket `listen` chunk → `ClientSocket._onSocketReceiveData` → `SocketManager._onSocketReceiveData` → buffer `addAll` → `decryptData` in try/catch (`""` = keep accumulating) → `jsonDecode` → `CommunicationObject.fromJson` → `Socket Manager receive object: …` log. ### Empirical — live aGate on its SoftAP (2026-10-13, from a client on `10.100.1.0/24`) Full TCP sweep of `10.100.1.1` (ports 1–65535): exactly **two** ports open — **53** and **9000**. No 80/443, no web UI, and **18000 (`0x4650`) is closed** on this unit. No `9000` reference exists anywhere in the app (Dart or Java) — but see the 2026-10-15 update below. - **Port 53** — authoritative, *closed* DNS: well-formed `NXDOMAIN` for everything (even `example.com`), no PTR for `1.1.0.100.in-addr.arpa`, no forwarding (the SoftAP is an island). Serves some local name pattern we haven't identified yet. - **Port 9000** — accepts TCP, reads one frame, answers zero bytes, closes — for *every* probe (TLS ClientHello, HTTP GET, MQTT CONNECT, exact-spec envelopes raw/split/length-prefixed, greeting-first reads). ### Update (2026-10-16) — binary capture fully decoded Raw `.bin` payloads (no text mangling) decrypt completely offline with radix 16 (`tests/fixtures/*.bin` are golden vectors): - Discovery request (153 B): `{"cmdType":1101,"equipNo":"00000000",…, "dataArea":{"opt":0,"minProtocolVer":"V1.00.00"}}`, key `"80"`/n=128. - Device-info reply (1154 B): `cmdType` **1102**, `equipNo` `10060005A03X25160132` (the unit's IBG SN — label SN `A03X2516013210060005` shares its trailing 12 chars), `len` 1024 exact, CRC exact, `dataArea.result == 0` with the full version inventory (`FHP_NUM: 2`, two `FHP_SN`, two `BMS_SN`, `protocolVer V1.12.36`). Encrypted with the **gateway** key (`"1c"`/n=28) while addressed to the IBG SN — the gateway is the crypto endpoint; sub-unit addressing rides inside. Reply rules learned: same `S1 || transform(S2)` framing both directions; reply id observed as request + 1 (`1101 → 1102`); universal split at the 2nd comma needs no ids up front (`decrypt_wire`). The remaining unknown is the live endpoint (dst IP:port + direction per capture). Superseded notes: pre-2026-10-16 probes additionally used the wrong key radix (32 instead of 16) and mostly omitted the `opt`-seeded payload, so every silent run is fully explained by bytes, with no need for exotic causes. Current probes use socket-space ids + radix 16 + `opt` payloads via `scripts/host_probe6.py`. ### Live results (2026-10-16, host probes with corrected bytes) - Discovery + directed `queryDeviceInfo` + `BMS`/`EMS` all answer on `10.100.1.1:9000` (replies: `1102` device info, `1706` BMS, `1708` EMS). - `DEVICE_APOWER_INFO` (`1307`) answers `{"result": 1, "reason": -1}` — rejected (args or unsupported on this firmware). - `BMS` **requires the pack id** (`queryBmsInfoBySocket` passes `arguments={"id"}`): id-less queries return the all-zero `id: 0` slot; `id: 1`/`2` select the `…B1`/`…B2` packs whose `batSoc`/`batSoh` are the charge/health readings. `scripts/host_probe7.py` reads both packs. ## 14. Direct-connect flow (manual AP join → return to app) `sections/menus/direct_connect/` owns the SoftAP onboarding path: - The *manual connect* button (`hotspot_guide_widget.dart`) calls `goToSystemWifiSettings()` → `OpenSystemWifiSetting` method channel → OS Wi-Fi picker. The app goes to background. - On return, the page (`viewDidLoad`, plus the provider-state listener in `direct_connect.dart`) re-evaluates: with a device SN selected it calls `DirectConnectProvider.socketQueryDeviceInfo()` (also fired from `onSelectHotspot` once wifi state settles). - `socketQueryDeviceInfo` first awaits `CommunicationService.directConnect()`, which is true when `connectedFhpAp()` (current SSID is the FHP AP) **or** a BLE device is connected — otherwise it aborts silently. - It then issues `queryDeviceInfo` in the *current* transport mode (`_queryDeviceInfoFuture`, args `{"minProtocolVer": "V1.00.00"}`, or `{"installInfo": 2}` for mqtt mode which is HTTP-forwarded instead). In socket mode `_connectBeforeRequest` dials `getWifiGatewayIP():18000` (`"10.100.1.1"` fallback) with ~2 retries. - On the reply it updates the device model (`updateFromDeviceInfoInitDataEntity`) and continues with `_queryTrustedDeviceModel` → `_checkConnectedNotTrustedAp`. - The `communicate` factory always seeds socket payloads with `"opt"` (operation type, `0` for query), so the on-air discovery frame is shaped `{"opt": 0, "minProtocolVer": "V1.00.00"}` (§3). Implication: on the SoftAP the app speaks **only** to the gateway IP, port **18000**. Port `9000` appears nowhere in the app; `18000` is closed on the tested unit, so even the app's own socket path cannot work there — consistent with all-silent probes. ## 15. Transport selection — one mode at a time, BLE provisions Wi-Fi - `DirectConnectProvider.initSelectedMode`: on the FHP AP → `socket`; otherwise, if a BLE device is connected (or a previous selection exists) → `bluetooth` (+ `initScan`); else `socket`. Every request carries its transport in `selectedMode` (`field_5b`); `sendObjectOrganized` routes to exactly one manager. Queries never run on both transports at once. - Switching AP → socket explicitly tears down BLE first (`onSelectHotspot`: `disconnectBluetooth` behind the `switchBluetoothToHotspotTip` dialog). - `directConnect()` (provider and service variants) is true when on the FHP AP **or** BLE-connected — the two are alternative routes to one device. - BLE and Wi-Fi *cooperate* at the commissioning layer, not the query layer: ESP BLE provisioning (`prov-session/scan/config`, sec0/sec1) delivers the home Wi-Fi SSID/password to the device **over BLE**; the socket/HTTP hotspot APIs (`/terminal/*`, `wifi_iot` join) take it from there. Same envelope + cipher on both transports (§5, §9). ## 16. Live power readings - `queryDeviceRealTimeData` — what the home page polls (`DeviceRealTimeDataMixin`, `startPollingDeviceRealTimeData`): cmd `deviceDetailInfo` (socket `1301`), no arguments on socket (`{"refreshData": 2}` is added only for mqtt mode). Reply `dataArea`: `apowerCount`, **`totalPower`** (the household-draw headline). - `queryLoadStatusInfo` — cmd `loadStatus` (socket `1411`), no arguments. Reply per smart switch: `Sw1Volt`/`Sw2Volt`, `SW1Curr`, `SW1ExpPower` (`SW2…`), `SW1ExpEnergy`, `index`. (`loadStatus2`, socket `1415`, is the newer variant.) - `queryEmsInfoBySocket` — cmd `ems` (socket `1707`), no arguments. Reply carries `gridVol1/2`, `gridCurr1/2`, `loadCurr1/2`, `gridFreq`, relay states. Household draw ≈ `(loadCurr1 + loadCurr2) × (gridVol1 + gridVol2) / 2` (library: `power.ems_load_watts`). - `InstallSunRunSummaryEntity` (`homeloadPower`, `solarPower`, `gridPower`, `apowersPower`) is parsed in the bean factory layer, not off the local socket — cloud/HTTP-side entity. Library: `power.get_power()` tries detail → load → EMS in order (`franklinwh_agate_client/power.py`); `LocalDeviceClient` gained `query_detail_info()` / `query_load_status()`. ## 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. Live verification of the BLE frame layout (settled from disassembly in §9 — `magic ++ crc ++ totalLen ++ sn ++ content`, radix-32 CRC pairs — but no live BLE exchange yet) and the GATT service/notify characteristics (write char matches `0000f002-…`). 2. ~~Exact `encryptObject` radix + framing~~ — **resolved live (2026-10-16, see §5 and `tests/fixtures/*.bin`): radix 16, plaintext header first (`S1 || transform(S2)`) both directions.** (An earlier radix-32 reading of a nearby `0x20` literal is superseded.) The `decryptData` `0x58`-marker model is kept for the unverified Bluetooth path only. 3. ~~Local HTTP port + TLS behavior for `https://IP:PORT`~~ — **revised (2026-10-13, §8 empirical):** no HTTP/TLS port exists on the device at all (only 53 + 9000). Either local mode is socket-only on this firmware, or the HTTP service is state-dependent and wasn't running. Revisit if a `/terminal` response is ever captured from the SoftAP. 4. ~~Local socket round-trip~~ — **done (2026-10-16): full two-way RPC on `10.100.1.1:9000`** (discovery → `1102` info, per-pack BMS `batSoc`, EMS telemetry). Remaining: the live endpoint's exact role for port 9000 vs the app's `18000` constant (closed here), and the `1102 = req + 1` reply-numbering rule across more commands. 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`.