Probes 10.100.1.1:9000,18000, sends 0x19/0x1D/0x1E envelopes, extracts currentBatterySoc/soc/avg(socArray). Includes offline extract_soc tests, BATTERY_POC docs, README usage, and empirical port-9000 RE notes.
19 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.
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.
- 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 — a length-prefixed framing service (int16LE length), consistent
with the app's socket layer:
- every TLS
ClientHellogetsUNEXPECTED_EOFmid-handshake — first two bytes16 03= int16LE790, so the decoder waits for a 790-byte frame that never completes, then closes on idle; - cleartext HTTP
GET(47 45=17735) hangs forever (read timeout). - A complete 147-byte frame (int16LE length + encrypted
QUERY_DEVICE_INFOenvelope,equipNo = X25160132, key fromsum(utf8(sn))&0xFF) is accepted but met with zero bytes — every command tried (0x00/0x01/0x02/0x06) and both key radices (16/32). Content-level rejection; the leading hypothesis isequipNovalidation against the real serial. Pending: retry with the true SN.
- every TLS
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— 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— resolved on one unit (2026-10-13):9000(§8 empirical). The0x4650constant is not listening; the port may be firmware-/device-dependent. Remaining: first confirmed RPC round-trip (blocked on the real SN — see §8 empirical).- 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.