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.
This commit is contained in:
kfj001 committed 2026-10-16 08:18:33 +00:00
1 parent eda78dd7d1
commit b60b32830f
4 files changed
+74 -24

No files matched your search

+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):
+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.
+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)