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.
@@ -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
@@ -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."""
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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.1.3
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any