Skip to content

voidstarr/franklinwh_local

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 

Repository files navigation

franklinwh-local

A direct-TCP (cloudless) Python client for the FranklinWH aGate energy gateway, reverse-engineered from the official Android app com.Franklinwh.FamilyEnergy and verified against a real packet capture of a live local-mode session.

Why

The two existing community libraries (richo/franklinwh-python, david2069/franklinwh-cloud) both speak the cloud HTTPS API at e-api.franklinwh.com. That requires an internet connection, a FranklinWH account, and trust in the cloud being available.

This library targets the local transport: when you join the gateway's built-in Wi-Fi access point, it exposes a TCP service on 10.100.1.1:9000 that the official app uses for installation, diagnostics, and the "Direct Connect" menu. Same data, no cloud.

Install

pip install -e .

Quick start

from franklinwh_local import LocalClient

# Connect your machine to the aGate's hotspot first, then:
with LocalClient() as c:                     # SN defaults to "00000000"
    info = c.query_device_info()             # discovery
    c.equip_no = info["dataArea"]["IBG_SN"]  # learn the real serial
    print(c.query_device_detail())
    print(c.query_mode())

Wire format (verified)

Every TCP segment carries one self-contained frame:

{"cmdType":<int>,"equipNo":"<SN>",<obfuscated_continuation>

The obfuscated continuation, once decoded, is the rest of a JSON object that completes the envelope:

"type":0,"timeStamp":1768698555,"snno":181,"len":37,
"crc":"A474C16F","dataArea":{"opt":0,"minProtocolVer":"V1.00.00"}}
Field Meaning
cmdType integer opcode from CommunicationCmd.spec.req_code (e.g. 1101 for queryDeviceInfo). Responses use cmdType + 1.
equipNo "00000000" for discovery, or the real device serial.
type request/response type tag — always 0 in the captured session.
timeStamp UNIX seconds when the message was built.
snno per-connection sequence number (increments on each request).
len byte length of the JSON-encoded dataArea value only.
crc CRC-32 (zlib/standard) of those same bytes, 8 uppercase hex digits.
dataArea inner payload (e.g. {"opt":0} for queries).

Per-byte obfuscation transform applied only to the bytes after the cleartext header:

secret  = derive_secret_key_int(equipNo)         # see encryption.py
enc[i]  = (plain[i] + i + secret) & 0xFF
plain[i] = (enc[i]   - i - secret) & 0xFF

The placeholder SN "00000000" observed in the reference capture yields secret 0xA5. The library tries the formula first and silently falls back to a 256-way brute force on receive, so unknown SNs still decode.

The 44-entry CommunicationCmd enum (e.g. queryDeviceInfo, deviceApowerInfo, solarInfo, ems, mode, modeV2, modeSettingV2, connectWifi, scanWifiList, rebootDevice, overloadCutOff, …) is exposed as franklinwh_local.CommunicationCmd / COMMUNICATION_CMDS.

The cloud / MQTT path uses AES-GCM-NoPadding via PointyCastle and AWS IoT topics fwh/{c2s,s2c}/{auth,rt,mng,device,debug}/<sn> — those are deliberately out of scope here.

Status

Verified against PCAPdroid_17_Jan_17_09_10.pcap (a real session):

  • ✅ Host 10.100.1.1, port 9000
  • ✅ Cleartext header layout {"cmdType":<int>,"equipNo":"<SN>",
  • cmdType is the integer req_code, not the enum name string
  • ✅ Response cmdType = request cmdType + 1
  • ✅ Inner envelope keys (type, timeStamp, snno, len, crc, dataArea) and their order
  • len is the JSON-encoded dataArea byte length
  • crc = standard CRC-32 (zlib) of those same bytes, uppercase hex
  • ✅ Per-byte additive obfuscation enc[i] = plain[i] + i + secret
  • ✅ One full request frame is reproduced byte-for-byte by the test suite (test_build_request_matches_pcap_byte_exactly)
  • ✅ One frame per TCP segment, no length prefix, no delimiter
  • ✅ 44-entry CommunicationCmd table

Not yet verified — only seen for the SN in the capture:

  • ❓ Exact derive_secret_key_int formula. The single observed data point ("00000000" → 0xA5) is consistent with (sum(utf8(SN)) + len(SN) + 29) & 0xFF, but other formulas also fit. Receive-side brute force makes this a non-issue for parsing; if your device rejects what we send, that's the first thing to investigate.
  • ❓ Behaviour for unknown opcodes. Only queryDeviceInfo (1101), deviceDetailInfo (1301), modeV2 (1725) and a handful of others actually appear in the capture; the rest of the table comes from static analysis of the AOT snapshot.

If you have an aGate and can capture a session for an SN that isn't covered here, please share — even a few packets are enough to nail down derive_secret_key_int for good.

Test

pip install -e '.[dev]'
pytest

The unit tests include real bytes from the captured session as fixtures, and exercise the full encrypt/parse round-trip plus an exact byte-for-byte build of a captured request. They do not require a real device.

License

MIT. The reverse-engineering work targets the publicly distributed app and the protocol it exposes on the device's own hotspot for interoperability purposes.

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

1 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages