Compare commits

..
3 Commits
Author SHA1 Message Date
kfj001 b60b32830f Optional aGate address on the API (home-LAN direct connect)
AgateClient takes address= default; attach_local accepts
address/host/gateway_ip per call with join control. An explicit
address skips the SoftAP radio join by default (join=True forces
it). derive_hotspot_credentials and join_device_hotspot accept a
gateway_ip override. battery/power host defaults now use
DEVICE_GATEWAY_IP. Verified live at 192.168.1.142:9000.
2026-10-16 08:18:33 +00:00
kfj001 eda78dd7d1 Home Wi-Fi join API (connectWifi SET + scan/status) with docs 2026-10-16 06:08:09 +00:00
kfj001 3c7e74fd0e Document cloud account binding (§17): deviceBindV2 + token signature 2026-10-16 05:57:31 +00:00
8 changed files with 188 additions and 28 deletions

No files matched your search

+53
View File
@@ -396,6 +396,59 @@ Library: `power.get_power()` tries detail → load → EMS in order
(`franklinwh_agate_client/power.py`); `LocalDeviceClient` gained (`franklinwh_agate_client/power.py`); `LocalDeviceClient` gained
`query_detail_info()` / `query_load_status()`. `query_detail_info()` / `query_load_status()`.
## 17. Cloud account binding (installer flow)
Binding a unit to an account is an *installer commissioning* action, not
a local-socket one (`sections/installer/bind_device/`):
- `BindDeviceProvider.bind()` POSTs a data map to
**`RemoteInterface.deviceBind` → `POST {cloud}/manage/deviceBindV2`**,
with `protocolVer: "V1.10.01"`, the installer `jobCode`, the
`commissionId` (preset-site path via `InstallContext`), and the
`BindDeviceEntity` payload. Result parses as
`HttpResultEntity<BindDeviceEntity>` (bound `account`, distributor /
financier ids/names).
- Mirror calls: `queryDeviceBindData` →
`GET {cloud}​/manage/getGatewayBindData` (pre-check: is this gateway
already bound, to whom) and `deviceUnbind` →
`POST {cloud}​/manage/unBindDevice` ("Are you sure you want to
unbind?", `unbindSuccess` strings).
- Request auth on cloud calls is a signature, not a session cookie:
`RemoteService` computes **`MD5(jsonEncode(body) + "B6F3Zu&aW8XlH9CA")`**
and sends it as the **`token`** header (dio `Options`). User login
itself is Okta (`sunrun.okta.com`, MFA endpoints); the known cloud
bases are `https://e-api.franklinwh.com/api/appapi` and
`https://energy.franklinwh.com/*` (exact `/manage` base mapping lives
in the `RemoteService` dio init).
- Locally, bound devices land in the trusted-devices table
(`DatabaseManager.getTrustedDevicesTableList`, consulted by
`_checkConnectedNotTrustedAp` on direct-connect).
## 18. Joining the aGate to home Wi-Fi (STA onboarding)
Three transports can deliver the credentials; all verified in
disassembly, socket path live-tested for framing (not the join itself):
- **Socket** (while on the SoftAP): `queryConnectWifi` →
`CommunicationService.connectWifi(entity, …)` → `_request` with cmd
`connectWifi` (socket `1111`), operate **SET** (`type: 1`), arguments =
`ConnectWifiEntity.toJson()`: `ssid`, `password`, `wifi_SSID`,
`wifi_Pw` (plus an unencrypted-network flag). Library:
`LocalDeviceClient.join_home_wifi(ssid, password)` (+ `opt: 1`
seeding via the shared factory path), `scan_home_wifi()`
(`scanWifiList`, socket `1109`, no args) to list visible networks,
`query_connection_status()` (`connectionStatus`, socket `1113`) to
confirm association.
- **BLE**: ESP protocomm (`prov-session`, `prov-scan`, `prov-config`,
`custom-data`, sec0/sec1) — the standard Espressif flow; `prov-config`
carries the SSID/password. Used when the phone is not on the SoftAP.
- **Phone-side**: `wifi_iot` plugin join/leave is the *phone* joining the
*device* AP (`wifi.join_device_hotspot`) — the opposite direction.
.. warning:: `join_home_wifi` is state-changing: on success the unit
leaves its SoftAP (or roams), dropping the current session. Only run
it with physical/rejoin access to the unit.
## 9. Bluetooth (BLE) transport — `communication_center/bluetooth` ## 9. Bluetooth (BLE) transport — `communication_center/bluetooth`
- `BluetoothManager` (singleton), `BluetoothStatus`: `closed / connected / connecting / interrupted`. - `BluetoothManager` (singleton), `BluetoothStatus`: `closed / connected / connecting / interrupted`.
+5 -5
View File
@@ -20,7 +20,7 @@ from typing import Any, Dict, List, Optional, Tuple
from .device_local import LocalDeviceClient from .device_local import LocalDeviceClient
from .protocol import CommunicationCmd from .protocol import CommunicationCmd
from .wifi import derive_hotspot_credentials from .wifi import DEVICE_GATEWAY_IP, derive_hotspot_credentials
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -144,7 +144,7 @@ def _is_empty_pack(reply: Dict[str, Any], result: Dict[str, Any]) -> bool:
def get_battery( def get_battery(
sn: str, sn: str,
host: str = "10.100.1.1", host: str = DEVICE_GATEWAY_IP,
ports: Tuple[int, ...] = SOCKET_PORT_CANDIDATES, ports: Tuple[int, ...] = SOCKET_PORT_CANDIDATES,
key_radix: int = 16, key_radix: int = 16,
timeout: float = 5.0, timeout: float = 5.0,
@@ -197,7 +197,7 @@ PROBE_VARIANTS: Tuple[Tuple[str, CommunicationCmd, Any], ...] = (
def probe_device( def probe_device(
sn: str, sn: str,
host: str = "10.100.1.1", host: str = DEVICE_GATEWAY_IP,
ports: Tuple[int, ...] = SOCKET_PORT_CANDIDATES, ports: Tuple[int, ...] = SOCKET_PORT_CANDIDATES,
key_radix: int = 16, key_radix: int = 16,
timeout: float = 5.0, timeout: float = 5.0,
@@ -262,7 +262,7 @@ _MQTT_CONNECT = bytes.fromhex("101200044d5154540402003c000670726f62653031")
def probe_transport( def probe_transport(
sn: str, sn: str,
host: str = "10.100.1.1", host: str = DEVICE_GATEWAY_IP,
ports: Tuple[int, ...] = (9000,), ports: Tuple[int, ...] = (9000,),
key_radix: int = 16, key_radix: int = 16,
timeout: float = 5.0, timeout: float = 5.0,
@@ -327,7 +327,7 @@ def main(argv: Optional[List[str]] = None) -> int:
description="Read aGate battery percentage over the device SoftAP socket." description="Read aGate battery percentage over the device SoftAP socket."
) )
parser.add_argument("--sn", required=True, help="20-char device serial") parser.add_argument("--sn", required=True, help="20-char device serial")
parser.add_argument("--host", default="10.100.1.1", help="aGate gateway IP") parser.add_argument("--host", default=DEVICE_GATEWAY_IP, help="aGate gateway IP")
parser.add_argument( parser.add_argument(
"--ports", default=",".join(map(str, SOCKET_PORT_CANDIDATES)), "--ports", default=",".join(map(str, SOCKET_PORT_CANDIDATES)),
help="comma-separated socket ports to probe", help="comma-separated socket ports to probe",
+52 -13
View File
@@ -7,22 +7,39 @@ from __future__ import annotations
from typing import Any, Optional from typing import Any, Optional
from .bluetooth import BluetoothManager from .bluetooth import BluetoothManager
from .wifi import HotspotProfile, WifiBackend, WifiManager from .wifi import DEVICE_GATEWAY_IP, HotspotProfile, WifiBackend, WifiManager
class AgateClient: class AgateClient:
"""Main client for FranklinWH AGATE device operations.""" """Main client for FranklinWH AGATE device operations.
:param device_id: optional default device identifier.
:param wifi_backend: platform backend used for hotspot joins.
:param address: optional default aGate address (IP or hostname).
Overrides the ``10.100.1.1`` SoftAP default for direct-connect
calls — e.g. the home-LAN address (``192.168.1.x``) once the unit
is on the router. Per-call ``address``/``host``/``gateway_ip``
arguments take precedence over this default.
"""
def __init__(self, device_id: Optional[str] = None, def __init__(self, device_id: Optional[str] = None,
wifi_backend: Optional[WifiBackend] = None) -> None: wifi_backend: Optional[WifiBackend] = None,
address: Optional[str] = None) -> None:
self.device_id = device_id self.device_id = device_id
self.bluetooth_manager: Optional[BluetoothManager] = None self.bluetooth_manager: Optional[BluetoothManager] = None
self.wifi_manager = WifiManager(backend=wifi_backend) self.wifi_manager = WifiManager(backend=wifi_backend)
self.address = address
# -- device hotspot (local connect) ------------------------------------- # -- device hotspot (local connect) -------------------------------------
def join_hotspot(self, sn: str, **kwargs: Any) -> HotspotProfile: def join_hotspot(self, sn: str, gateway_ip: Optional[str] = None,
"""Join the device SoftAP (derives creds, joins, verifies).""" **kwargs: Any) -> HotspotProfile:
return self.wifi_manager.join_device_hotspot(sn, **kwargs) """Join the device SoftAP (derives creds, joins, verifies).
``gateway_ip`` overrides the address recorded on the profile
without changing the radio join.
"""
return self.wifi_manager.join_device_hotspot(
sn, gateway_ip=gateway_ip or self.address, **kwargs)
def leave_hotspot(self) -> None: def leave_hotspot(self) -> None:
"""Disconnect from the device hotspot.""" """Disconnect from the device hotspot."""
@@ -30,9 +47,23 @@ class AgateClient:
def attach_local(self, sn: str, *, verify_join: bool = True, def attach_local(self, sn: str, *, verify_join: bool = True,
settle_timeout: float = 30.0, poll_interval: float = 1.0, settle_timeout: float = 30.0, poll_interval: float = 1.0,
address: Optional[str] = None,
host: Optional[str] = None,
gateway_ip: Optional[str] = None,
join: Optional[bool] = None,
**local_kwargs: Any): **local_kwargs: Any):
"""Join the device hotspot, then return a ``LocalDeviceClient`` """Join the device hotspot, then return a ``LocalDeviceClient``.
aimed at the device gateway (``https://10.100.1.1:18000`` by default).
Without an explicit address this keeps the existing behaviour:
join the SoftAP and aim at the profile gateway
(``https://10.100.1.1:18000`` by default).
Pass ``address`` (``host``/``gateway_ip`` are aliases) to reach the
aGate elsewhere — e.g. its home-LAN IP once it is on the router.
An explicit address defaults to *skipping* the hotspot radio join
(pass ``join=True`` to force it); ``join=False`` never touches the
radio. The client-level ``address`` default applies when no
per-call address is given.
``verify_join`` gates the association check (TLS verification of the ``verify_join`` gates the association check (TLS verification of the
device API is a separate concern — pass ``verify=`` in device API is a separate concern — pass ``verify=`` in
@@ -45,13 +76,21 @@ class AgateClient:
(``port``, ``scheme``, ``verify``, ``socket_port``, ``key_radix``, (``port``, ``scheme``, ``verify``, ``socket_port``, ``key_radix``,
``timeout``, ``extra_headers``, ``session``). ``timeout``, ``extra_headers``, ``session``).
""" """
profile = self.wifi_manager.join_device_hotspot( explicit = address if address is not None else (
sn, verify=verify_join, settle_timeout=settle_timeout, host if host is not None else gateway_ip)
poll_interval=poll_interval, target = explicit if explicit is not None else self.address
) do_join = (target is None) if join is None else join
if do_join:
profile = self.wifi_manager.join_device_hotspot(
sn, verify=verify_join, settle_timeout=settle_timeout,
poll_interval=poll_interval,
gateway_ip=target,
)
target = profile.gateway_ip
from .device_local import LocalDeviceClient from .device_local import LocalDeviceClient
return LocalDeviceClient(sn=sn, host=profile.gateway_ip, **local_kwargs) return LocalDeviceClient(sn=sn, host=target or DEVICE_GATEWAY_IP,
**local_kwargs)
# -- cloud / stub operations (pending RE) ------------------------------- # -- cloud / stub operations (pending RE) -------------------------------
def query_agate_info(self, device_id: Optional[str] = None): def query_agate_info(self, device_id: Optional[str] = None):
+31 -2
View File
@@ -190,7 +190,7 @@ class LocalDeviceClient:
if envelope is None: if envelope is None:
envelope = direct_connect_request( envelope = direct_connect_request(
cmd, self.sn if equip_no is None else equip_no, data=data, cmd, self.sn if equip_no is None else equip_no, data=data,
snno=self._sn_seq.next(), snno=self._sn_seq.next(), operate=operate,
) )
return self.send_envelope(envelope) return self.send_envelope(envelope)
@@ -212,6 +212,7 @@ class LocalDeviceClient:
data: Optional[Dict[str, Any]] = None, data: Optional[Dict[str, Any]] = None,
timeout: Optional[float] = None, timeout: Optional[float] = None,
equip_no: Optional[str] = None, equip_no: Optional[str] = None,
operate: CommunicationOperate = CommunicationOperate.QUERY,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Send one envelope, return exact TX/RX bytes plus any parsed reply. """Send one envelope, return exact TX/RX bytes plus any parsed reply.
@@ -223,7 +224,7 @@ class LocalDeviceClient:
""" """
envelope = direct_connect_request( envelope = direct_connect_request(
cmd, self.sn if equip_no is None else equip_no, data=data, cmd, self.sn if equip_no is None else equip_no, data=data,
snno=self._sn_seq.next(), snno=self._sn_seq.next(), operate=operate,
) )
wire = self.codec.encode(envelope) wire = self.codec.encode(envelope)
reply_raw = self._socket_roundtrip( reply_raw = self._socket_roundtrip(
@@ -354,6 +355,34 @@ class LocalDeviceClient:
def query_modes_v2(self) -> Dict[str, Any]: def query_modes_v2(self) -> Dict[str, Any]:
return self.send_rpc(CommunicationCmd.MODE_V2) return self.send_rpc(CommunicationCmd.MODE_V2)
def join_home_wifi(self, ssid: str, password: str = "") -> Dict[str, Any]:
"""``connectWifi`` — join the aGate to a home Wi-Fi network.
Mirrors ``WifiOnboardingConnectProvider``: sends a
``ConnectWifiEntity`` shaped payload (``ssid``/``password`` plus
the ``wifi_SSID``/``wifi_Pw`` mirrors) with operate SET over the
current transport. Follow with :meth:`query_connection_status`
to confirm association.
.. warning:: State-changing: on success the unit leaves its
SoftAP (or roams), so the current socket/HTTP session drops.
Only run when you can rejoin the device network afterwards.
"""
return self.send_rpc(
CommunicationCmd.CONNECT_WIFI,
operate=CommunicationOperate.SET,
data={"ssid": ssid, "password": password,
"wifi_SSID": ssid, "wifi_Pw": password},
)
def scan_home_wifi(self) -> Dict[str, Any]:
"""``scanWifiList`` — networks visible to the aGate (socket 1109)."""
return self.send_rpc(CommunicationCmd.SCAN_WIFI_LIST)
def query_connection_status(self) -> Dict[str, Any]:
"""``connectionStatus`` — current STA association state."""
return self.send_rpc(CommunicationCmd.CONNECTION_STATUS)
# --------------------------------------------------- business operations # --------------------------------------------------- business operations
# Intention-revealing reads: each runs the app's query sequence for one # Intention-revealing reads: each runs the app's query sequence for one
# question and returns ``{"value", "source", "detail", "reply", ...}``. # question and returns ``{"value", "source", "detail", "reply", ...}``.
+2 -1
View File
@@ -17,10 +17,11 @@ from typing import Any, Dict, List, Optional, Tuple
from .battery import _walk from .battery import _walk
from .device_local import LocalDeviceClient from .device_local import LocalDeviceClient
from .wifi import DEVICE_GATEWAY_IP
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
HOST = "10.100.1.1" HOST = DEVICE_GATEWAY_IP
PORTS: Tuple[int, ...] = (9000, 18000) PORTS: Tuple[int, ...] = (9000, 18000)
#: Reply keys holding watt readings, in priority order. #: Reply keys holding watt readings, in priority order.
+3 -2
View File
@@ -175,6 +175,7 @@ def direct_connect_request(
equip_no: str, equip_no: str,
data: Optional[Dict[str, Any]] = None, data: Optional[Dict[str, Any]] = None,
snno: int = 0, snno: int = 0,
operate: CommunicationOperate = CommunicationOperate.QUERY,
) -> CommunicationObject: ) -> CommunicationObject:
"""Build a *direct-connect* query envelope. """Build a *direct-connect* query envelope.
@@ -190,13 +191,13 @@ def direct_connect_request(
``sendObject`` (see :class:`SequenceNumber`); pass the issued value ``sendObject`` (see :class:`SequenceNumber`); pass the issued value
when re-serialising. when re-serialising.
""" """
payload: Dict[str, Any] = {"opt": int(CommunicationOperate.QUERY)} payload: Dict[str, Any] = {"opt": int(operate)}
if data: if data:
payload.update(data) payload.update(data)
return CommunicationObject( return CommunicationObject(
cmd_type=socket_cmd_type(cmd), cmd_type=socket_cmd_type(cmd),
equip_no=equip_no, equip_no=equip_no,
op_type=CommunicationOperate.QUERY, op_type=int(operate),
snno=snno, snno=snno,
data=payload, data=payload,
) )
+15 -5
View File
@@ -65,7 +65,8 @@ class HotspotJoinError(RuntimeError):
"""A join was issued but the association never verified in time.""" """A join was issued but the association never verified in time."""
def derive_hotspot_credentials(sn: str) -> "HotspotProfile": def derive_hotspot_credentials(sn: str,
gateway_ip: Optional[str] = None) -> "HotspotProfile":
"""``getDeviceApSsid`` / ``getDeviceApDefaultPwd``. """``getDeviceApSsid`` / ``getDeviceApDefaultPwd``.
Offline fallback: for a *20-character* SN the SSID is ``"AP_" + sn[11:]`` Offline fallback: for a *20-character* SN the SSID is ``"AP_" + sn[11:]``
@@ -74,6 +75,9 @@ def derive_hotspot_credentials(sn: str) -> "HotspotProfile":
The authoritative values come from the live device The authoritative values come from the live device
(``/terminal/getHotSpotInfo`` + ``/terminal/getHosSpotPassword``). (``/terminal/getHotSpotInfo`` + ``/terminal/getHosSpotPassword``).
``gateway_ip`` overrides the default SoftAP gateway (``10.100.1.1``) —
e.g. when the aGate is reached on its home-LAN address instead.
""" """
if len(sn) == VALID_SN_LENGTH: if len(sn) == VALID_SN_LENGTH:
ssid = "AP_" + sn[11:] ssid = "AP_" + sn[11:]
@@ -83,7 +87,8 @@ def derive_hotspot_credentials(sn: str) -> "HotspotProfile":
len(sn), VALID_SN_LENGTH) len(sn), VALID_SN_LENGTH)
ssid = "AP_" + sn ssid = "AP_" + sn
password = FALLBACK_PASSWORD password = FALLBACK_PASSWORD
return HotspotProfile(ssid=ssid, password=password) return HotspotProfile(ssid=ssid, password=password,
gateway_ip=gateway_ip or DEVICE_GATEWAY_IP)
class WifiBackend(ABC): class WifiBackend(ABC):
@@ -308,9 +313,14 @@ class WifiManager:
# -- device hotspot ------------------------------------------------------ # -- device hotspot ------------------------------------------------------
def join_device_hotspot(self, sn: str, *, verify: bool = True, def join_device_hotspot(self, sn: str, *, verify: bool = True,
settle_timeout: float = 30.0, settle_timeout: float = 30.0,
poll_interval: float = 1.0) -> HotspotProfile: poll_interval: float = 1.0,
"""Derive the profile from the SN, join, and verify the association.""" gateway_ip: Optional[str] = None) -> HotspotProfile:
profile = derive_hotspot_credentials(sn) """Derive the profile from the SN, join, and verify the association.
``gateway_ip`` overrides the device address recorded on the
profile (default ``10.100.1.1``) without changing the radio join.
"""
profile = derive_hotspot_credentials(sn, gateway_ip=gateway_ip)
return self._join(profile, verify=verify, return self._join(profile, verify=verify,
settle_timeout=settle_timeout, settle_timeout=settle_timeout,
poll_interval=poll_interval) poll_interval=poll_interval)
+27
View File
@@ -104,3 +104,30 @@ class TestWattageOp(unittest.TestCase):
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
class TestWifiOps(unittest.TestCase):
def test_join_home_wifi_shape(self):
from franklinwh_agate_client.protocol import direct_connect_request
from franklinwh_agate_client.protocol import CommunicationCmd
from franklinwh_agate_client.protocol import CommunicationOperate
env = direct_connect_request(
CommunicationCmd.CONNECT_WIFI, "SN",
data={"ssid": "Home", "password": "pw",
"wifi_SSID": "Home", "wifi_Pw": "pw"},
operate=CommunicationOperate.SET)
d = env.to_dict()
self.assertEqual(d["cmdType"], 1111) # socket id of connectWifi
self.assertEqual(d["type"], 1) # SET
self.assertEqual(d["dataArea"]["opt"], 1)
self.assertEqual(d["dataArea"]["ssid"], "Home")
def test_scan_and_status_shape(self):
from franklinwh_agate_client.protocol import direct_connect_request
from franklinwh_agate_client.protocol import CommunicationCmd
scan = direct_connect_request(CommunicationCmd.SCAN_WIFI_LIST, "SN")
self.assertEqual(scan.cmd_type, 1109)
self.assertEqual(scan.to_dict()["dataArea"], {"opt": 0})
status = direct_connect_request(
CommunicationCmd.CONNECTION_STATUS, "SN")
self.assertEqual(status.cmd_type, 1113)