28 KiB
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.
- Host is
- 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.apkDEX 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.apkat/tmp/opencode/jadx-out/base/(sources/— 18,617.javafiles — andresources/incl. the decodedAndroidManifest.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, …) needREA_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 decompiledsources/has covered everything so far.
- Java 21 (
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::calculateis used infromJsonString(integrity check of the payload).toJsonString()=jsonEncode(toJson())— compact JSON, the input ofencryptObject(see §5).toString()likewise returnstoJsonString().CommunicationObject(cmd, equipNo, {data})ctor zeroestype/snno, stamps seconds, and routesdatathrough thedata=setter (which recomputeslen/crc).sendObjectstampssnno = increaseSN()first: a 1-based counter that wraps to 1 past 9999 (manager field starts at 0, so the first request carriessnno == 1).- The
communicatefactory (0xc9264c) builds thedatamap from the caller'sarguments(e.g.queryAPowerDatapasses{"update": 2}with cmddeviceApowerInfo/0x19, operatequery) and calls the ctor with a nameddata:argument. On socket mode it always seeds the map with"opt"(the operation type —0for query) before merging arguments, so the app's apower query sendsdataArea = {"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):
s = object.toJsonString(); split on",".S1 = parts[0] + "," + parts[1] + ","(header, with trailing comma);a = UTF-8(S1).S2 = join(",", parts[2..])(the rest);b = UTF-8(S2).keyInt = int.tryParse(key)(default radix 16 — two live frames decrypt cleanly only with radix 16 and then verify byte-exact onlen/CRC; an earlier radix-32 reading of a nearby0x20literal is superseded).out[i] = (b[i] + i + len(a) + keyInt) & 0xFFfor all i (so each tail byte is shifted by its absolute JSON index plus the key).- Result =
a ++ out— the plaintext header goes on the wire first, transformed tail after (addAllonto 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.
i = indexWhere(byte == 0x58),j = indexWhere(byte == 0x58, start: i+1); either missing → return"".s = utf8(wire[0..j]);probe = jsonDecode(s + "}"), null →"".equipNo = probe["equipNo"];key = generateSecretKey(equipNo);keyInt = int.tryParse(key, radix: 32) ?? 0.tail = wire[j+1..];out[k] = (tail[k] - k - len(utf8(s + ",")) - keyInt) & 0xFF.- Return
s + "," + utf8(out)(the","re-inserts the comma the0x58separator replaced). The caller (_onSocketReceiveData)jsonDecodes that, buildsCommunicationObject.fromJson, logsSocket 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): init0xFFFF, poly0xA001(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 fromMqttStatus). - Method surface:
setup / connect / disconnect / subscribe / unsubscribe / publish(the earlierinit / 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 = randomUUIDper setup, keepAlive = 10 s. - Credentials:
CognitoCachingCredentialsProvider(unauthenticated Cognito identity pool5deecc22-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.
connecttakes 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 theMqttStatusmapping 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(gettersconnected/closed), status enumSocketStatus:closed / connected / connecting.CommunicationService._connectBeforeRequestcallsCommunicationCenter.connectOrganized(host:, port:)with:- host = stored host, else
"10.100.1.1"(device hotspot gateway); - port constant
0x4650 = 18000(candidate local port), - a
Durationand retry loop (up to ~2 retries) are involved.
- host = stored host, else
sendObject: stampssnno = increaseSN(), logsSocket Manager send object: <json>, derives the key withgenerateSecretKey(equipNo), callsencryptObject(object, key), then_sendData, which checksconnectedstatus and callsClientSocket.send→dart:io _Socket.addwith the bytes as-is: raw send, no length prefix, no markers, no handshake — then clears the receive buffer.- Receive: socket
listenchunk →ClientSocket._onSocketReceiveData→SocketManager._onSocketReceiveData→ bufferaddAll→decryptDatain 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
NXDOMAINfor everything (evenexample.com), no PTR for1.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):
cmdType1102,equipNo10060005A03X25160132(the unit's IBG SN — label SNA03X2516013210060005shares its trailing 12 chars),len1024 exact, CRC exact,dataArea.result == 0with the full version inventory (FHP_NUM: 2, twoFHP_SN, twoBMS_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/EMSall answer on10.100.1.1:9000(replies:1102device info,1706BMS,1708EMS). DEVICE_APOWER_INFO(1307) answers{"result": 1, "reason": -1}— rejected (args or unsupported on this firmware).BMSrequires the pack id (queryBmsInfoBySocketpassesarguments={"id"}): id-less queries return the all-zeroid: 0slot;id: 1/2select the…B1/…B2packs whosebatSoc/batSohare the charge/health readings.scripts/host_probe7.pyreads 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) callsgoToSystemWifiSettings()→OpenSystemWifiSettingmethod channel → OS Wi-Fi picker. The app goes to background. - On return, the page (
viewDidLoad, plus the provider-state listener indirect_connect.dart) re-evaluates: with a device SN selected it callsDirectConnectProvider.socketQueryDeviceInfo()(also fired fromonSelectHotspotonce wifi state settles). socketQueryDeviceInfofirst awaitsCommunicationService.directConnect(), which is true whenconnectedFhpAp()(current SSID is the FHP AP) or a BLE device is connected — otherwise it aborts silently.- It then issues
queryDeviceInfoin the current transport mode (_queryDeviceInfoFuture, args{"minProtocolVer": "V1.00.00"}, or{"installInfo": 2}for mqtt mode which is HTTP-forwarded instead). In socket mode_connectBeforeRequestdialsgetWifiGatewayIP():18000("10.100.1.1"fallback) with ~2 retries. - On the reply it updates the device model
(
updateFromDeviceInfoInitDataEntity) and continues with_queryTrustedDeviceModel→_checkConnectedNotTrustedAp. - The
communicatefactory always seeds socket payloads with"opt"(operation type,0for 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); elsesocket. Every request carries its transport inselectedMode(field_5b);sendObjectOrganizedroutes to exactly one manager. Queries never run on both transports at once.- Switching AP → socket explicitly tears down BLE first
(
onSelectHotspot:disconnectBluetoothbehind theswitchBluetoothToHotspotTipdialog). 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_iotjoin) 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): buildscontent ++ [00 00 00 00] ++ int16LE(totalLen) ++ int16LE(value) ++ sn, computesCrc16::calculateover that, hex→2 bytes, writes them into the00 00 00 00placeholder (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
btsnoopcapture.
- Heartbeat = Dart timer (
startBluetoothHeartbeat,cancelBluetoothHeartbeat,_bluetoothHeartbeat,[Bluetooth] Heartbeat Timeout), commandbluetoothHeartbeat(0x0d).
ESP provisioning (WiFi onboarding over BLE) — esp_provisioning_ble
/terminal-style endpoints (ESP protocomm):prov-session,prov-scan,prov-config,custom-data.EspProvhandlesEstablishSessionStatus;security.dart(sec0) &security1.dart(sec1): ECDH (Curve25519) → shared key, verification,abcd1234present as a PoP, AES-CTR (crypt.dart: key/iv/crypt).- Service UUID
021a9004-0382-4aea-bff4-6b3f1c5adfb4is used byfranklinwh2/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.
- if
getDeviceApDefaultPwd(sn):- if
sn.length == 20→sn.substring(8); else →"12345678".
- if
connectDeviceWifiWithSSID(ssid, pwd)→wifi_iotconnect;disconnectDeviceWifi→disconnect;forceWifiUsagetoggles the process binding; caches last ssid/pwd in statics.
The length check is
field_7 == 0x28on a Smi length ⇒length == 20. Derived values are the offline fallback; the authoritative values come from/terminal/getHotSpotInfoand/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:
- Exact BLE frame field order & the 2-byte magic semantics (confirm with
btsnoop). Exact— resolved from disassembly (see §5): radix 32, plaintext header first (encryptObjectradix + trailing-header handlingS1 || transform(S2)),decryptDatamarker model. Remaining: first confirmed live round-trip.Local HTTP port + TLS behavior for— 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 ahttps://IP:PORT/terminalresponse is ever captured from the SoftAP.Local socket port confirmation— partially resolved:9000open on one unit (§8 empirical); the0x4650constant is not listening there, so the port may be firmware-/device-dependent. Remaining: first confirmed RPC round-trip — the corrected request bytes (rawS1 || transform(S2), radix 32, intsnno, secondstimeStamp, true SNA03X2516013210060005) are ready viabattery --probe; needs a host on10.100.1.0/24.- Which class in
classes5.dexsets theX-Device-*headers and their exact values (string-pool location confirmed; class binding in progress — §13). - (Tooling, optional) Enable REA's DEX tools: obtain a jadx-headless-mcp 0.7.1 JAR
and set
REA_JADX_MCP_JAR.