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.
This commit is contained in:
1 parent
a87dbe200e
commit
4c0b9de3df
6 files changed
+616
-78
No files matched your search
+16
@@ -0,0 +1,16 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.egg-info/
|
||||||
|
.eggs/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Tooling / caches
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.tox/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
@@ -51,10 +51,48 @@ client = AgateClient()
|
|||||||
device_info = client.query_agate_info(device_id)
|
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
|
## Development
|
||||||
|
|
||||||
This library is a work in progress. Additional operations will be added as reverse engineering continues.
|
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
|
## License
|
||||||
|
|
||||||
Internal use only. Reverse engineering findings for research purposes.
|
Internal use only. Reverse engineering findings for research purposes.
|
||||||
@@ -7,8 +7,33 @@ Provides access to Bluetooth and Wi-Fi network I/O stack for AGATE devices.
|
|||||||
|
|
||||||
from .client import AgateClient
|
from .client import AgateClient
|
||||||
from .bluetooth import BluetoothManager
|
from .bluetooth import BluetoothManager
|
||||||
from .wifi import WifiManager
|
from .wifi import (
|
||||||
|
DEVICE_GATEWAY_IP,
|
||||||
|
FALLBACK_PASSWORD,
|
||||||
|
VALID_SN_LENGTH,
|
||||||
|
HotspotJoinError,
|
||||||
|
HotspotProfile,
|
||||||
|
NoOpWifiBackend,
|
||||||
|
NmcliWifiBackend,
|
||||||
|
WifiBackend,
|
||||||
|
WifiManager,
|
||||||
|
derive_hotspot_credentials,
|
||||||
|
)
|
||||||
from .api import ApiClient
|
from .api import ApiClient
|
||||||
|
|
||||||
__version__ = "0.1.0"
|
__version__ = "0.1.0"
|
||||||
__all__ = ["AgateClient", "BluetoothManager", "WifiManager", "ApiClient"]
|
__all__ = [
|
||||||
|
"AgateClient",
|
||||||
|
"BluetoothManager",
|
||||||
|
"ApiClient",
|
||||||
|
"WifiManager",
|
||||||
|
"WifiBackend",
|
||||||
|
"NmcliWifiBackend",
|
||||||
|
"NoOpWifiBackend",
|
||||||
|
"HotspotProfile",
|
||||||
|
"HotspotJoinError",
|
||||||
|
"derive_hotspot_credentials",
|
||||||
|
"DEVICE_GATEWAY_IP",
|
||||||
|
"FALLBACK_PASSWORD",
|
||||||
|
"VALID_SN_LENGTH",
|
||||||
|
]
|
||||||
@@ -2,25 +2,69 @@
|
|||||||
Agate Client - Main interface for AGATE device operations.
|
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:
|
class AgateClient:
|
||||||
"""Main client for FranklinWH AGATE device operations."""
|
"""Main client for FranklinWH AGATE device operations."""
|
||||||
|
|
||||||
def __init__(self, device_id=None):
|
def __init__(self, device_id: Optional[str] = None,
|
||||||
|
wifi_backend: Optional[WifiBackend] = None) -> None:
|
||||||
self.device_id = device_id
|
self.device_id = device_id
|
||||||
self.bluetooth_manager = None
|
self.bluetooth_manager: Optional[BluetoothManager] = None
|
||||||
self.wifi_manager = None
|
self.wifi_manager = WifiManager(backend=wifi_backend)
|
||||||
|
|
||||||
def query_agate_info(self, device_id=None):
|
# -- 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."""
|
"""Query AGATE device information."""
|
||||||
# Reverse engineered operation: queryAgateInfo
|
# Reverse engineered operation: queryAgateInfo
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def query_device_info_http(self, device_id=None):
|
def query_device_info_http(self, device_id: Optional[str] = None):
|
||||||
"""Query device info via HTTP."""
|
"""Query device info via HTTP."""
|
||||||
# Reverse engineered operation: queryDeviceInfoHttp
|
# Reverse engineered operation: queryDeviceInfoHttp
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def query_modes_by_http(self, device_id=None):
|
def query_modes_by_http(self, device_id: Optional[str] = None):
|
||||||
"""Query operating modes via HTTP."""
|
"""Query operating modes via HTTP."""
|
||||||
# Reverse engineered operation: queryModesByHttp
|
# Reverse engineered operation: queryModesByHttp
|
||||||
pass
|
pass
|
||||||
+214
-66
@@ -7,25 +7,34 @@ and ``com.alternadom.wifiiot.WifiIotPlugin`` (Java side, ``base.apk``) — see
|
|||||||
The app joins the **device SoftAP** so that the phone can talk to the aGate
|
The app joins the **device SoftAP** so that the phone can talk to the aGate
|
||||||
over its local HTTP/socket interfaces (gateway ``10.100.1.1``).
|
over its local HTTP/socket interfaces (gateway ``10.100.1.1``).
|
||||||
|
|
||||||
Credential derivation (confirmed from the disassembly, 20-char SN rule)::
|
How this module maps onto that::
|
||||||
|
|
||||||
ssid = "AP_" + sn[11:] pwd = sn[8:]
|
* credential derivation (``derive_hotspot_credentials``) — faithful
|
||||||
else (invalid length)
|
reconstruction of ``getDeviceApSsid`` / ``getDeviceApDefaultPwd`` (§10).
|
||||||
ssid = "AP_" + sn pwd = "12345678"
|
* the association itself (``WifiBackend.join`` / ``leave``) — platform
|
||||||
|
work. :class:`NmcliWifiBackend` drives NetworkManager on a Linux
|
||||||
|
desktop/laptop; :class:`NoOpWifiBackend` records state only, for
|
||||||
|
hosts whose own tooling owns the radio (containers, manual joins).
|
||||||
|
* ``WifiManager`` — the facade that makes the join *functional*:
|
||||||
|
derive → join → **verify** (poll ``is_connected`` against a deadline,
|
||||||
|
fail-closed) → expose our IP and the device gateway.
|
||||||
|
|
||||||
Actual joining is delegated to a :class:`WifiBackend` so this module stays
|
Note on the app's *other* "wifi" operations: ``connectWifi`` (0x0f) and the
|
||||||
testable without a radio. On a Linux desktop/laptop use
|
ESP-provisioning/onboarding flow (§4, §9) provision the **device** with a
|
||||||
:class:`NmcliWifiBackend` (NetworkManager); in a container or when joining
|
home router — they are device-side RPCs (see ``LocalDeviceClient`` /
|
||||||
manually use :class:`NoOpWifiBackend`.
|
``CommunicationCmd``), not host-radio actions. The compat shims here
|
||||||
|
(``query_connect_wifi`` / ``wifi_onboarding_add_wifi``) only perform the
|
||||||
|
host-side join the app performs at the end of onboarding.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import time
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Optional
|
from typing import Callable, List, Optional
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -37,6 +46,11 @@ VALID_SN_LENGTH = 20
|
|||||||
|
|
||||||
FALLBACK_PASSWORD = "12345678"
|
FALLBACK_PASSWORD = "12345678"
|
||||||
|
|
||||||
|
#: Address prefixes tried (in order) when picking the host address after a
|
||||||
|
#: join. The aGate gateway is 10.100.1.1 and its DHCP normally hands out
|
||||||
|
#: 10.100.1.x; the other prefixes are generic private-range fallbacks.
|
||||||
|
_ADDRESS_PREFIX_PREFERENCE = ("10.100.", "10.", "192.168.", "172.16.")
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class HotspotProfile:
|
class HotspotProfile:
|
||||||
@@ -47,6 +61,10 @@ class HotspotProfile:
|
|||||||
gateway_ip: str = DEVICE_GATEWAY_IP
|
gateway_ip: str = DEVICE_GATEWAY_IP
|
||||||
|
|
||||||
|
|
||||||
|
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) -> "HotspotProfile":
|
||||||
"""``getDeviceApSsid`` / ``getDeviceApDefaultPwd``.
|
"""``getDeviceApSsid`` / ``getDeviceApDefaultPwd``.
|
||||||
|
|
||||||
@@ -69,7 +87,18 @@ def derive_hotspot_credentials(sn: str) -> "HotspotProfile":
|
|||||||
|
|
||||||
|
|
||||||
class WifiBackend(ABC):
|
class WifiBackend(ABC):
|
||||||
"""Platform abstraction for joining/leaving a Wi-Fi network."""
|
"""Platform abstraction for joining/leaving a Wi-Fi network.
|
||||||
|
|
||||||
|
Contract:
|
||||||
|
|
||||||
|
* ``join`` must raise on hard failure (missing tool, rejected SSID…).
|
||||||
|
* ``leave`` is best-effort: do not raise when there is simply nothing
|
||||||
|
to disconnect.
|
||||||
|
* ``is_connected`` must be **fail-closed** (return ``False`` when
|
||||||
|
uncertain, e.g. the underlying tooling is absent). The manager polls
|
||||||
|
it to decide whether a join actually stuck, so a wrong ``True`` is
|
||||||
|
far worse than a late ``False``.
|
||||||
|
"""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def join(self, profile: HotspotProfile) -> None:
|
def join(self, profile: HotspotProfile) -> None:
|
||||||
@@ -77,22 +106,29 @@ class WifiBackend(ABC):
|
|||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def leave(self) -> None:
|
def leave(self) -> None:
|
||||||
"""Disconnect from the device hotspot."""
|
"""Disconnect from the current Wi-Fi network (best-effort)."""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def get_ip(self) -> Optional[str]:
|
def get_ip(self) -> Optional[str]:
|
||||||
"""Our own IP on the current Wi-Fi network (device-side gateway view)."""
|
"""Our own IPv4 address on the currently associated network."""
|
||||||
|
|
||||||
def is_connected(self, profile: HotspotProfile) -> bool:
|
def is_connected(self, profile: HotspotProfile) -> bool:
|
||||||
"""Best-effort: did the join stick?"""
|
"""Fail-closed: is ``profile`` the network we are associated to?"""
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def get_signal(self) -> Optional[int]:
|
||||||
|
"""Signal strength (0-100) of the associated AP, or ``None``."""
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class NoOpWifiBackend(WifiBackend):
|
class NoOpWifiBackend(WifiBackend):
|
||||||
"""Assumes the user has already connected (manually or via another tool).
|
"""Records join/leave state without touching a radio.
|
||||||
|
|
||||||
Useful in sandboxes/containers without a radio, and when the host's own
|
For sandboxes/containers and for hosts where the operator joins the
|
||||||
tooling handles the association.
|
device hotspot manually (or via the host's own tooling). Verification
|
||||||
|
then trivially passes: the backend records the profile ``join`` was
|
||||||
|
given. Use :meth:`assume_connected` when the association was made
|
||||||
|
outside of this process.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, local_ip: Optional[str] = None) -> None:
|
def __init__(self, local_ip: Optional[str] = None) -> None:
|
||||||
@@ -100,10 +136,17 @@ class NoOpWifiBackend(WifiBackend):
|
|||||||
self._profile: Optional[HotspotProfile] = None
|
self._profile: Optional[HotspotProfile] = None
|
||||||
self.joined: bool = False
|
self.joined: bool = False
|
||||||
|
|
||||||
def join(self, profile: HotspotProfile) -> None:
|
def assume_connected(self, profile: HotspotProfile,
|
||||||
|
local_ip: Optional[str] = None) -> None:
|
||||||
|
"""Declare that the host is already associated with ``profile``."""
|
||||||
|
if local_ip is not None:
|
||||||
|
self._local_ip = local_ip
|
||||||
self._profile = profile
|
self._profile = profile
|
||||||
self.joined = True
|
self.joined = True
|
||||||
|
|
||||||
|
def join(self, profile: HotspotProfile) -> None:
|
||||||
|
self.assume_connected(profile)
|
||||||
|
|
||||||
def leave(self) -> None:
|
def leave(self) -> None:
|
||||||
self._profile = None
|
self._profile = None
|
||||||
self.joined = False
|
self.joined = False
|
||||||
@@ -116,18 +159,33 @@ class NoOpWifiBackend(WifiBackend):
|
|||||||
|
|
||||||
|
|
||||||
class NmcliWifiBackend(WifiBackend):
|
class NmcliWifiBackend(WifiBackend):
|
||||||
"""Join via ``nmcli`` (NetworkManager) — for Linux desktops/laptops."""
|
"""Join via ``nmcli`` (NetworkManager) — for Linux desktops/laptops.
|
||||||
|
|
||||||
def __init__(self, nmcli: str = "nmcli", ipcmd: str = "ip") -> None:
|
Verified against nmcli/NetworkManager 1.52 source (``devices.c``):
|
||||||
|
|
||||||
|
* association state: ``nmcli -t -f SSID,ACTIVE device wifi list`` —
|
||||||
|
the ``ACTIVE`` column is ``yes`` *only* for the AP we are associated
|
||||||
|
to. (There is no ``device wifi show`` subcommand; the command table
|
||||||
|
is ``list / connect / hotspot / rescan / show-password`` and a bare
|
||||||
|
``show`` is accepted only by prefix-matching ``show-password``. Using
|
||||||
|
a scan list or that accident to mean "associated" is wrong, and
|
||||||
|
version-dependent — hence ``-f SSID,ACTIVE``.)
|
||||||
|
* our IP: ``nmcli -g IP4.ADDRESS device show <wifi-iface>`` per Wi-Fi
|
||||||
|
interface (never the unfiltered all-device dump, which would happily
|
||||||
|
report a wired/VPN address).
|
||||||
|
* signal: the ``SIGNAL`` column of the active AP from the same list.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, nmcli: str = "nmcli", timeout: float = 30.0) -> None:
|
||||||
self._nmcli = nmcli
|
self._nmcli = nmcli
|
||||||
self._ipcmd = ipcmd
|
self._timeout = timeout
|
||||||
|
|
||||||
def _run(self, *args: str) -> str:
|
def _run(self, *args: str) -> str:
|
||||||
proc = subprocess.run(
|
proc = subprocess.run(
|
||||||
[self._nmcli, *args],
|
[self._nmcli, *args],
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
timeout=30,
|
timeout=self._timeout,
|
||||||
)
|
)
|
||||||
if proc.returncode != 0:
|
if proc.returncode != 0:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
@@ -136,80 +194,170 @@ class NmcliWifiBackend(WifiBackend):
|
|||||||
)
|
)
|
||||||
return proc.stdout.decode("utf-8", "replace").strip()
|
return proc.stdout.decode("utf-8", "replace").strip()
|
||||||
|
|
||||||
|
def _wifi_ifaces(self) -> List[str]:
|
||||||
|
"""Wi-Fi interface names in an associated/active state."""
|
||||||
|
try:
|
||||||
|
out = self._run("-t", "-f", "DEVICE,TYPE,STATE", "device", "show")
|
||||||
|
except (RuntimeError, subprocess.TimeoutExpired, OSError):
|
||||||
|
return []
|
||||||
|
ifaces: List[str] = []
|
||||||
|
for line in out.splitlines():
|
||||||
|
parts = line.split(":")
|
||||||
|
if len(parts) >= 3 and parts[1] == "wifi" and parts[2] in ("activated", "connected"):
|
||||||
|
ifaces.append(parts[0])
|
||||||
|
return ifaces
|
||||||
|
|
||||||
def join(self, profile: HotspotProfile) -> None:
|
def join(self, profile: HotspotProfile) -> None:
|
||||||
self._run("device", "wifi", "connect", profile.ssid,
|
self._run("device", "wifi", "connect", profile.ssid,
|
||||||
"password", profile.password)
|
"password", profile.password)
|
||||||
|
|
||||||
def leave(self) -> None:
|
def leave(self) -> None:
|
||||||
self._run("device", "wifi", "disconnect")
|
# Best-effort: there may be nothing to disconnect (radio off, no
|
||||||
|
# association). Tolerate it — teardown must not fail the flow.
|
||||||
|
try:
|
||||||
|
self._run("device", "wifi", "disconnect")
|
||||||
|
except (RuntimeError, subprocess.TimeoutExpired, OSError) as exc:
|
||||||
|
log.warning("wifi disconnect did not complete cleanly: %s", exc)
|
||||||
|
|
||||||
def get_ip(self) -> Optional[str]:
|
def get_ip(self) -> Optional[str]:
|
||||||
try:
|
candidates: List[str] = []
|
||||||
out = self._run("-g", "IP4.ADDRESS", "device", "show")
|
for dev in self._wifi_ifaces():
|
||||||
except (RuntimeError, subprocess.TimeoutExpired, OSError):
|
try:
|
||||||
return None
|
out = self._run("-g", "IP4.ADDRESS", "device", "show", dev)
|
||||||
for line in out.splitlines():
|
except (RuntimeError, subprocess.TimeoutExpired, OSError):
|
||||||
addr = line.strip()
|
continue
|
||||||
if addr.startswith("10.") or addr.startswith("192.168."):
|
candidates.extend(a.strip() for a in out.splitlines() if a.strip())
|
||||||
return addr
|
for prefix in _ADDRESS_PREFIX_PREFERENCE:
|
||||||
return None
|
for addr in candidates:
|
||||||
|
if addr.startswith(prefix):
|
||||||
|
return addr
|
||||||
|
return candidates[0] if candidates else None
|
||||||
|
|
||||||
def is_connected(self, profile: HotspotProfile) -> bool:
|
def is_connected(self, profile: HotspotProfile) -> bool:
|
||||||
try:
|
try:
|
||||||
out = self._run("-g", "SSID", "device", "wifi", "show")
|
out = self._run("-t", "-f", "SSID,ACTIVE", "device", "wifi", "list")
|
||||||
except (RuntimeError, subprocess.TimeoutExpired, OSError):
|
except (RuntimeError, subprocess.TimeoutExpired, OSError):
|
||||||
return False
|
return False # fail-closed: cannot confirm association
|
||||||
return profile.ssid in out.splitlines()
|
return f"{profile.ssid}:yes" in out.splitlines()
|
||||||
|
|
||||||
|
def get_signal(self) -> Optional[int]:
|
||||||
|
try:
|
||||||
|
out = self._run("-t", "-f", "SSID,SIGNAL,ACTIVE", "device", "wifi", "list")
|
||||||
|
except (RuntimeError, subprocess.TimeoutExpired, OSError):
|
||||||
|
return None
|
||||||
|
for line in out.splitlines():
|
||||||
|
# SSIDs may contain ':'; the trailing field is the ACTIVE flag.
|
||||||
|
parts = line.rsplit(":", 2)
|
||||||
|
if len(parts) == 3 and parts[2] == "yes":
|
||||||
|
try:
|
||||||
|
return int(parts[1])
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class WifiManager:
|
class WifiManager:
|
||||||
"""Facade over a :class:`WifiBackend` (keeps the original stub API)."""
|
"""Facade over a :class:`WifiBackend` — derive, join, **verify**.
|
||||||
|
|
||||||
|
``join_*`` do not take the backend's exit code on faith: after
|
||||||
|
``join`` they poll ``backend.is_connected(profile)`` against
|
||||||
|
``settle_timeout`` (NetworkManager can report success before the
|
||||||
|
association actually settles, and it can fail a few seconds later).
|
||||||
|
An unverified join raises :class:`HotspotJoinError` and leaves
|
||||||
|
``connected`` ``False``. ``verify=False`` restores fire-and-forget.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self, backend: Optional[WifiBackend] = None) -> None:
|
def __init__(self, backend: Optional[WifiBackend] = None) -> None:
|
||||||
self.backend = backend or NoOpWifiBackend()
|
self.backend = backend or NoOpWifiBackend()
|
||||||
self.connected = False
|
self.connected = False
|
||||||
|
self.last_profile: Optional[HotspotProfile] = None
|
||||||
|
|
||||||
# -- new, clean API ----------------------------------------------------
|
# -- internal ----------------------------------------------------------
|
||||||
def join_device_hotspot(self, sn: str) -> HotspotProfile:
|
def _join(self, profile: HotspotProfile, *, verify: bool,
|
||||||
"""Derive the profile from the SN and join. Returns the profile."""
|
settle_timeout: float, poll_interval: float) -> HotspotProfile:
|
||||||
profile = derive_hotspot_credentials(sn)
|
self.last_profile = profile
|
||||||
self.backend.join(profile)
|
self.connected = False
|
||||||
self.connected = True
|
self.backend.join(profile) # raises on hard failure
|
||||||
|
ok = self._verify(
|
||||||
|
lambda: self.backend.is_connected(profile),
|
||||||
|
settle_timeout, poll_interval,
|
||||||
|
) if verify else True
|
||||||
|
self.connected = ok
|
||||||
|
if not ok:
|
||||||
|
raise HotspotJoinError(
|
||||||
|
f"join to '{profile.ssid}' was issued but association was "
|
||||||
|
f"not confirmed within {settle_timeout:g}s"
|
||||||
|
)
|
||||||
return profile
|
return profile
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _verify(check: Callable[[], bool], timeout: float,
|
||||||
|
poll_interval: float) -> bool:
|
||||||
|
"""Poll ``check()`` until it is true or ``timeout`` elapses."""
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
if check():
|
||||||
|
return True
|
||||||
|
except Exception as exc: # a broken backend must fail closed
|
||||||
|
log.debug("is_connected check raised: %s", exc)
|
||||||
|
if time.monotonic() >= deadline:
|
||||||
|
return False
|
||||||
|
time.sleep(max(poll_interval, 0.0))
|
||||||
|
|
||||||
|
# -- 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)
|
||||||
|
return self._join(profile, verify=verify,
|
||||||
|
settle_timeout=settle_timeout,
|
||||||
|
poll_interval=poll_interval)
|
||||||
|
|
||||||
|
def join_network(self, ssid: str, password: str, *, verify: bool = True,
|
||||||
|
settle_timeout: float = 30.0,
|
||||||
|
poll_interval: float = 1.0) -> HotspotProfile:
|
||||||
|
"""Join an arbitrary network (host side)."""
|
||||||
|
return self._join(HotspotProfile(ssid=ssid, password=password),
|
||||||
|
verify=verify, settle_timeout=settle_timeout,
|
||||||
|
poll_interval=poll_interval)
|
||||||
|
|
||||||
def leave_device_hotspot(self) -> None:
|
def leave_device_hotspot(self) -> None:
|
||||||
self.backend.leave()
|
self.backend.leave()
|
||||||
self.connected = False
|
self.connected = False
|
||||||
|
self.last_profile = None
|
||||||
|
|
||||||
|
# -- status --------------------------------------------------------------
|
||||||
def local_ip(self) -> Optional[str]:
|
def local_ip(self) -> Optional[str]:
|
||||||
|
"""Our own IP on the associated network (``None`` if unknown)."""
|
||||||
return self.backend.get_ip()
|
return self.backend.get_ip()
|
||||||
|
|
||||||
|
def gateway_ip(self) -> str:
|
||||||
|
"""The aGate's IP on the hotspot (where the local API lives)."""
|
||||||
|
return self.last_profile.gateway_ip if self.last_profile else DEVICE_GATEWAY_IP
|
||||||
|
|
||||||
|
def get_wifi_signal_strength(self) -> Optional[int]:
|
||||||
|
return self.backend.get_signal()
|
||||||
|
|
||||||
# -- original stub names (kept for API compatibility) -------------------
|
# -- original stub names (kept for API compatibility) -------------------
|
||||||
def query_connect_wifi(self, ssid: str, password: Optional[str] = None,
|
def query_connect_wifi(self, ssid: str, password: Optional[str] = None,
|
||||||
sn: Optional[str] = None):
|
sn: Optional[str] = None, **join_kwargs: object):
|
||||||
"""``queryConnectWifi`` — join (or rejoin) a network."""
|
"""Join an arbitrary network (host side); with ``sn`` only, join the
|
||||||
|
device hotspot — same as :meth:`join_device_hotspot`.
|
||||||
|
|
||||||
|
NB: in the app, ``queryConnectWifi`` is the *device* RPC
|
||||||
|
(``CommunicationCmd.CONNECT_WIFI``, 0x0f) that tells the aGate to
|
||||||
|
join a router — a provisioning operation, not a host-radio action.
|
||||||
|
"""
|
||||||
if sn is not None and password is None:
|
if sn is not None and password is None:
|
||||||
profile = derive_hotspot_credentials(sn)
|
return self.join_device_hotspot(sn, **join_kwargs)
|
||||||
else:
|
return self.join_network(ssid, password or "", **join_kwargs)
|
||||||
profile = HotspotProfile(ssid=ssid, password=password or "")
|
|
||||||
self.backend.join(profile)
|
|
||||||
self.connected = True
|
|
||||||
return profile
|
|
||||||
|
|
||||||
def wifi_onboarding_add_wifi(self, ssid: str, password: str):
|
def wifi_onboarding_add_wifi(self, ssid: str, password: str, **join_kwargs: object):
|
||||||
"""``wifiOnboardingAddWifi`` — register a known network."""
|
"""Host-side step of the app's Wi-Fi onboarding: after the aGate has
|
||||||
self.backend.join(HotspotProfile(ssid=ssid, password=password))
|
been provisioned with the home router (device-side — see
|
||||||
return True
|
``CommunicationCmd.SCAN_WIFI_LIST`` / ``CONNECT_WIFI``), the phone
|
||||||
|
rejoins the home network. This performs that host-side rejoin.
|
||||||
def get_wifi_signal_strength(self) -> Optional[int]:
|
"""
|
||||||
if not isinstance(self.backend, NmcliWifiBackend):
|
return self.join_network(ssid, password, **join_kwargs)
|
||||||
return None
|
|
||||||
try:
|
|
||||||
out = self.backend._run("-g", "SIGNAL", "device", "wifi", "show")
|
|
||||||
except (RuntimeError, subprocess.TimeoutExpired, OSError):
|
|
||||||
return None
|
|
||||||
for line in out.splitlines():
|
|
||||||
digits = "".join(ch for ch in line if ch.isdigit())
|
|
||||||
if digits:
|
|
||||||
return int(digits)
|
|
||||||
return None
|
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
"""Tests for the Wi-Fi join/leave abstraction (no radio, no NetworkManager).
|
||||||
|
|
||||||
|
Run with either::
|
||||||
|
|
||||||
|
python -m unittest discover -s tests
|
||||||
|
python -m pytest tests # if pytest is installed
|
||||||
|
|
||||||
|
The ``NmcliWifiBackend`` tests inject a fake ``_run`` so they exercise the
|
||||||
|
parsing / fail-closed logic without a real ``nmcli`` binary.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||||
|
|
||||||
|
from franklinwh_agate_client.wifi import (
|
||||||
|
DEVICE_GATEWAY_IP,
|
||||||
|
HotspotJoinError,
|
||||||
|
HotspotProfile,
|
||||||
|
NmcliWifiBackend,
|
||||||
|
NoOpWifiBackend,
|
||||||
|
WifiBackend,
|
||||||
|
WifiManager,
|
||||||
|
derive_hotspot_credentials,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeBackend(WifiBackend):
|
||||||
|
"""An in-memory backend whose association outcome we control."""
|
||||||
|
|
||||||
|
def __init__(self, associated: bool = True, ip: str = "10.100.1.2") -> None:
|
||||||
|
self.associated = associated
|
||||||
|
self._ip = ip
|
||||||
|
self._profile: HotspotProfile | None = None
|
||||||
|
self.joined_calls = 0
|
||||||
|
self.leave_calls = 0
|
||||||
|
|
||||||
|
def join(self, profile: HotspotProfile) -> None:
|
||||||
|
self.joined_calls += 1
|
||||||
|
self._profile = profile
|
||||||
|
|
||||||
|
def leave(self) -> None:
|
||||||
|
self.leave_calls += 1
|
||||||
|
self._profile = None
|
||||||
|
|
||||||
|
def get_ip(self):
|
||||||
|
return self._ip if self._profile else None
|
||||||
|
|
||||||
|
def is_connected(self, profile: HotspotProfile) -> bool:
|
||||||
|
return self.associated and self._profile is not None and self._profile == profile
|
||||||
|
|
||||||
|
def get_signal(self):
|
||||||
|
return 73 if self._profile else None
|
||||||
|
|
||||||
|
|
||||||
|
class _BoomBackend(_FakeBackend):
|
||||||
|
"""join() raises — models a hard association failure."""
|
||||||
|
|
||||||
|
def join(self, profile: HotspotProfile) -> None:
|
||||||
|
self.joined_calls += 1
|
||||||
|
raise RuntimeError("nmcli: device not found")
|
||||||
|
|
||||||
|
|
||||||
|
# A 20-char SN so the "valid" derivation branch is exercised.
|
||||||
|
SN20 = "SN0123456789ABCDEFGH"
|
||||||
|
assert len(SN20) == 20
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeriveHotspotCredentials(unittest.TestCase):
|
||||||
|
def test_valid_20char_sn(self):
|
||||||
|
p = derive_hotspot_credentials(SN20)
|
||||||
|
self.assertEqual(p.ssid, "AP_" + SN20[11:])
|
||||||
|
self.assertEqual(p.password, SN20[8:])
|
||||||
|
self.assertEqual(p.gateway_ip, DEVICE_GATEWAY_IP)
|
||||||
|
|
||||||
|
def test_invalid_length_uses_fallback(self):
|
||||||
|
p = derive_hotspot_credentials("short")
|
||||||
|
self.assertEqual(p.ssid, "AP_short")
|
||||||
|
self.assertEqual(p.password, "12345678")
|
||||||
|
self.assertEqual(p.gateway_ip, DEVICE_GATEWAY_IP)
|
||||||
|
|
||||||
|
|
||||||
|
class TestWifiManagerWithFakeBackend(unittest.TestCase):
|
||||||
|
def _manager(self, **kw) -> WifiManager:
|
||||||
|
return WifiManager(backend=_FakeBackend(**kw))
|
||||||
|
|
||||||
|
def test_join_verified_success(self):
|
||||||
|
m = self._manager(associated=True)
|
||||||
|
profile = m.join_device_hotspot(SN20, settle_timeout=5, poll_interval=0.01)
|
||||||
|
self.assertTrue(m.connected)
|
||||||
|
self.assertEqual(profile.ssid, "AP_" + SN20[11:])
|
||||||
|
self.assertEqual(m.last_profile, profile)
|
||||||
|
self.assertEqual(m.gateway_ip(), DEVICE_GATEWAY_IP)
|
||||||
|
self.assertEqual(m.local_ip(), "10.100.1.2")
|
||||||
|
|
||||||
|
def test_join_unverified_raises_and_stays_disconnected(self):
|
||||||
|
m = self._manager(associated=False)
|
||||||
|
with self.assertRaises(HotspotJoinError):
|
||||||
|
m.join_device_hotspot(SN20, settle_timeout=0.1, poll_interval=0.01)
|
||||||
|
self.assertFalse(m.connected)
|
||||||
|
|
||||||
|
def test_hard_join_failure_propagates(self):
|
||||||
|
m = WifiManager(backend=_BoomBackend(associated=True))
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
m.join_device_hotspot(SN20)
|
||||||
|
self.assertFalse(m.connected)
|
||||||
|
|
||||||
|
def test_verify_false_skips_check(self):
|
||||||
|
m = self._manager(associated=False)
|
||||||
|
profile = m.join_device_hotspot(SN20, verify=False)
|
||||||
|
self.assertTrue(m.connected)
|
||||||
|
self.assertEqual(profile.ssid, "AP_" + SN20[11:])
|
||||||
|
|
||||||
|
def test_leave_resets_state(self):
|
||||||
|
m = self._manager(associated=True)
|
||||||
|
m.join_device_hotspot(SN20, settle_timeout=5, poll_interval=0.01)
|
||||||
|
m.leave_device_hotspot()
|
||||||
|
self.assertFalse(m.connected)
|
||||||
|
self.assertIsNone(m.last_profile)
|
||||||
|
self.assertEqual(m.gateway_ip(), DEVICE_GATEWAY_IP) # fallback
|
||||||
|
self.assertEqual(m.local_ip(), None)
|
||||||
|
|
||||||
|
def test_signal_delegates_to_backend(self):
|
||||||
|
m = self._manager(associated=True)
|
||||||
|
m.join_device_hotspot(SN20, settle_timeout=5, poll_interval=0.01)
|
||||||
|
self.assertEqual(m.get_wifi_signal_strength(), 73)
|
||||||
|
|
||||||
|
def test_default_backend_is_noop(self):
|
||||||
|
m = WifiManager()
|
||||||
|
self.assertIsInstance(m.backend, NoOpWifiBackend)
|
||||||
|
m.join_device_hotspot(SN20, settle_timeout=1, poll_interval=0.01)
|
||||||
|
self.assertTrue(m.connected)
|
||||||
|
|
||||||
|
|
||||||
|
class TestNoOpBackend(unittest.TestCase):
|
||||||
|
def test_assume_connected_roundtrip(self):
|
||||||
|
b = NoOpWifiBackend(local_ip="10.100.1.9")
|
||||||
|
p = HotspotProfile(ssid="AP_x", password="pw")
|
||||||
|
b.assume_connected(p)
|
||||||
|
self.assertTrue(b.is_connected(p))
|
||||||
|
self.assertEqual(b.get_ip(), "10.100.1.9")
|
||||||
|
b.leave()
|
||||||
|
self.assertFalse(b.is_connected(p))
|
||||||
|
|
||||||
|
|
||||||
|
class _RunStub:
|
||||||
|
"""Replace ``NmcliWifiBackend._run`` with canned output / errors.
|
||||||
|
|
||||||
|
``responses`` maps a leading-argument tuple
|
||||||
|
(e.g. ``("-t", "-f", "SSID,ACTIVE", "device", "wifi", "list")``) to a
|
||||||
|
string result or an Exception to raise.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, responses: dict, backend: NmcliWifiBackend) -> None:
|
||||||
|
self.responses = responses
|
||||||
|
self.backend = backend
|
||||||
|
self.calls: list[tuple] = []
|
||||||
|
|
||||||
|
def __call__(self, *args):
|
||||||
|
self.calls.append(args)
|
||||||
|
for key, val in self.responses.items():
|
||||||
|
if args[: len(key)] == key:
|
||||||
|
if isinstance(val, Exception):
|
||||||
|
raise val
|
||||||
|
return val
|
||||||
|
raise AssertionError(f"unexpected nmcli invocation: {args}")
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_run(backend: NmcliWifiBackend, responses: dict) -> _RunStub:
|
||||||
|
stub = _RunStub(responses, backend)
|
||||||
|
backend._run = stub # type: ignore[method-assign]
|
||||||
|
return stub
|
||||||
|
|
||||||
|
|
||||||
|
class TestNmcliParsing(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.backend = NmcliWifiBackend()
|
||||||
|
self.profile = HotspotProfile(ssid="AP_SN123", password="pw")
|
||||||
|
|
||||||
|
def test_is_connected_true_when_active_yes(self):
|
||||||
|
_patch_run(self.backend, {
|
||||||
|
("-t", "-f", "SSID,ACTIVE", "device", "wifi", "list"):
|
||||||
|
"AP_SN123:yes\nOtherAP:no\n",
|
||||||
|
})
|
||||||
|
self.assertTrue(self.backend.is_connected(self.profile))
|
||||||
|
|
||||||
|
def test_is_connected_false_when_in_range_but_not_active(self):
|
||||||
|
# Regression: a visible (in-range) AP is NOT an association.
|
||||||
|
_patch_run(self.backend, {
|
||||||
|
("-t", "-f", "SSID,ACTIVE", "device", "wifi", "list"):
|
||||||
|
"AP_SN123:no\nOtherAP:yes\n",
|
||||||
|
})
|
||||||
|
self.assertFalse(self.backend.is_connected(self.profile))
|
||||||
|
|
||||||
|
def test_is_connected_fail_closed_on_error(self):
|
||||||
|
_patch_run(self.backend, {
|
||||||
|
("-t", "-f", "SSID,ACTIVE", "device", "wifi", "list"):
|
||||||
|
RuntimeError("nmcli: no NetworkManager"),
|
||||||
|
})
|
||||||
|
self.assertFalse(self.backend.is_connected(self.profile))
|
||||||
|
|
||||||
|
def test_get_ip_prefers_10_100_subnet(self):
|
||||||
|
_patch_run(self.backend, {
|
||||||
|
("-t", "-f", "DEVICE,TYPE,STATE", "device", "show"):
|
||||||
|
"wlan0:wifi:activated\neth0:ethernet:activated\n",
|
||||||
|
("-g", "IP4.ADDRESS", "device", "show", "wlan0"):
|
||||||
|
"192.168.1.50\n10.100.1.50\n",
|
||||||
|
})
|
||||||
|
self.assertEqual(self.backend.get_ip(), "10.100.1.50")
|
||||||
|
|
||||||
|
def test_get_ip_ignores_non_wifi_and_unlisted(self):
|
||||||
|
# eth0 is not wifi, so it must never be queried for an address.
|
||||||
|
stub = _patch_run(self.backend, {
|
||||||
|
("-t", "-f", "DEVICE,TYPE,STATE", "device", "show"):
|
||||||
|
"wlan0:wifi:activated\neth0:ethernet:activated\n",
|
||||||
|
("-g", "IP4.ADDRESS", "device", "show", "wlan0"): "10.100.1.50\n",
|
||||||
|
})
|
||||||
|
self.assertEqual(self.backend.get_ip(), "10.100.1.50")
|
||||||
|
self.assertNotIn(
|
||||||
|
("-g", "IP4.ADDRESS", "device", "show", "eth0"), stub.calls
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_get_ip_none_when_no_wifi_activated(self):
|
||||||
|
_patch_run(self.backend, {
|
||||||
|
("-t", "-f", "DEVICE,TYPE,STATE", "device", "show"):
|
||||||
|
"eth0:ethernet:activated\n",
|
||||||
|
})
|
||||||
|
self.assertIsNone(self.backend.get_ip())
|
||||||
|
|
||||||
|
def test_get_signal_reads_active_ap(self):
|
||||||
|
_patch_run(self.backend, {
|
||||||
|
("-t", "-f", "SSID,SIGNAL,ACTIVE", "device", "wifi", "list"):
|
||||||
|
"AP_SN123:73:yes\nOtherAP:20:no\n",
|
||||||
|
})
|
||||||
|
self.assertEqual(self.backend.get_signal(), 73)
|
||||||
|
|
||||||
|
def test_get_signal_none_when_no_active(self):
|
||||||
|
_patch_run(self.backend, {
|
||||||
|
("-t", "-f", "SSID,SIGNAL,ACTIVE", "device", "wifi", "list"):
|
||||||
|
"OtherAP:20:no\n",
|
||||||
|
})
|
||||||
|
self.assertIsNone(self.backend.get_signal())
|
||||||
|
|
||||||
|
def test_leave_tolerates_nothing_to_disconnect(self):
|
||||||
|
stub = _patch_run(self.backend, {
|
||||||
|
("device", "wifi", "disconnect"): RuntimeError("There is no Wi-Fi device"),
|
||||||
|
})
|
||||||
|
self.backend.leave() # must not raise
|
||||||
|
self.assertEqual(len(stub.calls), 1)
|
||||||
|
|
||||||
|
def test_join_passes_ssid_and_password(self):
|
||||||
|
stub = _patch_run(self.backend, {
|
||||||
|
("device", "wifi", "connect"): "",
|
||||||
|
})
|
||||||
|
self.backend.join(self.profile)
|
||||||
|
self.assertEqual(
|
||||||
|
stub.calls[0],
|
||||||
|
("device", "wifi", "connect", "AP_SN123", "password", "pw"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in new issue
Block a user