diff --git a/franklinwh_agate_client/battery.py b/franklinwh_agate_client/battery.py index f899de9..ffa9126 100644 --- a/franklinwh_agate_client/battery.py +++ b/franklinwh_agate_client/battery.py @@ -20,7 +20,7 @@ from typing import Any, Dict, List, Optional, Tuple from .device_local import LocalDeviceClient from .protocol import CommunicationCmd -from .wifi import derive_hotspot_credentials +from .wifi import DEVICE_GATEWAY_IP, derive_hotspot_credentials log = logging.getLogger(__name__) @@ -144,7 +144,7 @@ def _is_empty_pack(reply: Dict[str, Any], result: Dict[str, Any]) -> bool: def get_battery( sn: str, - host: str = "10.100.1.1", + host: str = DEVICE_GATEWAY_IP, ports: Tuple[int, ...] = SOCKET_PORT_CANDIDATES, key_radix: int = 16, timeout: float = 5.0, @@ -197,7 +197,7 @@ PROBE_VARIANTS: Tuple[Tuple[str, CommunicationCmd, Any], ...] = ( def probe_device( sn: str, - host: str = "10.100.1.1", + host: str = DEVICE_GATEWAY_IP, ports: Tuple[int, ...] = SOCKET_PORT_CANDIDATES, key_radix: int = 16, timeout: float = 5.0, @@ -262,7 +262,7 @@ _MQTT_CONNECT = bytes.fromhex("101200044d5154540402003c000670726f62653031") def probe_transport( sn: str, - host: str = "10.100.1.1", + host: str = DEVICE_GATEWAY_IP, ports: Tuple[int, ...] = (9000,), key_radix: int = 16, 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." ) 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( "--ports", default=",".join(map(str, SOCKET_PORT_CANDIDATES)), help="comma-separated socket ports to probe", diff --git a/franklinwh_agate_client/client.py b/franklinwh_agate_client/client.py index 7d4fc03..5af5a5d 100644 --- a/franklinwh_agate_client/client.py +++ b/franklinwh_agate_client/client.py @@ -7,22 +7,39 @@ from __future__ import annotations from typing import Any, Optional from .bluetooth import BluetoothManager -from .wifi import HotspotProfile, WifiBackend, WifiManager +from .wifi import DEVICE_GATEWAY_IP, HotspotProfile, WifiBackend, WifiManager 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, - wifi_backend: Optional[WifiBackend] = None) -> None: + wifi_backend: Optional[WifiBackend] = None, + address: Optional[str] = None) -> None: self.device_id = device_id self.bluetooth_manager: Optional[BluetoothManager] = None self.wifi_manager = WifiManager(backend=wifi_backend) + self.address = address # -- device hotspot (local connect) ------------------------------------- - def join_hotspot(self, sn: str, **kwargs: Any) -> HotspotProfile: - """Join the device SoftAP (derives creds, joins, verifies).""" - return self.wifi_manager.join_device_hotspot(sn, **kwargs) + def join_hotspot(self, sn: str, gateway_ip: Optional[str] = None, + **kwargs: Any) -> HotspotProfile: + """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: """Disconnect from the device hotspot.""" @@ -30,9 +47,23 @@ class AgateClient: def attach_local(self, sn: str, *, verify_join: bool = True, 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): - """Join the device hotspot, then return a ``LocalDeviceClient`` - aimed at the device gateway (``https://10.100.1.1:18000`` by default). + """Join the device hotspot, then return a ``LocalDeviceClient``. + + 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 device API is a separate concern — pass ``verify=`` in @@ -45,13 +76,21 @@ class AgateClient: (``port``, ``scheme``, ``verify``, ``socket_port``, ``key_radix``, ``timeout``, ``extra_headers``, ``session``). """ - profile = self.wifi_manager.join_device_hotspot( - sn, verify=verify_join, settle_timeout=settle_timeout, - poll_interval=poll_interval, - ) + explicit = address if address is not None else ( + host if host is not None else gateway_ip) + 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 - 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) ------------------------------- def query_agate_info(self, device_id: Optional[str] = None): diff --git a/franklinwh_agate_client/power.py b/franklinwh_agate_client/power.py index 378845e..07999c0 100644 --- a/franklinwh_agate_client/power.py +++ b/franklinwh_agate_client/power.py @@ -17,10 +17,11 @@ from typing import Any, Dict, List, Optional, Tuple from .battery import _walk from .device_local import LocalDeviceClient +from .wifi import DEVICE_GATEWAY_IP log = logging.getLogger(__name__) -HOST = "10.100.1.1" +HOST = DEVICE_GATEWAY_IP PORTS: Tuple[int, ...] = (9000, 18000) #: Reply keys holding watt readings, in priority order. diff --git a/franklinwh_agate_client/wifi.py b/franklinwh_agate_client/wifi.py index 764f7b6..b031083 100644 --- a/franklinwh_agate_client/wifi.py +++ b/franklinwh_agate_client/wifi.py @@ -65,7 +65,8 @@ class HotspotJoinError(RuntimeError): """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``. 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 (``/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: ssid = "AP_" + sn[11:] @@ -83,7 +87,8 @@ def derive_hotspot_credentials(sn: str) -> "HotspotProfile": len(sn), VALID_SN_LENGTH) ssid = "AP_" + sn 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): @@ -308,9 +313,14 @@ class WifiManager: # -- device hotspot ------------------------------------------------------ def join_device_hotspot(self, sn: str, *, verify: bool = True, settle_timeout: float = 30.0, - poll_interval: float = 1.0) -> HotspotProfile: - """Derive the profile from the SN, join, and verify the association.""" - profile = derive_hotspot_credentials(sn) + poll_interval: float = 1.0, + gateway_ip: Optional[str] = None) -> HotspotProfile: + """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, settle_timeout=settle_timeout, poll_interval=poll_interval)