Files
franklinwh-agate-client/docs/RE_DART_FINDINGS.md
T

23 KiB
Raw Blame History

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.

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, radix: 32) (literal 0x20 at the call site; null → 0).
  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):

  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) jsonDecodes 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-15) — socket cmd space + live capture

All pre-2026-10-15 probes used off_8 ids (25/0/29/30) and were dropped silently — the socket transport speaks the off_14 id space (§4), so the device saw unknown commands. A PCAPdroid capture of the working app shows real socket frames: {"cmdType":1101,…} (queryDeviceInfo, empty SN) and {"cmdType":1102,"equipNo":"1006005A03X25160132",…} (922 B). Open: dst IP:port + direction per frame (need capture metadata / full .pcap), the meaning of 1102 (no pool cmd maps to 0x44E), and the 17-char equipNo (sub-device? battery unit? reply convention?). Current probes use socket-space ids (1101/1307/1705/1707) via scripts/host_probe3.py.

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.