518 lines
28 KiB
Markdown
518 lines
28 KiB
Markdown
# 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 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) while responses are `s || 0x58 || transform(tail)` (split at the
|
||
second `0x58` marker). Both halves share the one additive stream.
|
||
|
||
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: <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.
|
||
- `sendObject`: stamps `snno = increaseSN()`, logs
|
||
`Socket Manager send object: <json>`, 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).
|
||
|
||
## 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~~ — **resolved
|
||
from disassembly (see §5): radix 32, plaintext header first
|
||
(`S1 || transform(S2)`), `decryptData` marker model. Remaining: first
|
||
confirmed live round-trip.**
|
||
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 port confirmation~~ — **partially resolved: `9000` open on
|
||
one unit** (§8 empirical); the `0x4650` constant is not listening there,
|
||
so the port may be firmware-/device-dependent. Remaining: first confirmed
|
||
RPC round-trip — the corrected request bytes (raw `S1 || transform(S2)`,
|
||
radix 32, int `snno`, seconds `timeStamp`, true SN
|
||
`A03X2516013210060005`) are ready via
|
||
`battery --probe`; needs a host on `10.100.1.0/24`.
|
||
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`.
|