wifi.py presented itself as a join/leave abstraction but provided no real
behavior: the default backend was a no-op, the facade never verified the
association, and the nmcli queries were wrong. This makes the module do what
it claims, verified end-to-end against the recovered protocol and nmcli 1.52.
wifi.py
- WifiManager: join now verifies the association by polling
backend.is_connected against a settle_timeout (fail-closed). An
unverified join raises HotspotJoinError and leaves connected=False;
verify=False restores fire-and-forget. Tracks last_profile and adds
gateway_ip().
- NmcliWifiBackend: the association check now uses the ACTIVE column
(`device wifi list -f SSID,ACTIVE`). The old `device wifi show` was a
prefix-match accident onto `show-password` and reported in-range APs, not
the associated one. get_ip() queries per-Wi-Fi-interface IP4.ADDRESS
(preferring the 10.100 aGate subnet) instead of the unfiltered all-device
dump that would return a wired/VPN address. Signal is read from the active
AP; the dead ipcmd param is removed; leave() is best-effort.
- WifiBackend: documented the fail-closed contract and added get_signal()
so the manager no longer reaches into a private _run via isinstance.
- NoOpWifiBackend: added assume_connected() for manual/external joins.
client.py
- AgateClient now owns a WifiManager and exposes join_hotspot(),
leave_hotspot(), and attach_local() (join + verify + LocalDeviceClient
aimed at the device gateway). LocalDeviceClient is imported lazily so the
package still works without `requests`.
__init__.py
- Export the backends, HotspotProfile, HotspotJoinError, the SN-derivation
helper, and the module constants.
tests/test_wifi.py (new)
- 20 stdlib-unittest cases: SN derivation, verify pass/fail, fail-closed
behavior, the in-range-but-not-associated regression, IP preference,
tolerant leave, and the client wiring. No radio or pytest required.
Also:
- README: documented the local-connect (device hotspot) flow.
- .gitignore: ignore Python build/cache artifacts.
99 lines
3.5 KiB
Markdown
99 lines
3.5 KiB
Markdown
# FranklinWH AGATE Client
|
|
|
|
Python client library for interacting with FranklinWH AGATE energy storage device.
|
|
|
|
## Repository
|
|
|
|
**Gitea:** http://192.168.1.74:30008/WesternTechnologies/franklinwh-agate-client
|
|
|
|
## Documentation
|
|
|
|
- **[Dart AOT Reverse-Engineering Findings](docs/RE_DART_FINDINGS.md)** — detailed protocol
|
|
analysis recovered from the app's `libapp.so` with [blutter](https://github.com/worawit/blutter)
|
|
(transports, wire envelope, command/topic enums, crypto/CRC, BLE framing, hotspot credentials).
|
|
|
|
## Reverse Engineering Summary
|
|
|
|
This library was reverse engineered from the official FranklinWH Family Energy Android app (`com.Franklinwh.FamilyEnergy`).
|
|
|
|
### Network I/O Stack
|
|
|
|
The device communicates via:
|
|
- **Bluetooth LE**: Direct connection to AGATE device
|
|
- **Onboard Wi-Fi**: Local network connection via device hotspot
|
|
- **Cloud APIs**: `https://e-api.franklinwh.com`, `https://energy.franklinwh.com`
|
|
|
|
### Key Operations Discovered
|
|
|
|
- `queryAgateInfo` - Get device information
|
|
- `queryDeviceInfoHttp` - HTTP device info query
|
|
- `queryModesByHttp` - Query operating modes
|
|
- `queryConnectWifi` - Wi-Fi connection management
|
|
- `connectViaBluetooth` - Bluetooth connection
|
|
- `startBluetoothHeartbeat` - Keepalive
|
|
- `BluetoothManager` - Bluetooth service management
|
|
- `WifiOnboarding` - Wi-Fi setup flows
|
|
|
|
### Endpoints Found
|
|
|
|
- `https://e-api.franklinwh.com/api/appapi`
|
|
- `https://energy.franklinwh.com/api-energy/deviceGroup/electric`
|
|
- `https://energy.franklinwh.com/hes-gateway`
|
|
- `https://dev.franklinwh.com/hes-gateway`
|
|
- `https://uat.franklinwh.com/api-vpp-portal`
|
|
|
|
## Usage
|
|
|
|
```python
|
|
from franklinwh_agate_client import AgateClient
|
|
|
|
client = AgateClient()
|
|
device_info = client.query_agate_info(device_id)
|
|
```
|
|
|
|
## Local connect (device hotspot)
|
|
|
|
The aGate runs its own SoftAP (gateway `10.100.1.1`); joining it is what
|
|
lets the client talk to the device directly. The client derives the hotspot
|
|
credentials from the 20-char SN (`AP_<sn[11:]>` / `<sn[8:]>`, or the
|
|
`AP_<sn>` / `12345678` fallback for malformed SNs), joins, **verifies the
|
|
association**, then talks to the device over `https://10.100.1.1:18000`
|
|
and the encrypted socket channel.
|
|
|
|
```python
|
|
from franklinwh_agate_client import (
|
|
AgateClient, NmcliWifiBackend, NoOpWifiBackend, derive_hotspot_credentials,
|
|
)
|
|
|
|
# Linux desktop/laptop with NetworkManager:
|
|
client = AgateClient(wifi_backend=NmcliWifiBackend())
|
|
profile = client.join_hotspot(sn) # joins + verifies; raises HotspotJoinError on failure
|
|
local = client.attach_local(sn) # LocalDeviceClient -> https://10.100.1.1:18000
|
|
info = local.obtain_agate_info() # or local.query_bms_info(), ...
|
|
|
|
# Container, or a host where you join the hotspot manually:
|
|
wm = AgateClient(wifi_backend=NoOpWifiBackend(local_ip="10.100.1.2")).wifi_manager
|
|
wm.assume_connected(derive_hotspot_credentials(sn))
|
|
```
|
|
|
|
`WifiManager.join_*` are fail-closed: they poll the backend's `is_connected`
|
|
against `settle_timeout` (default 30 s) and raise `HotspotJoinError` if the
|
|
association never confirms (`verify=False` for fire-and-forget). On Linux,
|
|
`NmcliWifiBackend` uses `nmcli ... device wifi list -f SSID,ACTIVE` for the
|
|
association check (a merely *visible* AP does not count) and per-Wi-Fi-
|
|
interface `IP4.ADDRESS` lookups for the host IP.
|
|
|
|
## Development
|
|
|
|
This library is a work in progress. Additional operations will be added as reverse engineering continues.
|
|
|
|
Tests (no radio or NetworkManager required):
|
|
|
|
```
|
|
python -m unittest discover -s tests
|
|
```
|
|
|
|
## License
|
|
|
|
Internal use only. Reverse engineering findings for research purposes.
|