franklinwh_local_api
A lightweight, fully local (LAN-only) async Python client for the FranklinWH aGate battery gateway, communicating directly over Modbus TCP.
No FranklinWH account, no cloud API, no internet access required — everything happens on your local network.
Why this exists
The official FranklinWH mobile app and cloud API work fine, but they
require an internet connection and an account. This library talks
directly to the aGate's Modbus TCP interface (port 502) on your LAN,
using registers that have been cross-validated against the
franklinwh-modbus
open-source reference project, and further verified against a live
device.
Features
- Fully async (
pymodbus's nativeAsyncModbusTcpClient— no thread pool / executor wrapping needed, integrates cleanly with asyncio-based apps such as Home Assistant) - Reads a single consolidated status snapshot in 6 Modbus round-trips:
- Battery capacity, available energy, SOC (both raw and a higher-precision derived value), SOH, charge/discharge power
- Grid import/export power, connection state, voltage (L-N ~120V, L-L ~240V), frequency
- Solar production, home load (sourced from an undocumented high-resolution register — see "Home load precision" below)
- Native operating mode (Emergency Backup / Self-Consumption / TOU)
- Self-Consumption reserve % and TOU reserve % (read directly from their respective hardware registers — see "Mode / reserve control" below)
- Ambient/cabinet temperature
- Decoded system alarms
- Active battery command state (see "Battery charge/discharge control" below)
- Device identification via
async_get_device_info()(SunSpec Model 1): manufacturer, model, firmware version, serial number — a separate, single-shot call intended for populating a UI's device info card (not part of the regular polling snapshot) - Write support for:
- Switching operating mode
- Setting Self-Consumption reserve %
- Setting TOU reserve %
- Commanding the battery to charge or discharge at a specific power (see "Battery charge/discharge control" below)
Home load precision
home_load_w is sourced from an undocumented single-register block at
address 16000, which mirrors the FranklinWH extension block's home
load reading (formerly register 15506/LoadActiveP) but with ~1W
precision instead of ~100W quantization. This register fully replaces
15506 as the source for home_load_w — the coarser register is no
longer read at all.
Battery charge/discharge control (M704)
async_start_battery_charge(power_w, duration_s=None) and
async_start_battery_discharge(power_w, duration_s=None) command the
aGate's battery to charge or discharge at a precise wattage, via the
standard SunSpec Model 704 (WSetEna/WSetMod/WSetPct) — no SPAN
Modbus unlock required, since these are standard SunSpec registers,
not FranklinWH's proprietary extension block.
Both directions hold the commanded setpoint precisely and consistently
once accepted by hardware (e.g. commanding 500W charge holds
battery_power_w at -500W; commanding 500W discharge holds it at
+500W).
Important: this controls BATTERY power, not a grid-side target.
Calling async_start_battery_charge(power_w=4000) with a 2000W home
load and no solar results in a grid import of ~6000W (4000W for the
battery + 2000W for the home) — not 4000W. The resulting grid
import/export is a side effect of the aGate's internal power balance
(grid = home_load + battery_charge - solar), not a value M704 targets
directly. There is no known Modbus path on this hardware/firmware that
directly limits grid-side import/export power (see "Known limitations"
below).
Typical use cases:
- Forcing the battery to charge from the grid during a specific time window (e.g. a cheap overnight rate), independent of the aGate's native TOU/Self-Consumption reserve % logic.
- Forcing a controlled discharge rate rather than whatever the native mode's algorithm would choose.
- Locking the battery at 0W (via
async_stop_battery_command()) so solar can serve the home while grid serves an un-isolated load (e.g. an EV charger with no dedicated smart circuit), without the aGate's native Self-Consumption logic auto-discharging the battery to cover that load.
A software watchdog (duration_s) auto-releases the command after a
bounded time, since the hardware's own reversion timer (WSetRvrtTms)
is a known no-op on FranklinWH firmware (the countdown runs but never
actually reverts power).
async_set_operating_mode() automatically detects and releases any
active battery command before switching native modes, since WSetEna=1
suspends the aGate's native mode scheduling entirely.
Mode / reserve control
This client is a thin, stateless wrapper around three independent Modbus extension registers - it does not maintain any internal state between calls:
async_set_operating_mode(mode)writes register15507(OnGridMode) and reads it back to confirm the write stuck. It does not touch15508/15509in any way.async_set_self_reserve_pct(pct)writes register15508(SelfReserve) and reads it back to confirm the write stuck. This is a plain, unconditional write regardless of the currently active mode.async_set_tou_reserve_pct(pct)writes register15509(TouReserve) and reads it back to confirm the write stuck. Also a plain, unconditional write regardless of the currently active mode.
Known FranklinWH firmware defect: register 15509 (TouReserve)
always mirrors whatever was last written to 15508 (SelfReserve), and
vice versa - there is no way to store two independent reserve values
in hardware at the same time. This client does not attempt to work
around or hide that defect - it just performs the write/read you ask
for, exactly as specified. Any business logic to manage "what each
reserve % should be, independent of this firmware defect" belongs in
the calling application - the ha_franklinwh_modbus Home Assistant
integration built on top of this library is one example of that kind
of coordination.
Requirements
There are two separate, independent installer-side prerequisites on the FranklinWH side for the extension register features (native mode switching and reserve % — NOT the battery charge/discharge commands above, which use standard SunSpec registers and always work). Both are enabled by your installer or by FranklinWH support - neither can be turned on from this library or from the standard end-user mobile app settings:
- Modbus TCP enabled - required for anything to work at all,
including read-only status queries. Without this, the aGate's
Modbus TCP listener (port 502) is not reachable and
connect()will fail outright. - "SPAN Modbus" write unlock - required additionally, on top of
(1), for the extension-register writes to succeed: switching
operating mode (
async_set_operating_mode()) or setting reserve percentages (async_set_self_reserve_pct()/async_set_tou_reserve_pct()). Without this unlock, reads work completely normally, but writes to the extension registers (15507/15508/15509) are silently discarded by the firmware.
If you can run scripts/test_read_all.py successfully but every
extension-register write attempt (mode switch, reserve %) raises
FranklinWHWriteError, that means (1) is enabled but (2) is not yet -
contact your installer or FranklinWH support to request the SPAN
Modbus write unlock for your aGate.
Empirically confirmed behavior when the SPAN unlock is NOT yet
granted: writes to extension registers (15507/15508/15509) are ACK'd
at the Modbus protocol level but are silently discarded by the
aGate firmware - a subsequent read-back shows the register unchanged.
This is why async_set_operating_mode() and the reserve % setters
always perform a read-back verification and raise
FranklinWHWriteError if the value didn't actually stick.
Installation
pip install franklinwh_local_api
For local development (editable install from a clone of this repo):
pip install -e .
Quick start
import asyncio
from franklinwh_local_api import FranklinWHLocalClient, OperatingMode
async def main():
client = FranklinWHLocalClient(host="192.168.1.50")
await client.connect()
status = await client.async_get_status()
print(f"SOC: {status.battery_soc_pct:.1f}%")
print(f"Mode: {status.operating_mode.value}")
print(f"Solar: {status.solar_power_w} W")
print(f"Home load: {status.home_load_w} W")
# Device identification (single-shot, e.g. at app startup)
info = await client.async_get_device_info()
print(f"{info.manufacturer} {info.model} (fw {info.firmware_version}, SN {info.serial_number})")
# Switch native mode + reserve % (requires SPAN Modbus unlock)
await client.async_set_operating_mode(OperatingMode.TOU)
await client.async_set_tou_reserve_pct(30)
# Command the battery directly (works regardless of SPAN unlock)
await client.async_start_battery_charge(power_w=2000, duration_s=3600)
# ... later ...
await client.async_stop_battery_command()
await client.close()
asyncio.run(main())
Or using the async context manager:
async with FranklinWHLocalClient(host="192.168.1.50") as client:
status = await client.async_get_status()
API reference
FranklinWHLocalClient
| Method | Description |
|---|---|
connect() |
Open the Modbus TCP connection. Raises FranklinWHConnectionError on failure. |
close() |
Close the connection. |
is_connected (property) |
True if currently connected. |
async_get_status() -> FranklinWHStatus |
Read a full status snapshot (6 Modbus round-trips). |
async_get_device_info() -> FranklinWHDeviceInfo |
Read manufacturer/model/firmware/serial from SunSpec Model 1. |
async_set_operating_mode(mode: OperatingMode) |
Switch native operating mode. Requires SPAN Modbus unlock. |
async_set_self_reserve_pct(pct: int) |
Write Self-Consumption reserve % (0-100). Requires SPAN Modbus unlock. |
async_set_tou_reserve_pct(pct: int) |
Write TOU reserve % (0-100). Requires SPAN Modbus unlock. |
async_start_battery_charge(power_w: float, duration_s: float | None = None) |
Command the battery to charge at power_w watts. |
async_start_battery_discharge(power_w: float, duration_s: float | None = None) |
Command the battery to discharge at power_w watts. |
async_stop_battery_command(handshake_wait_s: float = 1.0) |
Release an active M704 command; native mode scheduling resumes. |
async_get_battery_command_status() -> dict |
Lightweight read of just the M704 remote-control state. |
Data models
FranklinWHStatus— full point-in-time status snapshot returned byasync_get_status(). Seemodels.pyfor the complete field list and units (all power fields are in Watts, all energy fields in Wh).FranklinWHDeviceInfo— device identification returned byasync_get_device_info():manufacturer,model,firmware_version,serial_number.OperatingMode— enum of the three user-selectable native modes (EMERGENCY_BACKUP,SELF_CONSUMPTION,TOU), plus two read-only hardware states (STANDBY,MANUAL).
Exceptions
FranklinWHConnectionError— raised on any Modbus-level connection/read/write failure.FranklinWHWriteError— raised when a write is ACK'd at the protocol level but the read-back verification shows the value did not actually change in hardware.
Debugging / manual verification
Standalone debug scripts are included in scripts/:
test_read_all.py— reads connection settings from a.envfile and prints every field of a full status snapshot.monitor_live.py— continuously polls and redraws a live dashboard in place in the terminal.test_battery_charge.py/test_battery_discharge.py— end-to-end verification of the M704 battery command path.test_set_mode.py— round-trip test ofasync_set_operating_mode()and the reserve % setters.
cp .env.example .env
# edit .env and set AGATE_IP
pip install -r requirements.txt
python scripts/test_read_all.py
# or, to continuously re-read every 5 seconds:
python scripts/test_read_all.py --watch
# verify battery charge/discharge control end-to-end:
python scripts/test_battery_charge.py
python scripts/test_battery_discharge.py
Known limitations
- Grid import/export power limiting is NOT implemented and is not
believed to be possible on this hardware. Neither the Modbus
WMaxLimPct/WChaRteMaxregister groups nor the FranklinWH cloud API's power control settings reliably enforce a hardware-level export/import limit. The battery charge/discharge commands above can be used as a workaround for some use cases but do not directly target a grid-side power value. - The battery DC power sign convention (
M714.DCW) is negative = charging, positive = discharging. Seemodels.pyfor details. - Register
15509(TOU reserve) is a known firmware defect that always mirrors15508, and vice versa — see "Mode / reserve control" above.
Acknowledgments
The Modbus register map used by this library was cross-validated against
franklinwh-modbus
by @david2069, whose extensive testing
against live hardware helped confirm which registers and modes are
actually usable.
Changelog
See CHANGELOG.md.
Disclaimer
This project has no affiliation with FranklinWH. See DISCLAIMER.md for the full disclaimer.
License
GNU General Public License v3.0 or later (GPLv3+). See LICENSE for the full text.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file franklinwh_local_api-0.1.0.tar.gz.
File metadata
- Download URL: franklinwh_local_api-0.1.0.tar.gz
- Upload date:
- Size: 33.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a4040b89149afed9708e061546a9c572dcad6cb7d05d5821d18a976f41aeb987
|
|
| MD5 |
0939b5937bda45d714a013e5e2438a7f
|
|
| BLAKE2b-256 |
307373731c64bbadbcaf53a96541f52cc10ca52d9c816ae2cf8bcfcb08c34669
|
Provenance
The following attestation bundles were made for franklinwh_local_api-0.1.0.tar.gz:
Publisher:
publish-pypi.yml on hAr1x/franklinwh-local-api
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
franklinwh_local_api-0.1.0.tar.gz -
Subject digest:
a4040b89149afed9708e061546a9c572dcad6cb7d05d5821d18a976f41aeb987 - Sigstore transparency entry: 2511955954
- Sigstore integration time:
-
Permalink:
hAr1x/franklinwh-local-api@9957dad70da6f4e48b0b9cafbfcae7808467faa2 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/hAr1x
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish-pypi.yml@9957dad70da6f4e48b0b9cafbfcae7808467faa2 -
Trigger Event:
release
-
Statement type:
File details
Details for the file franklinwh_local_api-0.1.0-py3-none-any.whl.
File metadata
- Download URL: franklinwh_local_api-0.1.0-py3-none-any.whl
- Upload date:
- Size: 32.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c5a11d30f28660f44d2cdf2dc6883f1382dc4134f0395b5fae88f68dc8085e5a
|
|
| MD5 |
da48fba17a9e71e99cc17cc9b47403c9
|
|
| BLAKE2b-256 |
58729e06d8542ce61cd21d25284e682457e128d5726d2b4bd3ce9875b98e4c98
|
Provenance
The following attestation bundles were made for franklinwh_local_api-0.1.0-py3-none-any.whl:
Publisher:
publish-pypi.yml on hAr1x/franklinwh-local-api
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
franklinwh_local_api-0.1.0-py3-none-any.whl -
Subject digest:
c5a11d30f28660f44d2cdf2dc6883f1382dc4134f0395b5fae88f68dc8085e5a - Sigstore transparency entry: 2511956005
- Sigstore integration time:
-
Permalink:
hAr1x/franklinwh-local-api@9957dad70da6f4e48b0b9cafbfcae7808467faa2 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/hAr1x
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish-pypi.yml@9957dad70da6f4e48b0b9cafbfcae7808467faa2 -
Trigger Event:
release
-
Statement type: