franklinwh-local
Unofficial Python library and CLI for the FranklinWH aGate local broker protocol —
the JSON cmdType frames exchanged over TCP/9000. 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.
Why this exists
The cloud REST API drops most of the structural telemetry (battery cell voltages,
relay states, full physics arrays) to speed up the mobile app. That data still
flows over the local broker channel. franklinwh-local speaks that channel
directly, so you can read deep telemetry and device configuration locally.
Docs: USAGE.md (full how-to) · PROTOCOL.md (wire format + command catalog) · API.md (API reference)
A rendered docs site (MkDocs Material) is published from docs/ via GitHub
Actions. Build it locally with:
pip install -e ".[docs]"
mkdocs serve # http://127.0.0.1:8000
Setup
Requires Python 3.10+. No runtime dependencies (standard library only).
git clone https://github.com/david2069/franklinwh-local
cd franklinwh-local
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-local --version # 4. verify
# franklinwh-local 0.1.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-local 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-local ... # can't open file '.../franklinwh-local'
python franklinwh_local.py ... # no such file
Run it one of these three ways:
franklinwh-local catalog # venv activated (recommended)
.venv/bin/franklinwh-local catalog # venv not activated — call it by path
python -m franklinwh_local catalog # module form; works anywhere the package imports
The names are easy to mix up: the PyPI distribution is
franklinwh-direct-connect-api, the import package is franklinwh_local
(underscore), and the CLI command is franklinwh-local (hyphen).
Everything below assumes the venv is activated. Offline commands
(catalog, decode, analyze, emulate) need no hardware — start with
franklinwh-local catalog to confirm your install works.
Troubleshooting the install
| Symptom | Cause / fix |
|---|---|
can't open file '.../franklinwh-local' |
You ran python franklinwh-local. Drop the python — it's a command, not a script. |
command not found: franklinwh-local |
The venv isn't activated (source .venv/bin/activate), or pip install -e . hasn't been run in it. |
No module named franklinwh_local |
You're on a different interpreter than the one you installed into. Check with which python and pip -V — both should point inside .venv. |
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_local 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_local 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_local 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-local -i 192.0.2.110 battery # per-cell view
franklinwh-local -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-local -i 192.0.2.110 battery_cells --id 1 # per-cell V + temps, SoC/SoH
franklinwh-local -i 192.0.2.110 power_electronics --id 1 # bus/grid/inverter V and A
franklinwh-local -i 192.0.2.110 device_firmware --id 1 # PE/BMS serials + versions
franklinwh-local -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-local scan gateway # auto-targets the hotspot gateway franklinwh-local --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-local scan 10.100.1.0/24 # scan a subnet
franklinwh-local scan gateway # just the hotspot gateway
franklinwh-local 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-local emulate --port 9000 # fake aGate replaying captured data
# then, in another shell:
franklinwh-local --host 127.0.0.1 power_flow
CLI
franklinwh-local catalog # list known cmdType codes (grouped)
franklinwh-local catalog --grep bms # filter by name/description/cloud call
franklinwh-local catalog --json # ... as JSON, with cloud equivalents
franklinwh-local decode capture.pcap # decode a pcap
franklinwh-local decode capture.pcap --json # ... as JSON
franklinwh-local --host 10.100.1.1 power_flow # live read
franklinwh-local --host 10.100.1.1 call 1301 --data '{"opt":0}'
franklinwh-local --host 10.100.1.1 power_flow --watch 5 # poll every 5s
franklinwh-local --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-local -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-local -i 192.0.2.110 mode # just the current mode
franklinwh-local -i 192.0.2.110 mode --list # all modes + details
franklinwh-local -i 192.0.2.110 mode --json # decoded dataArea (JSON)
franklinwh-local -i 192.0.2.110 mode --raw # full on-the-wire frame (cmdType/snno/crc/...)
franklinwh-local -i 192.0.2.110 mode --set self # set; alias tou / self / sc / backup
franklinwh-local -i 192.0.2.110 mode --set 29287 # ...or a programme id
franklinwh-local -i 192.0.2.110 offgrid # show off-grid status
franklinwh-local -i 192.0.2.110 offgrid --set on --soc 5
franklinwh-local -i 192.0.2.110 offgrid --set off # reconnect
In Python:
from franklinwh_local 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-local analyze fwh.pcap
franklinwh-local 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-local proxy 192.0.2.110 # relay :9000 -> aGate:9000
franklinwh-local proxy 192.0.2.110 --listen 0.0.0.0:9000
franklinwh-local proxy 192.0.2.110 --record session.pcap # capture on demand
# session.pcap opens in Wireshark and re-decodes: franklinwh-local 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-local -i 192.0.2.110 probe # all uncatalogued odd codes
franklinwh-local -i 192.0.2.110 probe --range 1413-1699 # one block
franklinwh-local -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_local/
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.3.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.3.0.tar.gz | 115.3 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| franklinwh_direct_connect_api-0.3.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 213.5 kB
Release files / franklinwh_direct_connect_api-0.3.0.tar.gz
| Download URL | franklinwh_direct_connect_api-0.3.0.tar.gz |
|---|---|
| Size | 115.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
ae1fdc78e15363815fe95158036bd71f1978439a13215786254c0abf1969bc3c
|
|
BLAKE2b-256 checksum How to use checksums |
de6e90e25e6509ecf3d21cd1be08eacab5f70e2d864ba060462be2cacf647ea9
|
| 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 3, 2026.
Transparency logRelease files / franklinwh_direct_connect_api-0.3.0-py3-none-any.whl
| Download URL | franklinwh_direct_connect_api-0.3.0-py3-none-any.whl |
|---|---|
| Size | 98.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
66e203472cda3d7c5b497fe3be0e7b72af45d4b9cc299be7fbf772d58d141261
|
|
BLAKE2b-256 checksum How to use checksums |
df12b9ca5313c91c72c15ee3dc1525c132fd19b87209ae4b7ada977040cff944
|
| 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 3, 2026.
Transparency log