franklinwh-direct-connect-api
Unofficial Python library and CLI for the FranklinWH aGate local broker protocol —
the JSON cmdType frames exchanged over TCP/9000, the local interface the official
FranklinWH app calls "Direct Connect." This is the lower-level device↔broker channel
that the cloud sendMqtt REST relay (see
franklinwh-cloud) is layered on top of.
Status: alpha. The protocol was reverse-engineered from packet captures. Frame parsing/encoding and the offline tooling are well tested; live transport against real hardware should be validated on your own LAN.
This is still work in progress - as some functions have not been fully tested and/or exposed yet.
For example (there may be others):
- set operating mode specific reserved state of change
- battery dispatch to force charge and discharge
- set battery or grid inverter power (in watts) for battery dispatch operations
- determining battery capabilities for the AC inverter (in watts) and battery capacity (in watt hours)
Why this exists
A FranklinWH aGate can be reached three different ways:
- The FranklinWH Cloud API — what the official mobile app and the FleetView installer portal use. Capable, but it routes through FranklinWH's servers and needs an account and an internet connection.
- Modbus TCP (SunSpec) — a standards-based local interface. Good for basic power/energy reads and a few setpoints, but only a narrow slice of what the device actually knows.
- The aGate's own local broker protocol — the proprietary
sendMqttcmdTypechannel on TCP/9000 that the app itself speaks under the hood. The official FranklinWH app calls this interface "Direct Connect." This is what this library implements.
The project exists to demonstrate that third path: a fully local, no-cloud, no-account way to query, control, and administer an aGate directly on your LAN — no FranklinWH servers, no Modbus gateway required. Because it's the device's native channel, it surfaces the deep structural telemetry the cloud strips out to keep the app fast (per-cell battery voltages, relay/contactor states, full physics arrays), plus configuration and control (operating mode, off-grid, reboot) — things neither a plain Modbus read nor the cloud expose together in one place.
On the name "Direct Connect": the official FranklinWH app uses this label for the local interface; this project adopts the term purely to identify the same channel. It implies no affiliation with, endorsement by, or support from FranklinWH — this is unofficial, reverse-engineered software, provided as-is.
Docs: https://david2069.github.io/franklinwh-direct-connect-api/ — Usage (full how-to) · Protocol & catalog (wire format + command catalog) · API reference. The same pages as markdown: docs/.
Setup
Requires Python 3.10+. No runtime dependencies (standard library only).
git clone https://github.com/david2069/franklinwh-direct-connect-api
cd franklinwh-direct-connect-api
python3 -m venv .venv # 1. create a virtualenv
source .venv/bin/activate # 2. activate it (Windows: .venv\Scripts\activate)
pip install -e . # 3. install this checkout (editable)
franklinwh-direct-connect --version # 4. verify
# franklinwh-direct-connect 0.4.0
Add ".[test]" instead of "." in step 3 if you want to run the pytest suite.
From PyPI (no checkout needed) the install is pip install franklinwh-direct-connect-api.
How to run it
franklinwh-direct-connect is a console script that pip install puts on your
PATH — it is not a .py file in this repo, so these do not work:
python franklinwh-direct-connect ... # can't open file '.../franklinwh-direct-connect'
python franklinwh_direct_connect_api.py ... # no such file
Run it one of these three ways:
franklinwh-direct-connect catalog # venv activated (recommended)
.venv/bin/franklinwh-direct-connect catalog # venv not activated — call it by path
python -m franklinwh_direct_connect_api catalog # module form; works anywhere the package imports
One name throughout, as of 0.4.0: distribution franklinwh-direct-connect-api, import
package franklinwh_direct_connect_api, CLI franklinwh-direct-connect. Direct
Connect is FranklinWH's own term for this protocol.
Migrating from 0.3.x and earlier
The import package was franklinwh_local and the CLI was franklinwh-local. Both still
work in 0.4.x and emit a DeprecationWarning; they are removed in 0.5.0.
from franklinwh_local import LocalClient # 0.3.x — deprecated
from franklinwh_direct_connect_api import DirectConnectClient # 0.4.0 onward
The alias re-exports the same objects, so isinstance, subclassing and is comparisons
hold across both spellings — you can migrate file by file. LocalClient and
LocalTransport also remain as aliases of DirectConnectClient / DirectConnectTransport.
To find every remaining use, run with PYTHONWARNINGS=error::DeprecationWarning.
For a ready-made application built on this library — REST API, MQTT / Home Assistant entities and a web UI — see the FranklinWH Direct Connect Bridge.
Everything below assumes the venv is activated. Offline commands
(catalog, decode, analyze, emulate) need no hardware — start with
franklinwh-direct-connect catalog to confirm your install works.
Troubleshooting the install
| Symptom | Cause / fix |
|---|---|
can't open file '.../franklinwh-direct-connect' |
You ran python franklinwh-direct-connect. Drop the python — it's a command, not a script. |
command not found: franklinwh-direct-connect |
The venv isn't activated (source .venv/bin/activate), or pip install -e . hasn't been run in it. |
No module named franklinwh_direct_connect_api |
You're on a different interpreter than the one you installed into. Check with which python and pip -V — both should point inside .venv. |
DeprecationWarning: 'franklinwh_local' was renamed |
Expected on 0.4.x if you import the old name. See Migrating; it stops working at 0.5.0. |
error: externally-managed-environment |
You ran pip install outside a venv on a Homebrew/system Python. Create and activate the venv first. |
Quick start
Decode a capture (offline, no hardware):
from franklinwh_direct_connect_api import iter_pcap_frames
for frame in iter_pcap_frames("capture.pcap"):
print(frame, frame.data_area)
Build and parse a single frame:
from franklinwh_direct_connect_api import encode_frame, decode_frame
wire = encode_frame(1301, "FAKEGATE90FJ09J6H4F2", {"opt": 0}) # power-flow poll
frame = decode_frame(wire)
assert frame.verify() # len + crc check
Talk to a live gateway:
from franklinwh_direct_connect_api import LocalClient
with LocalClient("10.100.1.1") as c: # the hotspot gateway IP
c.login() # 1101 -> 1102 (firmware manifest)
print(c.power_flow()["soc"])
print(c.smart_circuits())
bms = c.battery_cells(1) # per-cell telemetry, the deep read
print(bms["batVolt"]) # [3326, 3326, ...] mV per cell
print(bms["batTemp"]) # [18.8, 18.9, ...] degC per cell
print(bms["batSoh"]) # 94.9
Battery Management
franklinwh-direct-connect -i 192.0.2.110 battery # per-cell view
franklinwh-direct-connect -i 192.0.2.110 battery --watch 2 --for 10m
Per-cell voltages and temperatures, pack SoC/SoH, bus rails and states — one session,
local only. --watch/--for/--count monitor continuously; Ctrl-C stops.
Per-device reads
Four reads are addressed to a specific device rather than the gateway, and need an
id from device_check / battery_modules (devMap[].id):
franklinwh-direct-connect -i 192.0.2.110 battery_cells --id 1 # per-cell V + temps, SoC/SoH
franklinwh-direct-connect -i 192.0.2.110 power_electronics --id 1 # bus/grid/inverter V and A
franklinwh-direct-connect -i 192.0.2.110 device_firmware --id 1 # PE/BMS serials + versions
franklinwh-direct-connect -i 192.0.2.110 device_states --id 1 # DSP/main/PE/BMS states
Called without id they return the same keys with empty or zero values
(result=1 reason=-1) — easy to misread as "this command returns nothing".
Connecting (Direct Connection)
The aGate exposes TCP/9000 on its own WiFi hotspot — the same "Direct Connect" path the FranklinWH mobile app uses. To talk to it:
-
On the gateway/app, start Direct Connect → Connect via aGate hotspot.
-
Join the WiFi network
AP_<serial-suffix>(e.g.AP_F24170091). -
The gateway is the hotspot gateway IP (typically
10.100.1.1). PointLocalClient/ the CLI at it, or let the scanner find it:franklinwh-direct-connect scan gateway # auto-targets the hotspot gateway franklinwh-direct-connect --host 10.100.1.1 power_flow
(The app also offers a Bluetooth Direct Connect path; only the WiFi/TCP path is implemented here.)
Discover devices on the LAN
franklinwh-direct-connect scan 10.100.1.0/24 # scan a subnet
franklinwh-direct-connect scan gateway # just the hotspot gateway
franklinwh-direct-connect scan 10.0.0.0/24 --json
Detects open TCP 9000 (sendMqtt) and TCP 502 (Modbus), and by default confirms a 9000 listener by performing the login handshake.
Test without hardware (emulator)
franklinwh-direct-connect emulate --port 9000 # fake aGate replaying captured data
# then, in another shell:
franklinwh-direct-connect --host 127.0.0.1 power_flow
CLI
franklinwh-direct-connect catalog # list known cmdType codes (grouped)
franklinwh-direct-connect catalog --grep bms # filter by name/description/cloud call
franklinwh-direct-connect catalog --json # ... as JSON, with cloud equivalents
franklinwh-direct-connect decode capture.pcap # decode a pcap
franklinwh-direct-connect decode capture.pcap --json # ... as JSON
franklinwh-direct-connect --host 10.100.1.1 power_flow # live read
franklinwh-direct-connect --host 10.100.1.1 call 1301 --data '{"opt":0}'
franklinwh-direct-connect --host 10.100.1.1 power_flow --watch 5 # poll every 5s
franklinwh-direct-connect --host 10.100.1.1 power_flow --watch 5 --count 12
Every name in catalog is a live subcommand — the command list is generated from
the catalog, so the two cannot drift. franklinwh-direct-connect -i <ip> grid_ov_trip works just
as power_flow does, and anything uncatalogued is still reachable via call <code>.
--watch [SECONDS] (default 5) reuses one logged-in session and prints a line
per poll — a friendly summary for power_flow, compact JSON otherwise.
--count N stops after N samples (Ctrl-C otherwise).
Control (live writes)
Confirmed working over LAN TCP/9000 (no SPAN unlock, no cloud) — see
catalog.WRITES and docs/PROTOCOL.md. The CLI
shape mirrors the sibling tools: -i/--ip for the aGate (as in
franklinwh-modbus), and a
get-or---set command (as in
franklinwh-cloud):
franklinwh-direct-connect -i 192.0.2.110 mode # just the current mode
franklinwh-direct-connect -i 192.0.2.110 mode --list # all modes + details
franklinwh-direct-connect -i 192.0.2.110 mode --json # decoded dataArea (JSON)
franklinwh-direct-connect -i 192.0.2.110 mode --raw # full on-the-wire frame (cmdType/snno/crc/...)
franklinwh-direct-connect -i 192.0.2.110 mode --set self # set; alias tou / self / sc / backup
franklinwh-direct-connect -i 192.0.2.110 mode --set 29287 # ...or a programme id
franklinwh-direct-connect -i 192.0.2.110 offgrid # show off-grid status
franklinwh-direct-connect -i 192.0.2.110 offgrid --set on --soc 5
franklinwh-direct-connect -i 192.0.2.110 offgrid --set off # reconnect
In Python:
from franklinwh_direct_connect_api import LocalClient
with LocalClient("192.0.2.110") as c:
c.login()
c.set_mode("tou") # 1727 {opt:3, current_id}; alias or full name or id
These command a real battery system.
mode --setreportscurrent_idbefore→after so you can confirm it took. Some settings (e.g. smart-circuit SoC cut-off) still round-trip through the cloud — use… call <cmd> --data '…'to experiment with codes not yet wrapped.
Finding new (write/control) commands
The catalog so far is reverse-engineered from read traffic. To discover the
write/control codes, capture the app while it issues a control action and
look for cmdTypes not yet in the catalog. You don't need Wireshark — macOS/Linux
ship tcpdump, and this library de-obfuscates the frames (Wireshark would only
show ciphered bytes):
# 1. capture phone <-> aGate :9000 (Direct-Connect hotspot or LAN), then in the
# app do ONE control action at a time (switch mode, start charge, toggle SC).
sudo tcpdump -i <iface> -s0 -w fwh.pcap 'tcp port 9000'
# 2. triage — lists every cmdType seen; flags the ones NOT in the catalog:
franklinwh-direct-connect analyze fwh.pcap
franklinwh-direct-connect analyze fwh.pcap --unknown # just the candidate write codes
Or watch the conversation live with a transparent decoding proxy — redirect
the app's :9000 to it (ARP-spoof + DNAT, or a host route); it forwards every
byte verbatim and prints each decoded frame, flagging unknown cmdTypes:
franklinwh-direct-connect proxy 192.0.2.110 # relay :9000 -> aGate:9000
franklinwh-direct-connect proxy 192.0.2.110 --listen 0.0.0.0:9000
franklinwh-direct-connect proxy 192.0.2.110 --record session.pcap # capture on demand
# session.pcap opens in Wireshark and re-decodes: franklinwh-direct-connect decode session.pcap
Unknown odd codes captured during a control action are the requests; their
dataAreacarries the setpoint. Add confirmed codes tocatalog.py.
Or sweep the gateway directly with probe, which tries several request payload
shapes per code rather than only the usual {"opt":0}:
franklinwh-direct-connect -i 192.0.2.110 probe # all uncatalogued odd codes
franklinwh-direct-connect -i 192.0.2.110 probe --range 1413-1699 # one block
franklinwh-direct-connect -i 192.0.2.110 probe --json > probe.json
Some reads are addressed per battery, not per gateway — the cloud API's per-cell BMS
read sends {"fhpSn": "<aPower serial>", "type": 2|3}. A code like that answers a plain
{"opt":0} probe with silence, so a single-shape sweep cannot find it. probe
auto-discovers the battery serials (from battery_modules + the login manifest), tries
each shape, reports which one produced data, and flags replies that look like per-cell
telemetry. It only ever sends reads — never opt=1.
Caution: this sends cmdTypes whose semantics are unknown to a real battery system. The payload matrix is read-shaped by construction, but scope your sweeps (
--codes,--range) rather than blanket-probing a production gateway.
How the protocol works
A frame is one JSON object. The header is cleartext; everything from "type"
onward is obfuscated with a trivial position-based additive cipher:
{"cmdType":<N>,"equipNo":"<E>", <- cleartext header
"type":...,"timeStamp":...,"snno":...,"len":<L>,"crc":"<C>","dataArea":{...}}
\------------------------ ciphered region (i = 0 here) ----------------------/
encrypt: c[i] = (p[i] + seed + i) & 0xFF
decrypt: p[i] = (c[i] - seed - i) & 0xFF
There is no key. seed is 0x3F for normal frames and 0xA5 for the
pre-login 1101 handshake. Because the first plaintext byte is always ",
decoding is self-synchronizing — the seed is recovered from the first ciphered
byte, so any frame can be read without knowing the seed in advance.
Field rules: len is the byte length of the compact dataArea JSON, and crc
is its CRC32 (uppercase hex). Full details and the command catalog are in
docs/PROTOCOL.md.
Security note
The broker channel is plain TCP and the obfuscation is keyless, so anyone on the LAN path can read and forge frames. Decoded payloads expose WiFi credentials, device serials, firmware versions, and GPS/location. Treat the network segment accordingly.
Layout
franklinwh_direct_connect_api/
protocol.py cipher, Frame, encode/decode, FrameStream, pcap reader
catalog.py cmdType enum + descriptions
transport.py TCP client: connect, login, request/response
client.py high-level named commands
cli.py command-line entry point
tests/ pytest suite (driven by a real capture fixture)
docs/PROTOCOL.md protocol + command catalog
License
MIT. Unofficial and not affiliated with or endorsed by FranklinWH.
Metadata
Release files for franklinwh-direct-connect-api 0.4.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| franklinwh_direct_connect_api-0.4.0.tar.gz | 125.3 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| franklinwh_direct_connect_api-0.4.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 227.2 kB
Release files / franklinwh_direct_connect_api-0.4.0.tar.gz
| Download URL | franklinwh_direct_connect_api-0.4.0.tar.gz |
|---|---|
| Size | 125.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
cb0ddd5033aa6ee450f508bafad39c554cf5b2c5471126f4c865df73ed274301
|
|
BLAKE2b-256 checksum How to use checksums |
040ebb5ca32f3e81861675ec46b58678c23b3c8d236f476646e92029fcc97ee5
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 9, 2026.
Transparency logRelease files / franklinwh_direct_connect_api-0.4.0-py3-none-any.whl
| Download URL | franklinwh_direct_connect_api-0.4.0-py3-none-any.whl |
|---|---|
| Size | 101.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
b3712935d54ebb76e85c8487d9d468fd838ad5fea7464ae28134a6601099f5ef
|
|
BLAKE2b-256 checksum How to use checksums |
f1af3f4bcda45eb210cecab776eb858d0fb5b591349ea958007b07344dff89ee
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 9, 2026.
Transparency log