Files
franklinwh-agate-client/franklinwh_agate_client/client.py
T
kfj001 4c0b9de3df Make Wi-Fi join/leave actually functional
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.
2026-10-13 02:27:43 +00:00

71 lines
2.9 KiB
Python

"""
Agate Client - Main interface for AGATE device operations.
"""
from __future__ import annotations
from typing import Any, Optional
from .bluetooth import BluetoothManager
from .wifi import HotspotProfile, WifiBackend, WifiManager
class AgateClient:
"""Main client for FranklinWH AGATE device operations."""
def __init__(self, device_id: Optional[str] = None,
wifi_backend: Optional[WifiBackend] = None) -> None:
self.device_id = device_id
self.bluetooth_manager: Optional[BluetoothManager] = None
self.wifi_manager = WifiManager(backend=wifi_backend)
# -- 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 leave_hotspot(self) -> None:
"""Disconnect from the device hotspot."""
self.wifi_manager.leave_device_hotspot()
def attach_local(self, sn: str, *, verify_join: bool = True,
settle_timeout: float = 30.0, poll_interval: float = 1.0,
**local_kwargs: Any):
"""Join the device hotspot, then return a ``LocalDeviceClient``
aimed at the device gateway (``https://10.100.1.1:18000`` by default).
``verify_join`` gates the association check (TLS verification of the
device API is a separate concern — pass ``verify=`` in
``local_kwargs``). Raises
:class:`~franklinwh_agate_client.wifi.HotspotJoinError` if the
association never verifies. (``LocalDeviceClient`` imports
``requests``; install it before calling this on a cloud-less host.)
Extra keyword arguments are passed through to ``LocalDeviceClient``
(``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,
)
from .device_local import LocalDeviceClient
return LocalDeviceClient(sn=sn, host=profile.gateway_ip, **local_kwargs)
# -- cloud / stub operations (pending RE) -------------------------------
def query_agate_info(self, device_id: Optional[str] = None):
"""Query AGATE device information."""
# Reverse engineered operation: queryAgateInfo
pass
def query_device_info_http(self, device_id: Optional[str] = None):
"""Query device info via HTTP."""
# Reverse engineered operation: queryDeviceInfoHttp
pass
def query_modes_by_http(self, device_id: Optional[str] = None):
"""Query operating modes via HTTP."""
# Reverse engineered operation: queryModesByHttp
pass