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.
17 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/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::calculateis used infromJsonString(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):
s = obj.toJsonString(); split on",".a= UTF-8 ofpart[0] + "," + part[1](i.e.{"cmdType":..and"equipNo":..).b= UTF-8 ofjoin(",", parts[2..])(the rest).keyInt = int.tryParse(key, radix: …).out[i] = (b[i] + i + len(a) + keyInt) & 0xFFfor all i, then appendaunchanged.
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
ahandling should be confirmed against a captured frame before trusting it byte-for-byte.
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
BytesToInt16/Int16ToBytesLE 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): 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
encryptObjectradix + trailing-header handling. - Local HTTP port + TLS behavior for
https://IP:PORT(device self-signed cert?). - Local socket port confirmation (
18000candidate) and whether local RPC uses socket vs/terminalHTTP vs local MQTT. - 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.