wifi-controller 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- wifi_controller/__init__.py +357 -0
- wifi_controller/_abc.py +66 -0
- wifi_controller/_linux.py +170 -0
- wifi_controller/_macos.py +164 -0
- wifi_controller/_swift.py +197 -0
- wifi_controller/_types.py +19 -0
- wifi_controller/py.typed +0 -0
- wifi_controller-0.1.0.dist-info/LICENSE +21 -0
- wifi_controller-0.1.0.dist-info/METADATA +199 -0
- wifi_controller-0.1.0.dist-info/RECORD +11 -0
- wifi_controller-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
"""Cross-platform Wi-Fi controller with pluggable providers.
|
|
2
|
+
|
|
3
|
+
Detects the current OS and registers known-good providers automatically.
|
|
4
|
+
External code can register additional providers (e.g., a Swift-based scanner
|
|
5
|
+
for macOS SSID redaction workarounds).
|
|
6
|
+
|
|
7
|
+
Usage::
|
|
8
|
+
|
|
9
|
+
from wifi_controller import WiFiController
|
|
10
|
+
|
|
11
|
+
wifi = WiFiController()
|
|
12
|
+
ssid = wifi.get_current_ssid()
|
|
13
|
+
networks = wifi.scan()
|
|
14
|
+
wifi.connect("MyNetwork", "hunter2")
|
|
15
|
+
wifi.disconnect()
|
|
16
|
+
|
|
17
|
+
# Poll for a specific SSID
|
|
18
|
+
found = wifi.scan_for_ssid("TestAP 5678", timeout_sec=30)
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import platform
|
|
25
|
+
import subprocess
|
|
26
|
+
import time
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
from bear_tools.lumberjack import Logger
|
|
31
|
+
|
|
32
|
+
from wifi_controller._abc import (
|
|
33
|
+
CurrentSSIDProvider,
|
|
34
|
+
SSIDConnectProvider,
|
|
35
|
+
SSIDDisconnectProvider,
|
|
36
|
+
SSIDScanProvider,
|
|
37
|
+
)
|
|
38
|
+
from wifi_controller._linux import (
|
|
39
|
+
IwgetidCurrentSSID,
|
|
40
|
+
NmcliConnect,
|
|
41
|
+
NmcliCurrentSSID,
|
|
42
|
+
NmcliDisconnect,
|
|
43
|
+
NmcliScan,
|
|
44
|
+
)
|
|
45
|
+
from wifi_controller._macos import (
|
|
46
|
+
IpconfigCurrentSSID,
|
|
47
|
+
NetworkSetupConnect,
|
|
48
|
+
NetworkSetupCurrentSSID,
|
|
49
|
+
NetworkSetupDisconnect,
|
|
50
|
+
SystemProfilerScan,
|
|
51
|
+
)
|
|
52
|
+
from wifi_controller._types import SSIDInfo, WiFiConnectionError
|
|
53
|
+
|
|
54
|
+
logger = Logger()
|
|
55
|
+
|
|
56
|
+
__all__ = [
|
|
57
|
+
"WiFiController",
|
|
58
|
+
"SSIDInfo",
|
|
59
|
+
"WiFiConnectionError",
|
|
60
|
+
"CurrentSSIDProvider",
|
|
61
|
+
"SSIDScanProvider",
|
|
62
|
+
"SSIDConnectProvider",
|
|
63
|
+
"SSIDDisconnectProvider",
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class _RegisteredProvider:
|
|
69
|
+
provider: CurrentSSIDProvider | SSIDScanProvider | SSIDConnectProvider | SSIDDisconnectProvider
|
|
70
|
+
priority: int
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class WiFiController:
|
|
74
|
+
"""Orchestrates Wi-Fi operations across pluggable, per-operation providers.
|
|
75
|
+
|
|
76
|
+
On construction, detects the OS and registers built-in providers that are
|
|
77
|
+
known to work on that platform/version. If no provider is available for a
|
|
78
|
+
given operation, a warning is logged so the caller knows to register their
|
|
79
|
+
own.
|
|
80
|
+
|
|
81
|
+
:param interface: Network interface name. Auto-detected if ``None``.
|
|
82
|
+
:param cache_path: Optional JSON file to persist provider selections across runs.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
def __init__(
|
|
86
|
+
self,
|
|
87
|
+
interface: str | None = None,
|
|
88
|
+
cache_path: Path | None = None,
|
|
89
|
+
) -> None:
|
|
90
|
+
self._current_providers: list[_RegisteredProvider] = []
|
|
91
|
+
self._scan_providers: list[_RegisteredProvider] = []
|
|
92
|
+
self._connect_providers: list[_RegisteredProvider] = []
|
|
93
|
+
self._disconnect_providers: list[_RegisteredProvider] = []
|
|
94
|
+
|
|
95
|
+
self._resolved_current: CurrentSSIDProvider | None = None
|
|
96
|
+
self._resolved_scan: SSIDScanProvider | None = None
|
|
97
|
+
self._resolved_connect: SSIDConnectProvider | None = None
|
|
98
|
+
self._resolved_disconnect: SSIDDisconnectProvider | None = None
|
|
99
|
+
|
|
100
|
+
self._cache_path = cache_path.expanduser() if cache_path else None
|
|
101
|
+
self._cache: dict[str, str] = {}
|
|
102
|
+
if self._cache_path and self._cache_path.exists():
|
|
103
|
+
try:
|
|
104
|
+
self._cache = json.loads(self._cache_path.read_text())
|
|
105
|
+
except (json.JSONDecodeError, OSError):
|
|
106
|
+
self._cache = {}
|
|
107
|
+
|
|
108
|
+
os_name = platform.system()
|
|
109
|
+
self._interface = interface or self._detect_interface(os_name)
|
|
110
|
+
self._os_name = os_name
|
|
111
|
+
|
|
112
|
+
self._setup_builtin_providers(os_name)
|
|
113
|
+
|
|
114
|
+
# -- Public properties --------------------------------------------------
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def platform_info(self) -> str:
|
|
118
|
+
"""Human-readable OS + version string."""
|
|
119
|
+
ver = platform.mac_ver()[0]
|
|
120
|
+
if ver:
|
|
121
|
+
return f"macOS {ver} (Darwin)"
|
|
122
|
+
return f"{platform.system()} {platform.release()}"
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def interface_name(self) -> str:
|
|
126
|
+
"""The network interface being used (e.g., ``en0``, ``wlan0``)."""
|
|
127
|
+
return self._interface
|
|
128
|
+
|
|
129
|
+
# -- Registration -------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
def register_current_ssid_provider(self, provider: CurrentSSIDProvider, priority: int = 0) -> None:
|
|
132
|
+
self._current_providers.append(_RegisteredProvider(provider, priority))
|
|
133
|
+
self._resolved_current = None
|
|
134
|
+
|
|
135
|
+
def register_scan_provider(self, provider: SSIDScanProvider, priority: int = 0) -> None:
|
|
136
|
+
self._scan_providers.append(_RegisteredProvider(provider, priority))
|
|
137
|
+
self._resolved_scan = None
|
|
138
|
+
|
|
139
|
+
def register_connect_provider(self, provider: SSIDConnectProvider, priority: int = 0) -> None:
|
|
140
|
+
self._connect_providers.append(_RegisteredProvider(provider, priority))
|
|
141
|
+
self._resolved_connect = None
|
|
142
|
+
|
|
143
|
+
def register_disconnect_provider(self, provider: SSIDDisconnectProvider, priority: int = 0) -> None:
|
|
144
|
+
self._disconnect_providers.append(_RegisteredProvider(provider, priority))
|
|
145
|
+
self._resolved_disconnect = None
|
|
146
|
+
|
|
147
|
+
# -- Core operations ----------------------------------------------------
|
|
148
|
+
|
|
149
|
+
def get_current_ssid(self) -> str | None:
|
|
150
|
+
"""Return the SSID of the currently-connected network, or ``None``."""
|
|
151
|
+
provider = self._resolve("current", self._current_providers, self._resolved_current)
|
|
152
|
+
self._resolved_current = provider # type: ignore[assignment]
|
|
153
|
+
if provider is None:
|
|
154
|
+
logger.warning("No current-SSID provider registered. Call register_current_ssid_provider() first.")
|
|
155
|
+
return None
|
|
156
|
+
return provider.get_current_ssid(self._interface) # type: ignore[union-attr]
|
|
157
|
+
|
|
158
|
+
def scan(self, timeout: int = 15) -> list[SSIDInfo]:
|
|
159
|
+
"""Scan for nearby Wi-Fi networks."""
|
|
160
|
+
provider = self._resolve("scan", self._scan_providers, self._resolved_scan)
|
|
161
|
+
self._resolved_scan = provider # type: ignore[assignment]
|
|
162
|
+
if provider is None:
|
|
163
|
+
logger.warning("No scan provider registered. Call register_scan_provider() first.")
|
|
164
|
+
return []
|
|
165
|
+
return provider.scan_ssids(self._interface, timeout) # type: ignore[union-attr]
|
|
166
|
+
|
|
167
|
+
def connect(self, ssid: str, password: str, timeout: int = 15) -> None:
|
|
168
|
+
"""Connect to a Wi-Fi network.
|
|
169
|
+
|
|
170
|
+
:raises WiFiConnectionError: on failure
|
|
171
|
+
"""
|
|
172
|
+
# ── Already-connected guard ──────────────────────────────────────
|
|
173
|
+
# On macOS, calling CoreWLAN's CWInterface.associate(to:password:)
|
|
174
|
+
# when the adapter is *already* associated with the target SSID
|
|
175
|
+
# results in Apple80211 error -3925 ("tmpErr"). The error itself
|
|
176
|
+
# is harmless, but the failed associate causes macOS to briefly
|
|
177
|
+
# drop the WiFi link. When a second network interface is active
|
|
178
|
+
# (e.g. Ethernet on en7, VPN on utun1), macOS re-evaluates its
|
|
179
|
+
# routing table during the dropout and may permanently re-route
|
|
180
|
+
# the camera's subnet (10.5.5.0/24) through the other interface.
|
|
181
|
+
# After that, Python's `requests` library (which uses BSD sockets
|
|
182
|
+
# and relies on the kernel routing table) gets [Errno 65] EHOSTUNREACH,
|
|
183
|
+
# while Apple's `curl` (which uses Network.framework with scoped
|
|
184
|
+
# routing and per-interface DNS) continues to work — leading to
|
|
185
|
+
# a confusing state where `is_reachable()` passes but every HTTP
|
|
186
|
+
# call from `requests` fails.
|
|
187
|
+
#
|
|
188
|
+
# The same issue applies to `networksetup -setairportnetwork`:
|
|
189
|
+
# re-running it on an already-connected SSID forces a full
|
|
190
|
+
# reassociation with the access point (~9 seconds), during which
|
|
191
|
+
# routing can shift to another interface.
|
|
192
|
+
#
|
|
193
|
+
# Fix: skip the connect entirely if we're already on the target.
|
|
194
|
+
# ────────────────────────────────────────────────────────────────
|
|
195
|
+
current = self.get_current_ssid()
|
|
196
|
+
if current == ssid:
|
|
197
|
+
logger.info(f"Already connected to '{ssid}', skipping connect")
|
|
198
|
+
return
|
|
199
|
+
|
|
200
|
+
provider = self._resolve("connect", self._connect_providers, self._resolved_connect)
|
|
201
|
+
self._resolved_connect = provider # type: ignore[assignment]
|
|
202
|
+
if provider is None:
|
|
203
|
+
raise WiFiConnectionError("No connect provider registered")
|
|
204
|
+
provider.connect(ssid, password, self._interface, timeout) # type: ignore[union-attr]
|
|
205
|
+
|
|
206
|
+
def disconnect(self) -> None:
|
|
207
|
+
"""Disconnect from the current Wi-Fi network."""
|
|
208
|
+
provider = self._resolve("disconnect", self._disconnect_providers, self._resolved_disconnect)
|
|
209
|
+
self._resolved_disconnect = provider # type: ignore[assignment]
|
|
210
|
+
if provider is None:
|
|
211
|
+
logger.warning("No disconnect provider registered.")
|
|
212
|
+
return
|
|
213
|
+
provider.disconnect(self._interface) # type: ignore[union-attr]
|
|
214
|
+
|
|
215
|
+
# -- Convenience methods ------------------------------------------------
|
|
216
|
+
|
|
217
|
+
def scan_for_ssid(self, ssid: str, timeout_sec: float = 20.0, invert: bool = False) -> bool:
|
|
218
|
+
"""Poll ``scan()`` until *ssid* is found (or confirmed absent if *invert* is True).
|
|
219
|
+
|
|
220
|
+
:param ssid: The SSID to look for.
|
|
221
|
+
:param timeout_sec: Total time to keep trying (seconds).
|
|
222
|
+
:param invert: If True, return True only if the SSID is **not** found after the timeout.
|
|
223
|
+
:return: True if the SSID was found (or not found when *invert* is True).
|
|
224
|
+
"""
|
|
225
|
+
logger.info(f'Scanning for SSID: "{ssid}" (timeout: {timeout_sec:.2f} seconds)')
|
|
226
|
+
start = time.perf_counter()
|
|
227
|
+
while time.perf_counter() - start <= timeout_sec:
|
|
228
|
+
networks = self.scan(timeout=5)
|
|
229
|
+
found = any(n.ssid == ssid for n in networks)
|
|
230
|
+
if found and not invert:
|
|
231
|
+
return True
|
|
232
|
+
if not found and invert:
|
|
233
|
+
return True
|
|
234
|
+
time.sleep(1.0)
|
|
235
|
+
return invert
|
|
236
|
+
|
|
237
|
+
def is_connected(self) -> bool:
|
|
238
|
+
"""Return True if connected to any Wi-Fi network."""
|
|
239
|
+
return self.get_current_ssid() is not None
|
|
240
|
+
|
|
241
|
+
def is_wifi_enabled(self) -> bool:
|
|
242
|
+
"""Check whether the system's Wi-Fi adapter is powered on."""
|
|
243
|
+
if self._os_name == "Darwin":
|
|
244
|
+
try:
|
|
245
|
+
out = subprocess.check_output(
|
|
246
|
+
["networksetup", "-getairportpower", self._interface],
|
|
247
|
+
text=True,
|
|
248
|
+
timeout=5,
|
|
249
|
+
)
|
|
250
|
+
return "On" in out
|
|
251
|
+
except (OSError, subprocess.SubprocessError):
|
|
252
|
+
return True # assume on if we can't check
|
|
253
|
+
if self._os_name == "Linux":
|
|
254
|
+
try:
|
|
255
|
+
out = subprocess.check_output(["nmcli", "radio", "wifi"], text=True, timeout=5)
|
|
256
|
+
return "enabled" in out.lower()
|
|
257
|
+
except (OSError, subprocess.SubprocessError):
|
|
258
|
+
return True
|
|
259
|
+
return True # unknown platform -- assume on
|
|
260
|
+
|
|
261
|
+
# -- Internal -----------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
@staticmethod
|
|
264
|
+
def _detect_interface(os_name: str) -> str:
|
|
265
|
+
if os_name == "Darwin":
|
|
266
|
+
return "en0"
|
|
267
|
+
if os_name == "Linux":
|
|
268
|
+
try:
|
|
269
|
+
for p in sorted(Path("/sys/class/net").iterdir()):
|
|
270
|
+
if (p / "wireless").exists():
|
|
271
|
+
return p.name
|
|
272
|
+
except OSError:
|
|
273
|
+
pass
|
|
274
|
+
return "wlan0"
|
|
275
|
+
return "Wi-Fi" # Windows default
|
|
276
|
+
|
|
277
|
+
def _setup_builtin_providers(self, os_name: str) -> None:
|
|
278
|
+
if os_name == "Darwin":
|
|
279
|
+
self._setup_macos()
|
|
280
|
+
elif os_name == "Linux":
|
|
281
|
+
self._setup_linux()
|
|
282
|
+
else:
|
|
283
|
+
logger.warning(f"Unsupported platform: {os_name}. No Wi-Fi providers registered.")
|
|
284
|
+
|
|
285
|
+
def _setup_macos(self) -> None:
|
|
286
|
+
major = _macos_major()
|
|
287
|
+
|
|
288
|
+
if major <= 14:
|
|
289
|
+
self.register_current_ssid_provider(NetworkSetupCurrentSSID(), priority=0)
|
|
290
|
+
self.register_scan_provider(SystemProfilerScan(), priority=0)
|
|
291
|
+
else:
|
|
292
|
+
self.register_current_ssid_provider(IpconfigCurrentSSID(), priority=0)
|
|
293
|
+
logger.warning(
|
|
294
|
+
f"No built-in SSID scan provider for macOS {platform.mac_ver()[0]}. "
|
|
295
|
+
"SSIDs are redacted by the OS. Register a scan provider "
|
|
296
|
+
"(e.g., SwiftSsidScanner from extras/) to enable scanning."
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
self.register_connect_provider(NetworkSetupConnect(), priority=0)
|
|
300
|
+
self.register_disconnect_provider(NetworkSetupDisconnect(), priority=0)
|
|
301
|
+
|
|
302
|
+
def _setup_linux(self) -> None:
|
|
303
|
+
has_nmcli = NmcliCurrentSSID().is_available()
|
|
304
|
+
has_iwgetid = IwgetidCurrentSSID().is_available()
|
|
305
|
+
|
|
306
|
+
if has_nmcli:
|
|
307
|
+
self.register_current_ssid_provider(NmcliCurrentSSID(), priority=10)
|
|
308
|
+
self.register_scan_provider(NmcliScan(), priority=0)
|
|
309
|
+
self.register_connect_provider(NmcliConnect(), priority=0)
|
|
310
|
+
self.register_disconnect_provider(NmcliDisconnect(), priority=0)
|
|
311
|
+
if has_iwgetid:
|
|
312
|
+
self.register_current_ssid_provider(IwgetidCurrentSSID(), priority=0)
|
|
313
|
+
|
|
314
|
+
if not has_nmcli and not has_iwgetid:
|
|
315
|
+
logger.warning(
|
|
316
|
+
"No supported WiFi manager found on Linux. "
|
|
317
|
+
"Install NetworkManager (nmcli) or wireless-tools (iwgetid)."
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
def _resolve(
|
|
321
|
+
self,
|
|
322
|
+
operation: str,
|
|
323
|
+
registry: list[_RegisteredProvider],
|
|
324
|
+
cached_instance: CurrentSSIDProvider | SSIDScanProvider | SSIDConnectProvider | SSIDDisconnectProvider | None,
|
|
325
|
+
) -> CurrentSSIDProvider | SSIDScanProvider | SSIDConnectProvider | SSIDDisconnectProvider | None:
|
|
326
|
+
if cached_instance is not None:
|
|
327
|
+
return cached_instance
|
|
328
|
+
|
|
329
|
+
# Try to restore from disk cache
|
|
330
|
+
cached_name = self._cache.get(operation)
|
|
331
|
+
if cached_name:
|
|
332
|
+
for entry in registry:
|
|
333
|
+
if entry.provider.name == cached_name:
|
|
334
|
+
return entry.provider
|
|
335
|
+
|
|
336
|
+
# Discovery: try providers in descending priority order
|
|
337
|
+
for entry in sorted(registry, key=lambda e: e.priority, reverse=True):
|
|
338
|
+
if entry.provider.is_available():
|
|
339
|
+
self._cache[operation] = entry.provider.name
|
|
340
|
+
self._write_cache()
|
|
341
|
+
return entry.provider
|
|
342
|
+
|
|
343
|
+
return None
|
|
344
|
+
|
|
345
|
+
def _write_cache(self) -> None:
|
|
346
|
+
if self._cache_path is None:
|
|
347
|
+
return
|
|
348
|
+
try:
|
|
349
|
+
self._cache_path.parent.mkdir(parents=True, exist_ok=True)
|
|
350
|
+
self._cache_path.write_text(json.dumps(self._cache, indent=2) + "\n")
|
|
351
|
+
except OSError:
|
|
352
|
+
pass
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _macos_major() -> int:
|
|
356
|
+
ver = platform.mac_ver()[0]
|
|
357
|
+
return int(ver.split(".")[0]) if ver else 0
|
wifi_controller/_abc.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Abstract base classes for Wi-Fi provider plugins."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
|
|
7
|
+
from wifi_controller._types import SSIDInfo
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CurrentSSIDProvider(ABC):
|
|
11
|
+
"""Can retrieve the SSID of the currently-connected network."""
|
|
12
|
+
|
|
13
|
+
@property
|
|
14
|
+
@abstractmethod
|
|
15
|
+
def name(self) -> str: ...
|
|
16
|
+
|
|
17
|
+
@abstractmethod
|
|
18
|
+
def is_available(self) -> bool: ...
|
|
19
|
+
|
|
20
|
+
@abstractmethod
|
|
21
|
+
def get_current_ssid(self, interface: str) -> str | None: ...
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SSIDScanProvider(ABC):
|
|
25
|
+
"""Can scan for nearby Wi-Fi networks and return real (non-redacted) SSIDs."""
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
@abstractmethod
|
|
29
|
+
def name(self) -> str: ...
|
|
30
|
+
|
|
31
|
+
@abstractmethod
|
|
32
|
+
def is_available(self) -> bool: ...
|
|
33
|
+
|
|
34
|
+
@abstractmethod
|
|
35
|
+
def scan_ssids(self, interface: str, timeout: int = 15) -> list[SSIDInfo]: ...
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class SSIDConnectProvider(ABC):
|
|
39
|
+
"""Can connect to a Wi-Fi network given SSID and password.
|
|
40
|
+
|
|
41
|
+
:raises WiFiConnectionError: on failure
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
@abstractmethod
|
|
46
|
+
def name(self) -> str: ...
|
|
47
|
+
|
|
48
|
+
@abstractmethod
|
|
49
|
+
def is_available(self) -> bool: ...
|
|
50
|
+
|
|
51
|
+
@abstractmethod
|
|
52
|
+
def connect(self, ssid: str, password: str, interface: str, timeout: int = 15) -> None: ...
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class SSIDDisconnectProvider(ABC):
|
|
56
|
+
"""Can disconnect from the current Wi-Fi network."""
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
@abstractmethod
|
|
60
|
+
def name(self) -> str: ...
|
|
61
|
+
|
|
62
|
+
@abstractmethod
|
|
63
|
+
def is_available(self) -> bool: ...
|
|
64
|
+
|
|
65
|
+
@abstractmethod
|
|
66
|
+
def disconnect(self, interface: str) -> None: ...
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Built-in Linux Wi-Fi providers (nmcli, iwgetid)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
|
|
9
|
+
from wifi_controller._abc import (
|
|
10
|
+
CurrentSSIDProvider,
|
|
11
|
+
SSIDConnectProvider,
|
|
12
|
+
SSIDDisconnectProvider,
|
|
13
|
+
SSIDScanProvider,
|
|
14
|
+
)
|
|
15
|
+
from wifi_controller._types import SSIDInfo, WiFiConnectionError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _has_nmcli() -> bool:
|
|
19
|
+
return shutil.which("nmcli") is not None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _has_iwgetid() -> bool:
|
|
23
|
+
return shutil.which("iwgetid") is not None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class NmcliCurrentSSID(CurrentSSIDProvider):
|
|
27
|
+
"""``nmcli`` -- active SSID on Linux with NetworkManager."""
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def name(self) -> str:
|
|
31
|
+
return "nmcli"
|
|
32
|
+
|
|
33
|
+
def is_available(self) -> bool:
|
|
34
|
+
return _has_nmcli()
|
|
35
|
+
|
|
36
|
+
def get_current_ssid(self, interface: str) -> str | None:
|
|
37
|
+
try:
|
|
38
|
+
output = subprocess.check_output(
|
|
39
|
+
["nmcli", "-t", "-f", "active,ssid", "dev", "wifi"],
|
|
40
|
+
text=True,
|
|
41
|
+
)
|
|
42
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
43
|
+
return None
|
|
44
|
+
for line in output.splitlines():
|
|
45
|
+
if line.startswith("yes:"):
|
|
46
|
+
ssid = line.split(":", 1)[1]
|
|
47
|
+
return ssid if ssid else None
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class NmcliScan(SSIDScanProvider):
|
|
52
|
+
"""``nmcli dev wifi list`` -- scan nearby networks on Linux."""
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def name(self) -> str:
|
|
56
|
+
return "nmcli"
|
|
57
|
+
|
|
58
|
+
def is_available(self) -> bool:
|
|
59
|
+
return _has_nmcli()
|
|
60
|
+
|
|
61
|
+
def scan_ssids(self, interface: str, timeout: int = 15) -> list[SSIDInfo]:
|
|
62
|
+
try:
|
|
63
|
+
# Trigger a fresh scan
|
|
64
|
+
subprocess.run(
|
|
65
|
+
["nmcli", "dev", "wifi", "rescan", "ifname", interface],
|
|
66
|
+
capture_output=True,
|
|
67
|
+
timeout=timeout,
|
|
68
|
+
)
|
|
69
|
+
output = subprocess.check_output(
|
|
70
|
+
["nmcli", "-t", "-f", "ssid,bssid,signal,freq", "dev", "wifi", "list", "ifname", interface],
|
|
71
|
+
text=True,
|
|
72
|
+
timeout=timeout,
|
|
73
|
+
)
|
|
74
|
+
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError):
|
|
75
|
+
return []
|
|
76
|
+
|
|
77
|
+
results: list[SSIDInfo] = []
|
|
78
|
+
seen: set[str] = set()
|
|
79
|
+
for line in output.splitlines():
|
|
80
|
+
parts = line.split(":")
|
|
81
|
+
if len(parts) < 4:
|
|
82
|
+
continue
|
|
83
|
+
ssid = parts[0]
|
|
84
|
+
if not ssid or ssid in seen:
|
|
85
|
+
continue
|
|
86
|
+
seen.add(ssid)
|
|
87
|
+
bssid = ":".join(parts[1:7]) if len(parts) >= 7 else parts[1]
|
|
88
|
+
try:
|
|
89
|
+
signal = int(parts[-2]) if len(parts) >= 4 else 0
|
|
90
|
+
freq = int(parts[-1]) if len(parts) >= 4 else 0
|
|
91
|
+
except ValueError:
|
|
92
|
+
signal, freq = 0, 0
|
|
93
|
+
# nmcli reports signal as 0-100%; approximate dBm
|
|
94
|
+
rssi = signal - 100 if signal else 0
|
|
95
|
+
channel = _freq_to_channel(freq)
|
|
96
|
+
results.append(SSIDInfo(ssid=ssid, bssid=bssid, rssi=rssi, channel=channel))
|
|
97
|
+
return results
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class NmcliConnect(SSIDConnectProvider):
|
|
101
|
+
"""``nmcli dev wifi connect`` -- connect on Linux with NetworkManager."""
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def name(self) -> str:
|
|
105
|
+
return "nmcli"
|
|
106
|
+
|
|
107
|
+
def is_available(self) -> bool:
|
|
108
|
+
return _has_nmcli()
|
|
109
|
+
|
|
110
|
+
def connect(self, ssid: str, password: str, interface: str, timeout: int = 15) -> None:
|
|
111
|
+
try:
|
|
112
|
+
result = subprocess.run(
|
|
113
|
+
["nmcli", "dev", "wifi", "connect", ssid, "password", password, "ifname", interface],
|
|
114
|
+
capture_output=True,
|
|
115
|
+
text=True,
|
|
116
|
+
timeout=timeout + 15,
|
|
117
|
+
)
|
|
118
|
+
except (subprocess.TimeoutExpired, FileNotFoundError) as exc:
|
|
119
|
+
raise WiFiConnectionError(f"nmcli failed for '{ssid}': {exc}") from exc
|
|
120
|
+
if result.returncode != 0:
|
|
121
|
+
stderr = result.stderr.strip() or result.stdout.strip()
|
|
122
|
+
raise WiFiConnectionError(f"nmcli failed for '{ssid}': {stderr}")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class NmcliDisconnect(SSIDDisconnectProvider):
|
|
126
|
+
"""``nmcli dev disconnect`` -- disconnect on Linux."""
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def name(self) -> str:
|
|
130
|
+
return "nmcli"
|
|
131
|
+
|
|
132
|
+
def is_available(self) -> bool:
|
|
133
|
+
return _has_nmcli()
|
|
134
|
+
|
|
135
|
+
def disconnect(self, interface: str) -> None:
|
|
136
|
+
with contextlib.suppress(subprocess.TimeoutExpired, FileNotFoundError):
|
|
137
|
+
subprocess.run(
|
|
138
|
+
["nmcli", "dev", "disconnect", interface],
|
|
139
|
+
capture_output=True,
|
|
140
|
+
timeout=10,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class IwgetidCurrentSSID(CurrentSSIDProvider):
|
|
145
|
+
"""``iwgetid -r`` -- get current SSID on Linux without NetworkManager."""
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def name(self) -> str:
|
|
149
|
+
return "iwgetid"
|
|
150
|
+
|
|
151
|
+
def is_available(self) -> bool:
|
|
152
|
+
return _has_iwgetid()
|
|
153
|
+
|
|
154
|
+
def get_current_ssid(self, interface: str) -> str | None:
|
|
155
|
+
try:
|
|
156
|
+
output = subprocess.check_output(["iwgetid", "-r", interface], text=True).strip()
|
|
157
|
+
return output if output else None
|
|
158
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _freq_to_channel(freq_mhz: int) -> int:
|
|
163
|
+
"""Convert Wi-Fi frequency in MHz to channel number."""
|
|
164
|
+
if 2412 <= freq_mhz <= 2484:
|
|
165
|
+
if freq_mhz == 2484:
|
|
166
|
+
return 14
|
|
167
|
+
return (freq_mhz - 2412) // 5 + 1
|
|
168
|
+
if 5170 <= freq_mhz <= 5825:
|
|
169
|
+
return (freq_mhz - 5170) // 5 + 34
|
|
170
|
+
return 0
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Built-in macOS Wi-Fi providers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import platform
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
|
|
9
|
+
from bear_tools.lumberjack import Logger
|
|
10
|
+
|
|
11
|
+
from wifi_controller._abc import (
|
|
12
|
+
CurrentSSIDProvider,
|
|
13
|
+
SSIDConnectProvider,
|
|
14
|
+
SSIDDisconnectProvider,
|
|
15
|
+
SSIDScanProvider,
|
|
16
|
+
)
|
|
17
|
+
from wifi_controller._types import SSIDInfo, WiFiConnectionError
|
|
18
|
+
|
|
19
|
+
logger = Logger()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _macos_major_version() -> int:
|
|
23
|
+
ver = platform.mac_ver()[0]
|
|
24
|
+
return int(ver.split(".")[0]) if ver else 0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class NetworkSetupCurrentSSID(CurrentSSIDProvider):
|
|
28
|
+
"""``networksetup -getairportnetwork`` -- works on macOS <= 14."""
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def name(self) -> str:
|
|
32
|
+
return "networksetup"
|
|
33
|
+
|
|
34
|
+
def is_available(self) -> bool:
|
|
35
|
+
return _macos_major_version() <= 14
|
|
36
|
+
|
|
37
|
+
def get_current_ssid(self, interface: str) -> str | None:
|
|
38
|
+
try:
|
|
39
|
+
output = subprocess.check_output(
|
|
40
|
+
["networksetup", "-getairportnetwork", interface],
|
|
41
|
+
).decode()
|
|
42
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
43
|
+
return None
|
|
44
|
+
prefix = "Current Wi-Fi Network: "
|
|
45
|
+
if prefix in output:
|
|
46
|
+
return output.replace(prefix, "").strip()
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class IpconfigCurrentSSID(CurrentSSIDProvider):
|
|
51
|
+
"""``ipconfig getsummary`` -- works on macOS 15+ (requires ``sudo ipconfig setverbose 1`` once)."""
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def name(self) -> str:
|
|
55
|
+
return "ipconfig"
|
|
56
|
+
|
|
57
|
+
def is_available(self) -> bool:
|
|
58
|
+
return _macos_major_version() >= 15
|
|
59
|
+
|
|
60
|
+
def get_current_ssid(self, interface: str) -> str | None:
|
|
61
|
+
try:
|
|
62
|
+
output = subprocess.check_output(["ipconfig", "getsummary", interface]).decode()
|
|
63
|
+
regex = r"\n\s+SSID : ([\x20-\x7E]{1,32})(?=\n)"
|
|
64
|
+
match = re.search(regex, output)
|
|
65
|
+
return match.group(1) if match else None
|
|
66
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class SystemProfilerScan(SSIDScanProvider):
|
|
71
|
+
"""``system_profiler SPAirPortDataType`` -- works on macOS <= 14 (SSIDs redacted on 15+)."""
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def name(self) -> str:
|
|
75
|
+
return "system_profiler"
|
|
76
|
+
|
|
77
|
+
def is_available(self) -> bool:
|
|
78
|
+
return _macos_major_version() <= 14
|
|
79
|
+
|
|
80
|
+
def scan_ssids(self, interface: str, timeout: int = 15) -> list[SSIDInfo]:
|
|
81
|
+
try:
|
|
82
|
+
output = subprocess.check_output(["/usr/sbin/system_profiler", "SPAirPortDataType"]).decode()
|
|
83
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
84
|
+
return []
|
|
85
|
+
regex = re.compile(r"\n\s+([\x20-\x7E]{1,32}):\n\s+PHY Mode:")
|
|
86
|
+
return [
|
|
87
|
+
SSIDInfo(ssid=ssid, bssid="", rssi=0, channel=0)
|
|
88
|
+
for ssid in sorted(set(regex.findall(output)))
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class NetworkSetupConnect(SSIDConnectProvider):
|
|
93
|
+
"""``networksetup -setairportnetwork`` -- works on all macOS versions with explicit password."""
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def name(self) -> str:
|
|
97
|
+
return "networksetup"
|
|
98
|
+
|
|
99
|
+
def is_available(self) -> bool:
|
|
100
|
+
return platform.system() == "Darwin"
|
|
101
|
+
|
|
102
|
+
def connect(self, ssid: str, password: str, interface: str, timeout: int = 15) -> None:
|
|
103
|
+
# ── Already-connected guard ──────────────────────────────────
|
|
104
|
+
# `networksetup -setairportnetwork` forces a full 802.11
|
|
105
|
+
# reassociation even when already on the target SSID (~9 sec).
|
|
106
|
+
# During reassociation, macOS may re-route the camera's
|
|
107
|
+
# 10.5.5.0/24 subnet through Ethernet/VPN if a second
|
|
108
|
+
# interface is active, breaking Python BSD-socket traffic.
|
|
109
|
+
# See WiFiController.connect() for the full explanation.
|
|
110
|
+
# ─────────────────────────────────────────────────────────────
|
|
111
|
+
current = self._get_current_ssid(interface)
|
|
112
|
+
if current == ssid:
|
|
113
|
+
logger.info(f"Already connected to '{ssid}', skipping networksetup")
|
|
114
|
+
return
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
result = subprocess.run(
|
|
118
|
+
["networksetup", "-setairportnetwork", interface, ssid, password],
|
|
119
|
+
capture_output=True,
|
|
120
|
+
text=True,
|
|
121
|
+
timeout=timeout + 15,
|
|
122
|
+
)
|
|
123
|
+
except (subprocess.TimeoutExpired, FileNotFoundError) as exc:
|
|
124
|
+
raise WiFiConnectionError(f"networksetup failed for '{ssid}': {exc}") from exc
|
|
125
|
+
if result.stdout.strip():
|
|
126
|
+
raise WiFiConnectionError(f"networksetup failed for '{ssid}': {result.stdout.strip()}")
|
|
127
|
+
|
|
128
|
+
@staticmethod
|
|
129
|
+
def _get_current_ssid(interface: str) -> str | None:
|
|
130
|
+
"""Quick SSID check via ipconfig (works on macOS 15+)."""
|
|
131
|
+
try:
|
|
132
|
+
output = subprocess.check_output(
|
|
133
|
+
["ipconfig", "getsummary", interface], text=True, timeout=5,
|
|
134
|
+
)
|
|
135
|
+
match = re.search(r"\n\s+SSID : ([\x20-\x7E]{1,32})(?=\n)", output)
|
|
136
|
+
return match.group(1) if match else None
|
|
137
|
+
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError):
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class NetworkSetupDisconnect(SSIDDisconnectProvider):
|
|
142
|
+
"""``networksetup -setairportpower`` off/on to disconnect."""
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def name(self) -> str:
|
|
146
|
+
return "networksetup"
|
|
147
|
+
|
|
148
|
+
def is_available(self) -> bool:
|
|
149
|
+
return platform.system() == "Darwin"
|
|
150
|
+
|
|
151
|
+
def disconnect(self, interface: str) -> None:
|
|
152
|
+
try:
|
|
153
|
+
subprocess.run(
|
|
154
|
+
["networksetup", "-setairportpower", interface, "off"],
|
|
155
|
+
capture_output=True,
|
|
156
|
+
timeout=10,
|
|
157
|
+
)
|
|
158
|
+
subprocess.run(
|
|
159
|
+
["networksetup", "-setairportpower", interface, "on"],
|
|
160
|
+
capture_output=True,
|
|
161
|
+
timeout=10,
|
|
162
|
+
)
|
|
163
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
164
|
+
pass
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Wi-Fi providers backed by the Swift ``ssid_scanner`` binary.
|
|
2
|
+
|
|
3
|
+
These providers shell out to the signed macOS app bundle that uses
|
|
4
|
+
CoreWLAN + CoreLocation to bypass macOS SSID redaction (macOS 15+).
|
|
5
|
+
|
|
6
|
+
The Swift source lives in ``extras/ssid_scanner/`` and must be built
|
|
7
|
+
separately (``make -C extras/ssid_scanner``). The Python wrappers here
|
|
8
|
+
ship on PyPI and gracefully report ``is_available() -> False`` when the
|
|
9
|
+
binary is not present.
|
|
10
|
+
|
|
11
|
+
Usage::
|
|
12
|
+
|
|
13
|
+
from wifi_controller import WiFiController
|
|
14
|
+
from wifi_controller._swift import (
|
|
15
|
+
SwiftSsidScannerCurrentSSID,
|
|
16
|
+
SwiftSsidScannerScan,
|
|
17
|
+
SwiftSsidScannerConnect,
|
|
18
|
+
SwiftSsidScannerDisconnect,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
ctrl = WiFiController()
|
|
22
|
+
binary = "/path/to/ssid_scanner"
|
|
23
|
+
ctrl.register_current_ssid_provider(SwiftSsidScannerCurrentSSID(binary), priority=10)
|
|
24
|
+
ctrl.register_scan_provider(SwiftSsidScannerScan(binary), priority=10)
|
|
25
|
+
ctrl.register_connect_provider(SwiftSsidScannerConnect(binary), priority=10)
|
|
26
|
+
ctrl.register_disconnect_provider(SwiftSsidScannerDisconnect(binary), priority=10)
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import json
|
|
32
|
+
import subprocess
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
from bear_tools.lumberjack import Logger
|
|
36
|
+
|
|
37
|
+
from wifi_controller._abc import (
|
|
38
|
+
CurrentSSIDProvider,
|
|
39
|
+
SSIDConnectProvider,
|
|
40
|
+
SSIDDisconnectProvider,
|
|
41
|
+
SSIDScanProvider,
|
|
42
|
+
)
|
|
43
|
+
from wifi_controller._types import SSIDInfo, WiFiConnectionError
|
|
44
|
+
|
|
45
|
+
logger = Logger()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class SwiftSsidScannerCurrentSSID(CurrentSSIDProvider):
|
|
49
|
+
"""Retrieve the current SSID via ``ssid_scanner --current``."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, binary: str | Path) -> None:
|
|
52
|
+
self._binary = str(binary)
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def name(self) -> str:
|
|
56
|
+
return "swift_ssid_scanner"
|
|
57
|
+
|
|
58
|
+
def is_available(self) -> bool:
|
|
59
|
+
return Path(self._binary).is_file()
|
|
60
|
+
|
|
61
|
+
def get_current_ssid(self, interface: str = "en0") -> str | None:
|
|
62
|
+
try:
|
|
63
|
+
result = subprocess.run(
|
|
64
|
+
[self._binary, "--current"],
|
|
65
|
+
capture_output=True,
|
|
66
|
+
text=True,
|
|
67
|
+
timeout=10,
|
|
68
|
+
)
|
|
69
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
70
|
+
return None
|
|
71
|
+
if result.returncode != 0:
|
|
72
|
+
return None
|
|
73
|
+
ssid = result.stdout.strip()
|
|
74
|
+
return ssid if ssid else None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class SwiftSsidScannerScan(SSIDScanProvider):
|
|
78
|
+
"""Scan nearby networks via ``ssid_scanner --scan --json``."""
|
|
79
|
+
|
|
80
|
+
def __init__(self, binary: str | Path) -> None:
|
|
81
|
+
self._binary = str(binary)
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def name(self) -> str:
|
|
85
|
+
return "swift_ssid_scanner"
|
|
86
|
+
|
|
87
|
+
def is_available(self) -> bool:
|
|
88
|
+
return Path(self._binary).is_file()
|
|
89
|
+
|
|
90
|
+
def scan_ssids(self, interface: str = "en0", timeout: int = 15) -> list[SSIDInfo]:
|
|
91
|
+
try:
|
|
92
|
+
result = subprocess.run(
|
|
93
|
+
[self._binary, "--scan", "--json", "--timeout", str(timeout)],
|
|
94
|
+
capture_output=True,
|
|
95
|
+
text=True,
|
|
96
|
+
timeout=timeout + 30,
|
|
97
|
+
)
|
|
98
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
99
|
+
return []
|
|
100
|
+
if result.returncode != 0:
|
|
101
|
+
return []
|
|
102
|
+
try:
|
|
103
|
+
raw: list[dict[str, str | int]] = json.loads(result.stdout)
|
|
104
|
+
except (json.JSONDecodeError, ValueError):
|
|
105
|
+
return []
|
|
106
|
+
return [
|
|
107
|
+
SSIDInfo(
|
|
108
|
+
ssid=str(entry.get("ssid", "")),
|
|
109
|
+
bssid=str(entry.get("bssid", "")),
|
|
110
|
+
rssi=int(entry.get("rssi", 0)),
|
|
111
|
+
channel=int(entry.get("channel", 0)),
|
|
112
|
+
)
|
|
113
|
+
for entry in raw
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class SwiftSsidScannerConnect(SSIDConnectProvider):
|
|
118
|
+
"""Connect to a network via ``ssid_scanner --connect``."""
|
|
119
|
+
|
|
120
|
+
def __init__(self, binary: str | Path) -> None:
|
|
121
|
+
self._binary = str(binary)
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def name(self) -> str:
|
|
125
|
+
return "swift_ssid_scanner"
|
|
126
|
+
|
|
127
|
+
def is_available(self) -> bool:
|
|
128
|
+
return Path(self._binary).is_file()
|
|
129
|
+
|
|
130
|
+
def connect(self, ssid: str, password: str, interface: str = "en0", timeout: int = 15) -> None:
|
|
131
|
+
# ── Already-connected guard ──────────────────────────────────
|
|
132
|
+
# CoreWLAN's CWInterface.associate(to:password:) fails with
|
|
133
|
+
# Apple80211 error -3925 when already on the target SSID.
|
|
134
|
+
# The failed associate drops the WiFi link momentarily, which
|
|
135
|
+
# causes macOS to re-route the camera's 10.5.5.0/24 subnet
|
|
136
|
+
# through another interface (Ethernet, VPN) if one exists.
|
|
137
|
+
# Python `requests` then gets EHOSTUNREACH while `curl` still
|
|
138
|
+
# works (Network.framework vs BSD sockets). See the detailed
|
|
139
|
+
# comment in WiFiController.connect() for the full explanation.
|
|
140
|
+
# ─────────────────────────────────────────────────────────────
|
|
141
|
+
current = self._get_current_ssid()
|
|
142
|
+
if current == ssid:
|
|
143
|
+
logger.info(f"Already connected to '{ssid}', skipping ssid_scanner --connect")
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
result = subprocess.run(
|
|
148
|
+
[self._binary, "--connect", ssid, password, "--timeout", str(timeout)],
|
|
149
|
+
capture_output=True,
|
|
150
|
+
text=True,
|
|
151
|
+
timeout=timeout + 30,
|
|
152
|
+
)
|
|
153
|
+
except subprocess.TimeoutExpired as exc:
|
|
154
|
+
raise WiFiConnectionError(f"ssid_scanner timed out connecting to '{ssid}'") from exc
|
|
155
|
+
except (FileNotFoundError, OSError) as exc:
|
|
156
|
+
raise WiFiConnectionError(f"ssid_scanner binary not found: {self._binary}") from exc
|
|
157
|
+
if result.returncode != 0:
|
|
158
|
+
stderr = result.stderr.strip()
|
|
159
|
+
raise WiFiConnectionError(f"ssid_scanner failed to connect to '{ssid}': {stderr}")
|
|
160
|
+
|
|
161
|
+
def _get_current_ssid(self) -> str | None:
|
|
162
|
+
"""Quick check via ``ssid_scanner --current``."""
|
|
163
|
+
try:
|
|
164
|
+
result = subprocess.run(
|
|
165
|
+
[self._binary, "--current"],
|
|
166
|
+
capture_output=True, text=True, timeout=10,
|
|
167
|
+
)
|
|
168
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
169
|
+
return None
|
|
170
|
+
return result.stdout.strip() or None if result.returncode == 0 else None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class SwiftSsidScannerDisconnect(SSIDDisconnectProvider):
|
|
174
|
+
"""Disconnect from the current network via ``ssid_scanner --disconnect``."""
|
|
175
|
+
|
|
176
|
+
def __init__(self, binary: str | Path) -> None:
|
|
177
|
+
self._binary = str(binary)
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def name(self) -> str:
|
|
181
|
+
return "swift_ssid_scanner"
|
|
182
|
+
|
|
183
|
+
def is_available(self) -> bool:
|
|
184
|
+
return Path(self._binary).is_file()
|
|
185
|
+
|
|
186
|
+
def disconnect(self, interface: str = "en0") -> None:
|
|
187
|
+
try:
|
|
188
|
+
result = subprocess.run(
|
|
189
|
+
[self._binary, "--disconnect"],
|
|
190
|
+
capture_output=True,
|
|
191
|
+
text=True,
|
|
192
|
+
timeout=10,
|
|
193
|
+
)
|
|
194
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
195
|
+
return # best-effort
|
|
196
|
+
if result.returncode != 0:
|
|
197
|
+
logger.warning(f"ssid_scanner --disconnect failed: {result.stderr.strip()}")
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Data types for the wifi_controller package."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class SSIDInfo:
|
|
10
|
+
"""Information about a discovered Wi-Fi network."""
|
|
11
|
+
|
|
12
|
+
ssid: str
|
|
13
|
+
bssid: str
|
|
14
|
+
rssi: int
|
|
15
|
+
channel: int
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class WiFiConnectionError(Exception):
|
|
19
|
+
"""Raised when a Wi-Fi connect operation fails."""
|
wifi_controller/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 jugglingbear
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: wifi-controller
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Cross-platform Wi-Fi controller with pluggable providers for macOS, Linux, and Windows.
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: wifi,wireless,macos,linux,network
|
|
7
|
+
Author: jugglingbear
|
|
8
|
+
Requires-Python: >=3.10,<4.0
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: MacOS
|
|
13
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: System :: Networking
|
|
20
|
+
Requires-Dist: bear-tools (>=0.1.37,<0.2.0)
|
|
21
|
+
Project-URL: Homepage, https://github.com/jugglingbear/wifi_controller
|
|
22
|
+
Project-URL: Repository, https://github.com/jugglingbear/wifi_controller
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# wifi-controller
|
|
26
|
+
|
|
27
|
+
A cross-platform Wi-Fi controller for Python with pluggable provider architecture.
|
|
28
|
+
Supports macOS and Linux out of the box, with an optional Swift-based scanner for
|
|
29
|
+
macOS 15+ where Apple redacts SSIDs without Location Services authorization.
|
|
30
|
+
|
|
31
|
+
## Features
|
|
32
|
+
|
|
33
|
+
- **Cross-platform** -- built-in providers for macOS (`networksetup`, `ipconfig`,
|
|
34
|
+
`system_profiler`) and Linux (`nmcli`, `iwgetid`)
|
|
35
|
+
- **Pluggable providers** -- register your own scan/connect/disconnect implementations
|
|
36
|
+
with priority-based resolution
|
|
37
|
+
- **macOS SSID redaction workaround** -- optional Swift scanner (`extras/ssid_scanner/`)
|
|
38
|
+
uses CoreWLAN + CoreLocation to return real SSIDs on macOS 15+
|
|
39
|
+
- **Zero dependencies** -- pure Python, stdlib only
|
|
40
|
+
|
|
41
|
+
## Installation
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install wifi-controller
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Or with Poetry:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
poetry add wifi-controller
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Quick Start
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from wifi_controller import WiFiController
|
|
57
|
+
|
|
58
|
+
wifi = WiFiController()
|
|
59
|
+
|
|
60
|
+
# Get current network
|
|
61
|
+
ssid = wifi.get_current_ssid()
|
|
62
|
+
print(f"Connected to: {ssid}")
|
|
63
|
+
|
|
64
|
+
# Scan nearby networks
|
|
65
|
+
networks = wifi.scan()
|
|
66
|
+
for net in networks:
|
|
67
|
+
print(f" {net.ssid} (RSSI={net.rssi}, CH={net.channel})")
|
|
68
|
+
|
|
69
|
+
# Connect to a network
|
|
70
|
+
wifi.connect("MyNetwork", "hunter2")
|
|
71
|
+
|
|
72
|
+
# Poll for a specific SSID
|
|
73
|
+
found = wifi.scan_for_ssid("MyNetwork", timeout_sec=30)
|
|
74
|
+
|
|
75
|
+
# Disconnect
|
|
76
|
+
wifi.disconnect()
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## macOS 15+ SSID Redaction
|
|
80
|
+
|
|
81
|
+
Starting with macOS 15 (Sequoia), Apple redacts SSID information from
|
|
82
|
+
`system_profiler`, `CoreWLAN`, and other system APIs unless the calling process
|
|
83
|
+
has Location Services authorization via a signed app bundle.
|
|
84
|
+
|
|
85
|
+
The built-in Python providers **cannot** work around this limitation. To get
|
|
86
|
+
real SSIDs on macOS 15+, build the Swift scanner from `extras/ssid_scanner/`:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
# Prerequisites: Xcode Command Line Tools + Apple Development certificate
|
|
90
|
+
make -C extras/ssid_scanner check # verify prerequisites
|
|
91
|
+
make -C extras/ssid_scanner all # build and sign
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Then register the Swift providers:
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
from wifi_controller import WiFiController
|
|
98
|
+
from wifi_controller._swift import (
|
|
99
|
+
SwiftSsidScannerCurrentSSID,
|
|
100
|
+
SwiftSsidScannerScan,
|
|
101
|
+
SwiftSsidScannerConnect,
|
|
102
|
+
SwiftSsidScannerDisconnect,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
wifi = WiFiController()
|
|
106
|
+
binary = "extras/ssid_scanner/ssid_scanner" # path to built binary
|
|
107
|
+
|
|
108
|
+
wifi.register_scan_provider(SwiftSsidScannerScan(binary), priority=10)
|
|
109
|
+
wifi.register_current_ssid_provider(SwiftSsidScannerCurrentSSID(binary), priority=10)
|
|
110
|
+
wifi.register_connect_provider(SwiftSsidScannerConnect(binary), priority=10)
|
|
111
|
+
wifi.register_disconnect_provider(SwiftSsidScannerDisconnect(binary), priority=10)
|
|
112
|
+
|
|
113
|
+
# Now scan() returns real SSIDs on macOS 15+
|
|
114
|
+
networks = wifi.scan()
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Custom Providers
|
|
118
|
+
|
|
119
|
+
Implement any of the four provider ABCs to add support for additional tools:
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
from wifi_controller import WiFiController, SSIDScanProvider, SSIDInfo
|
|
123
|
+
|
|
124
|
+
class MyCustomScanner(SSIDScanProvider):
|
|
125
|
+
@property
|
|
126
|
+
def name(self) -> str:
|
|
127
|
+
return "my_scanner"
|
|
128
|
+
|
|
129
|
+
def is_available(self) -> bool:
|
|
130
|
+
return True # check if your tool is installed
|
|
131
|
+
|
|
132
|
+
def scan_ssids(self, interface: str, timeout: int = 15) -> list[SSIDInfo]:
|
|
133
|
+
# ... your implementation ...
|
|
134
|
+
return [SSIDInfo(ssid="Example", bssid="00:11:22:33:44:55", rssi=-42, channel=6)]
|
|
135
|
+
|
|
136
|
+
wifi = WiFiController()
|
|
137
|
+
wifi.register_scan_provider(MyCustomScanner(), priority=20)
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Provider ABCs:
|
|
141
|
+
|
|
142
|
+
| ABC | Operation |
|
|
143
|
+
|-----|-----------|
|
|
144
|
+
| `CurrentSSIDProvider` | Get the currently-connected SSID |
|
|
145
|
+
| `SSIDScanProvider` | Scan for nearby networks |
|
|
146
|
+
| `SSIDConnectProvider` | Connect to a network (SSID + password) |
|
|
147
|
+
| `SSIDDisconnectProvider` | Disconnect from the current network |
|
|
148
|
+
|
|
149
|
+
Higher priority providers are tried first. The first provider whose
|
|
150
|
+
`is_available()` returns `True` is used and cached for subsequent calls.
|
|
151
|
+
|
|
152
|
+
## Project Layout
|
|
153
|
+
|
|
154
|
+
```
|
|
155
|
+
wifi_controller/
|
|
156
|
+
├── src/wifi_controller/ # Python package (ships on PyPI)
|
|
157
|
+
│ ├── __init__.py # WiFiController orchestrator
|
|
158
|
+
│ ├── _types.py # SSIDInfo, WiFiConnectionError
|
|
159
|
+
│ ├── _abc.py # Four provider ABCs
|
|
160
|
+
│ ├── _macos.py # Built-in macOS providers
|
|
161
|
+
│ ├── _linux.py # Built-in Linux providers
|
|
162
|
+
│ └── _swift.py # Python wrappers for Swift binary
|
|
163
|
+
├── extras/ssid_scanner/ # Swift source (not on PyPI, clone to use)
|
|
164
|
+
│ ├── scan.swift # CoreWLAN + CoreLocation scanner
|
|
165
|
+
│ ├── Makefile # Build, sign, test
|
|
166
|
+
│ └── *.plist # App bundle configuration
|
|
167
|
+
├── docs/ # Architecture diagrams (PlantUML)
|
|
168
|
+
└── tests/ # Unit tests
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
## Architecture
|
|
172
|
+
|
|
173
|
+
See [docs/](docs/) for PlantUML diagrams covering:
|
|
174
|
+
|
|
175
|
+
- **Class diagram** -- provider ABCs, WiFiController, built-in implementations
|
|
176
|
+
- **Sequence diagram** -- provider resolution and operation flow
|
|
177
|
+
- **Component diagram** -- package structure and platform boundaries
|
|
178
|
+
|
|
179
|
+
## Development
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
# Install dev dependencies
|
|
183
|
+
poetry install
|
|
184
|
+
|
|
185
|
+
# Run tests
|
|
186
|
+
poetry run pytest
|
|
187
|
+
|
|
188
|
+
# Lint
|
|
189
|
+
poetry run ruff check src/ tests/
|
|
190
|
+
|
|
191
|
+
# Format
|
|
192
|
+
poetry run ruff format src/ tests/
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
## License
|
|
196
|
+
|
|
197
|
+
MIT -- see [LICENSE](LICENSE).
|
|
198
|
+
|
|
199
|
+
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
wifi_controller/__init__.py,sha256=-RqcD_bk2jF2uvSn5lO79V-ujCxJbY86uaB75JPgq7M,14605
|
|
2
|
+
wifi_controller/_abc.py,sha256=CNLxWdVGTISu_te0N1IOKQUvvb83SbZSPBPbeLgSALA,1525
|
|
3
|
+
wifi_controller/_linux.py,sha256=4zr--scPVbS5GehOGq8eg6z1n1qabEPIcmpHr_WDwXo,5395
|
|
4
|
+
wifi_controller/_macos.py,sha256=JQOYvM98Qzwx0shBRjAXP3w5m6km1PyCs_PHfHf9orc,5929
|
|
5
|
+
wifi_controller/_swift.py,sha256=HW3MFIvFh1D8IguffYnHPoqev-bnBrNKswyPboPNBWY,7198
|
|
6
|
+
wifi_controller/_types.py,sha256=P2b0dhKtNIQoObY1mPzDqMKvDp1Md3kIKEUj3bHeFaQ,375
|
|
7
|
+
wifi_controller/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
wifi_controller-0.1.0.dist-info/LICENSE,sha256=aMnS8mxT3JGjIuC2HpDrjqM4RTPPx5PKdp4DML5r9cM,1069
|
|
9
|
+
wifi_controller-0.1.0.dist-info/METADATA,sha256=jO9ow3U4iCJlDObLA4W3Wxb6BvBavGwsUS9_hlqF-2A,6245
|
|
10
|
+
wifi_controller-0.1.0.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
|
|
11
|
+
wifi_controller-0.1.0.dist-info/RECORD,,
|