audex 1.0.7a3__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.
Files changed (192) hide show
  1. audex/__init__.py +9 -0
  2. audex/__main__.py +7 -0
  3. audex/cli/__init__.py +189 -0
  4. audex/cli/apis/__init__.py +12 -0
  5. audex/cli/apis/init/__init__.py +34 -0
  6. audex/cli/apis/init/gencfg.py +130 -0
  7. audex/cli/apis/init/setup.py +330 -0
  8. audex/cli/apis/init/vprgroup.py +125 -0
  9. audex/cli/apis/serve.py +141 -0
  10. audex/cli/args.py +356 -0
  11. audex/cli/exceptions.py +44 -0
  12. audex/cli/helper/__init__.py +0 -0
  13. audex/cli/helper/ansi.py +193 -0
  14. audex/cli/helper/display.py +288 -0
  15. audex/config/__init__.py +64 -0
  16. audex/config/core/__init__.py +30 -0
  17. audex/config/core/app.py +29 -0
  18. audex/config/core/audio.py +45 -0
  19. audex/config/core/logging.py +163 -0
  20. audex/config/core/session.py +11 -0
  21. audex/config/helper/__init__.py +1 -0
  22. audex/config/helper/client/__init__.py +1 -0
  23. audex/config/helper/client/http.py +28 -0
  24. audex/config/helper/client/websocket.py +21 -0
  25. audex/config/helper/provider/__init__.py +1 -0
  26. audex/config/helper/provider/dashscope.py +13 -0
  27. audex/config/helper/provider/unisound.py +18 -0
  28. audex/config/helper/provider/xfyun.py +23 -0
  29. audex/config/infrastructure/__init__.py +31 -0
  30. audex/config/infrastructure/cache.py +51 -0
  31. audex/config/infrastructure/database.py +48 -0
  32. audex/config/infrastructure/recorder.py +32 -0
  33. audex/config/infrastructure/store.py +19 -0
  34. audex/config/provider/__init__.py +18 -0
  35. audex/config/provider/transcription.py +109 -0
  36. audex/config/provider/vpr.py +99 -0
  37. audex/container.py +40 -0
  38. audex/entity/__init__.py +468 -0
  39. audex/entity/doctor.py +109 -0
  40. audex/entity/doctor.pyi +51 -0
  41. audex/entity/fields.py +401 -0
  42. audex/entity/segment.py +115 -0
  43. audex/entity/segment.pyi +38 -0
  44. audex/entity/session.py +133 -0
  45. audex/entity/session.pyi +47 -0
  46. audex/entity/utterance.py +142 -0
  47. audex/entity/utterance.pyi +48 -0
  48. audex/entity/vp.py +68 -0
  49. audex/entity/vp.pyi +35 -0
  50. audex/exceptions.py +157 -0
  51. audex/filters/__init__.py +692 -0
  52. audex/filters/generated/__init__.py +21 -0
  53. audex/filters/generated/doctor.py +987 -0
  54. audex/filters/generated/segment.py +723 -0
  55. audex/filters/generated/session.py +978 -0
  56. audex/filters/generated/utterance.py +939 -0
  57. audex/filters/generated/vp.py +815 -0
  58. audex/helper/__init__.py +1 -0
  59. audex/helper/hash.py +33 -0
  60. audex/helper/mixin.py +65 -0
  61. audex/helper/net.py +19 -0
  62. audex/helper/settings/__init__.py +830 -0
  63. audex/helper/settings/fields.py +317 -0
  64. audex/helper/stream.py +153 -0
  65. audex/injectors/__init__.py +1 -0
  66. audex/injectors/config.py +12 -0
  67. audex/injectors/lifespan.py +7 -0
  68. audex/lib/__init__.py +1 -0
  69. audex/lib/cache/__init__.py +383 -0
  70. audex/lib/cache/inmemory.py +513 -0
  71. audex/lib/database/__init__.py +83 -0
  72. audex/lib/database/sqlite.py +406 -0
  73. audex/lib/exporter.py +189 -0
  74. audex/lib/injectors/__init__.py +1 -0
  75. audex/lib/injectors/cache.py +25 -0
  76. audex/lib/injectors/container.py +47 -0
  77. audex/lib/injectors/exporter.py +26 -0
  78. audex/lib/injectors/recorder.py +33 -0
  79. audex/lib/injectors/server.py +17 -0
  80. audex/lib/injectors/session.py +18 -0
  81. audex/lib/injectors/sqlite.py +24 -0
  82. audex/lib/injectors/store.py +13 -0
  83. audex/lib/injectors/transcription.py +42 -0
  84. audex/lib/injectors/usb.py +12 -0
  85. audex/lib/injectors/vpr.py +65 -0
  86. audex/lib/injectors/wifi.py +7 -0
  87. audex/lib/recorder.py +844 -0
  88. audex/lib/repos/__init__.py +149 -0
  89. audex/lib/repos/container.py +23 -0
  90. audex/lib/repos/database/__init__.py +1 -0
  91. audex/lib/repos/database/sqlite.py +672 -0
  92. audex/lib/repos/decorators.py +74 -0
  93. audex/lib/repos/doctor.py +286 -0
  94. audex/lib/repos/segment.py +302 -0
  95. audex/lib/repos/session.py +285 -0
  96. audex/lib/repos/tables/__init__.py +70 -0
  97. audex/lib/repos/tables/doctor.py +137 -0
  98. audex/lib/repos/tables/segment.py +113 -0
  99. audex/lib/repos/tables/session.py +140 -0
  100. audex/lib/repos/tables/utterance.py +131 -0
  101. audex/lib/repos/tables/vp.py +102 -0
  102. audex/lib/repos/utterance.py +288 -0
  103. audex/lib/repos/vp.py +286 -0
  104. audex/lib/restful.py +251 -0
  105. audex/lib/server/__init__.py +97 -0
  106. audex/lib/server/auth.py +98 -0
  107. audex/lib/server/handlers.py +248 -0
  108. audex/lib/server/templates/index.html.j2 +226 -0
  109. audex/lib/server/templates/login.html.j2 +111 -0
  110. audex/lib/server/templates/static/script.js +68 -0
  111. audex/lib/server/templates/static/style.css +579 -0
  112. audex/lib/server/types.py +123 -0
  113. audex/lib/session.py +503 -0
  114. audex/lib/store/__init__.py +238 -0
  115. audex/lib/store/localfile.py +411 -0
  116. audex/lib/transcription/__init__.py +33 -0
  117. audex/lib/transcription/dashscope.py +525 -0
  118. audex/lib/transcription/events.py +62 -0
  119. audex/lib/usb.py +554 -0
  120. audex/lib/vpr/__init__.py +38 -0
  121. audex/lib/vpr/unisound/__init__.py +185 -0
  122. audex/lib/vpr/unisound/types.py +469 -0
  123. audex/lib/vpr/xfyun/__init__.py +483 -0
  124. audex/lib/vpr/xfyun/types.py +679 -0
  125. audex/lib/websocket/__init__.py +8 -0
  126. audex/lib/websocket/connection.py +485 -0
  127. audex/lib/websocket/pool.py +991 -0
  128. audex/lib/wifi.py +1146 -0
  129. audex/lifespan.py +75 -0
  130. audex/service/__init__.py +27 -0
  131. audex/service/decorators.py +73 -0
  132. audex/service/doctor/__init__.py +652 -0
  133. audex/service/doctor/const.py +36 -0
  134. audex/service/doctor/exceptions.py +96 -0
  135. audex/service/doctor/types.py +54 -0
  136. audex/service/export/__init__.py +236 -0
  137. audex/service/export/const.py +17 -0
  138. audex/service/export/exceptions.py +34 -0
  139. audex/service/export/types.py +21 -0
  140. audex/service/injectors/__init__.py +1 -0
  141. audex/service/injectors/container.py +53 -0
  142. audex/service/injectors/doctor.py +34 -0
  143. audex/service/injectors/export.py +27 -0
  144. audex/service/injectors/session.py +49 -0
  145. audex/service/session/__init__.py +754 -0
  146. audex/service/session/const.py +34 -0
  147. audex/service/session/exceptions.py +67 -0
  148. audex/service/session/types.py +91 -0
  149. audex/types.py +39 -0
  150. audex/utils.py +287 -0
  151. audex/valueobj/__init__.py +81 -0
  152. audex/valueobj/common/__init__.py +1 -0
  153. audex/valueobj/common/auth.py +84 -0
  154. audex/valueobj/common/email.py +16 -0
  155. audex/valueobj/common/ops.py +22 -0
  156. audex/valueobj/common/phone.py +84 -0
  157. audex/valueobj/common/version.py +72 -0
  158. audex/valueobj/session.py +19 -0
  159. audex/valueobj/utterance.py +15 -0
  160. audex/view/__init__.py +51 -0
  161. audex/view/container.py +17 -0
  162. audex/view/decorators.py +303 -0
  163. audex/view/pages/__init__.py +1 -0
  164. audex/view/pages/dashboard/__init__.py +286 -0
  165. audex/view/pages/dashboard/wifi.py +407 -0
  166. audex/view/pages/login.py +110 -0
  167. audex/view/pages/recording.py +348 -0
  168. audex/view/pages/register.py +202 -0
  169. audex/view/pages/sessions/__init__.py +196 -0
  170. audex/view/pages/sessions/details.py +224 -0
  171. audex/view/pages/sessions/export.py +443 -0
  172. audex/view/pages/settings.py +374 -0
  173. audex/view/pages/voiceprint/__init__.py +1 -0
  174. audex/view/pages/voiceprint/enroll.py +195 -0
  175. audex/view/pages/voiceprint/update.py +195 -0
  176. audex/view/static/css/dashboard.css +452 -0
  177. audex/view/static/css/glass.css +22 -0
  178. audex/view/static/css/global.css +541 -0
  179. audex/view/static/css/login.css +386 -0
  180. audex/view/static/css/recording.css +439 -0
  181. audex/view/static/css/register.css +293 -0
  182. audex/view/static/css/sessions/styles.css +501 -0
  183. audex/view/static/css/settings.css +186 -0
  184. audex/view/static/css/voiceprint/enroll.css +43 -0
  185. audex/view/static/css/voiceprint/styles.css +209 -0
  186. audex/view/static/css/voiceprint/update.css +44 -0
  187. audex/view/static/images/logo.svg +95 -0
  188. audex/view/static/js/recording.js +42 -0
  189. audex-1.0.7a3.dist-info/METADATA +361 -0
  190. audex-1.0.7a3.dist-info/RECORD +192 -0
  191. audex-1.0.7a3.dist-info/WHEEL +4 -0
  192. audex-1.0.7a3.dist-info/entry_points.txt +3 -0
audex/lib/wifi.py ADDED
@@ -0,0 +1,1146 @@
1
+ from __future__ import annotations
2
+
3
+ import abc
4
+ import asyncio
5
+ import enum
6
+ import pathlib
7
+ import platform
8
+ import re
9
+ import typing as t
10
+
11
+ from audex.helper.mixin import AsyncContextMixin
12
+ from audex.helper.mixin import LoggingMixin
13
+
14
+
15
+ class WiFiSecurity(str, enum.Enum):
16
+ """WiFi security types."""
17
+
18
+ OPEN = "open"
19
+ WEP = "wep"
20
+ WPA = "wpa"
21
+ WPA2 = "wpa2"
22
+ WPA3 = "wpa3"
23
+ UNKNOWN = "unknown"
24
+
25
+
26
+ class WiFiStatus(str, enum.Enum):
27
+ """WiFi connection status."""
28
+
29
+ CONNECTED = "connected"
30
+ CONNECTING = "connecting"
31
+ DISCONNECTED = "disconnected"
32
+ FAILED = "failed"
33
+ UNKNOWN = "unknown"
34
+
35
+
36
+ class WiFiNetwork(t.NamedTuple):
37
+ """Represents a WiFi network.
38
+
39
+ Attributes:
40
+ ssid: Network SSID (name).
41
+ bssid: MAC address of the access point.
42
+ signal_strength: Signal strength in dBm (e.g., -50).
43
+ signal_quality: Signal quality percentage (0-100).
44
+ frequency: Frequency in MHz (e.g., 2412, 5180).
45
+ channel: WiFi channel number.
46
+ security: Security type.
47
+ is_connected: Whether currently connected to this network.
48
+ """
49
+
50
+ ssid: str
51
+ bssid: str | None
52
+ signal_strength: int # dBm
53
+ signal_quality: int # 0-100
54
+ frequency: int | None # MHz
55
+ channel: int | None
56
+ security: WiFiSecurity
57
+ is_connected: bool
58
+
59
+
60
+ class WiFiConnectionInfo(t.NamedTuple):
61
+ """Current WiFi connection information.
62
+
63
+ Attributes:
64
+ ssid: Connected network SSID.
65
+ bssid: Connected access point BSSID.
66
+ signal_strength: Current signal strength in dBm.
67
+ signal_quality: Current signal quality percentage.
68
+ frequency: Connection frequency in MHz.
69
+ channel: Connection channel.
70
+ link_speed: Link speed in Mbps.
71
+ ip_address: Assigned IP address.
72
+ status: Connection status.
73
+ """
74
+
75
+ ssid: str
76
+ bssid: str | None
77
+ signal_strength: int
78
+ signal_quality: int
79
+ frequency: int | None
80
+ channel: int | None
81
+ link_speed: int | None # Mbps
82
+ ip_address: str | None
83
+ status: WiFiStatus
84
+
85
+
86
+ class WiFiBackend(abc.ABC):
87
+ """Abstract base class for WiFi backends."""
88
+
89
+ def __init__(self, logger: t.Any) -> None:
90
+ self.logger = logger
91
+
92
+ @abc.abstractmethod
93
+ async def scan(self) -> list[WiFiNetwork]:
94
+ """Scan for available WiFi networks."""
95
+
96
+ @abc.abstractmethod
97
+ async def connect(self, ssid: str, password: str | None = None) -> bool:
98
+ """Connect to a WiFi network."""
99
+
100
+ @abc.abstractmethod
101
+ async def disconnect(self) -> bool:
102
+ """Disconnect from WiFi network."""
103
+
104
+ @abc.abstractmethod
105
+ async def get_connection_info(self) -> WiFiConnectionInfo | None:
106
+ """Get current connection information."""
107
+
108
+ @abc.abstractmethod
109
+ async def is_available(self) -> bool:
110
+ """Check if WiFi adapter is available."""
111
+
112
+
113
+ class LinuxWiFiBackend(WiFiBackend):
114
+ """Linux WiFi backend using NetworkManager (nmcli)."""
115
+
116
+ def __init__(self, logger: t.Any) -> None:
117
+ super().__init__(logger)
118
+ self._interface: str | None = None
119
+
120
+ async def is_available(self) -> bool:
121
+ """Check if nmcli is available."""
122
+ try:
123
+ result = await asyncio.create_subprocess_exec(
124
+ "nmcli",
125
+ "--version",
126
+ stdout=asyncio.subprocess.PIPE,
127
+ stderr=asyncio.subprocess.PIPE,
128
+ )
129
+ await result.communicate()
130
+ return result.returncode == 0
131
+ except FileNotFoundError:
132
+ return False
133
+
134
+ async def _get_interface(self) -> str | None:
135
+ """Get the first available WiFi interface."""
136
+ if self._interface:
137
+ return self._interface
138
+
139
+ try:
140
+ result = await asyncio.create_subprocess_exec(
141
+ "nmcli",
142
+ "-t",
143
+ "-f",
144
+ "DEVICE,TYPE",
145
+ "device",
146
+ stdout=asyncio.subprocess.PIPE,
147
+ stderr=asyncio.subprocess.PIPE,
148
+ )
149
+ stdout, _ = await result.communicate()
150
+
151
+ if result.returncode == 0:
152
+ lines = stdout.decode("utf-8").strip().split("\n")
153
+ for line in lines:
154
+ parts = line.split(":")
155
+ if len(parts) >= 2 and parts[1] == "wifi":
156
+ self._interface = parts[0]
157
+ self.logger.debug(f"Found WiFi interface: {self._interface}")
158
+ return self._interface
159
+
160
+ except Exception as e:
161
+ self.logger.error(f"Failed to get WiFi interface: {e}")
162
+
163
+ return None
164
+
165
+ def _parse_security(self, security_str: str) -> WiFiSecurity:
166
+ """Parse security type from nmcli output."""
167
+ if not security_str or security_str == "--" or security_str == "":
168
+ return WiFiSecurity.OPEN
169
+
170
+ security_str = security_str.upper()
171
+
172
+ if "WPA3" in security_str:
173
+ return WiFiSecurity.WPA3
174
+ if "WPA2" in security_str or "RSN" in security_str:
175
+ return WiFiSecurity.WPA2
176
+ if "WPA" in security_str:
177
+ return WiFiSecurity.WPA
178
+ if "WEP" in security_str:
179
+ return WiFiSecurity.WEP
180
+
181
+ return WiFiSecurity.UNKNOWN
182
+
183
+ def _signal_to_dbm(self, signal: int) -> int:
184
+ """Convert signal quality (0-100) to dBm.
185
+
186
+ Typical WiFi range: -90 dBm (poor) to -30 dBm (excellent)
187
+ Formula: dBm = -90 + (signal * 0.6)
188
+ """
189
+ if signal >= 100:
190
+ return -30
191
+ if signal <= 0:
192
+ return -90
193
+ # Linear mapping: 0-100 -> -90 to -30
194
+ return int(-90 + (signal * 0.6))
195
+
196
+ async def scan(self) -> list[WiFiNetwork]:
197
+ """Scan for available WiFi networks using nmcli."""
198
+ interface = await self._get_interface()
199
+ if not interface:
200
+ self.logger.warning("No WiFi interface available")
201
+ return []
202
+
203
+ try:
204
+ # Request rescan
205
+ rescan_result = await asyncio.create_subprocess_exec(
206
+ "nmcli",
207
+ "device",
208
+ "wifi",
209
+ "rescan",
210
+ stdout=asyncio.subprocess.PIPE,
211
+ stderr=asyncio.subprocess.PIPE,
212
+ )
213
+ await rescan_result.communicate()
214
+ await asyncio.sleep(1) # Wait for scan to complete
215
+
216
+ # Get scan results
217
+ result = await asyncio.create_subprocess_exec(
218
+ "nmcli",
219
+ "-t",
220
+ "-f",
221
+ "SSID,BSSID,MODE,CHAN,FREQ,RATE,SIGNAL,SECURITY,IN-USE",
222
+ "device",
223
+ "wifi",
224
+ "list",
225
+ "ifname",
226
+ interface,
227
+ stdout=asyncio.subprocess.PIPE,
228
+ stderr=asyncio.subprocess.PIPE,
229
+ )
230
+ stdout, stderr = await result.communicate()
231
+
232
+ if result.returncode != 0:
233
+ self.logger.error(f"WiFi scan failed: {stderr.decode('utf-8')}")
234
+ return []
235
+
236
+ raw_output = stdout.decode("utf-8").strip()
237
+ networks: list[WiFiNetwork] = []
238
+ seen_ssids: set[str] = set()
239
+
240
+ lines = raw_output.split("\n")
241
+ for line in lines:
242
+ if not line:
243
+ continue
244
+
245
+ # Split by colon, but handle escaped colons in BSSID
246
+ # Format: SSID:BSSID:MODE:CHAN:FREQ:RATE:SIGNAL:SECURITY:IN-USE
247
+ # Example: XINNENG-5G:18\:2A\:57\:CE\:97\:C4:Infra:36:5180 MHz:270 Mbit/s:75:WPA2:*
248
+
249
+ # First, unescape the BSSID colons
250
+ line = line.replace("\\:", "<! COLON! >")
251
+ parts = line.split(":")
252
+
253
+ # Restore BSSID colons
254
+ parts = [p.replace("<!COLON! >", ":") for p in parts]
255
+
256
+ if len(parts) < 9:
257
+ self.logger.warning(
258
+ f"Skipping line with {len(parts)} parts (expected 9): {line}"
259
+ )
260
+ continue
261
+
262
+ ssid = parts[0].strip()
263
+ if not ssid or ssid in seen_ssids:
264
+ continue
265
+
266
+ bssid = parts[1].strip() or None
267
+ # mode = parts[2].strip() # Not used
268
+ channel_str = parts[3].strip()
269
+ freq_str = parts[4].strip() # e.g., "5180 MHz"
270
+ # rate_str = parts[5].strip() # e.g., "270 Mbit/s" - not used
271
+ signal_str = parts[6].strip()
272
+ security_str = parts[7].strip()
273
+ in_use = parts[8].strip()
274
+ is_connected = in_use == "*" or in_use == "yes" or in_use == "是"
275
+
276
+ # Parse signal (already 0-100 percentage)
277
+ try:
278
+ signal_quality = int(signal_str) if signal_str else 0
279
+ signal_strength = self._signal_to_dbm(signal_quality)
280
+ except ValueError:
281
+ self.logger.warning(f"Failed to parse signal: '{signal_str}'")
282
+ signal_quality = 0
283
+ signal_strength = -90
284
+
285
+ # Parse channel
286
+ try:
287
+ channel = int(channel_str) if channel_str else None
288
+ except ValueError:
289
+ self.logger.warning(f"Failed to parse channel: '{channel_str}'")
290
+ channel = None
291
+
292
+ # Parse frequency (remove " MHz" suffix)
293
+ try:
294
+ freq_clean = freq_str.replace(" MHz", "").replace("MHz", "").strip()
295
+ frequency = int(freq_clean) if freq_clean else None
296
+ except ValueError:
297
+ self.logger.warning(f"Failed to parse frequency: '{freq_str}'")
298
+ frequency = None
299
+
300
+ # Parse security
301
+ security = self._parse_security(security_str)
302
+
303
+ network = WiFiNetwork(
304
+ ssid=ssid,
305
+ bssid=bssid,
306
+ signal_strength=signal_strength,
307
+ signal_quality=signal_quality,
308
+ frequency=frequency,
309
+ channel=channel,
310
+ security=security,
311
+ is_connected=is_connected,
312
+ )
313
+ networks.append(network)
314
+ seen_ssids.add(ssid)
315
+
316
+ self.logger.debug(f"Found {len(networks)} WiFi networks")
317
+ return networks
318
+
319
+ except Exception as e:
320
+ self.logger.error(f"WiFi scan error: {e}", exc_info=True)
321
+ return []
322
+
323
+ async def connect(self, ssid: str, password: str | None = None) -> bool:
324
+ """Connect to a WiFi network using nmcli."""
325
+ try:
326
+ args = ["nmcli", "device", "wifi", "connect", ssid]
327
+ if password:
328
+ args.extend(["password", password])
329
+
330
+ result = await asyncio.create_subprocess_exec(
331
+ *args,
332
+ stdout=asyncio.subprocess.PIPE,
333
+ stderr=asyncio.subprocess.PIPE,
334
+ )
335
+ stdout, stderr = await result.communicate()
336
+
337
+ if result.returncode == 0:
338
+ self.logger.info(f"Successfully connected to {ssid}")
339
+ return True
340
+
341
+ error_msg = stderr.decode("utf-8").strip()
342
+ self.logger.error(f"Failed to connect to {ssid}: {error_msg}")
343
+ return False
344
+
345
+ except Exception as e:
346
+ self.logger.error(f"Error connecting to {ssid}: {e}", exc_info=True)
347
+ return False
348
+
349
+ async def disconnect(self, ssid: str | None = None) -> bool:
350
+ """Disconnect from WiFi network."""
351
+ interface = await self._get_interface()
352
+ if not interface:
353
+ self.logger.warning("No WiFi interface available")
354
+ return False
355
+
356
+ try:
357
+ if ssid:
358
+ # Disconnect specific connection
359
+ result = await asyncio.create_subprocess_exec(
360
+ "nmcli",
361
+ "connection",
362
+ "down",
363
+ ssid,
364
+ stdout=asyncio.subprocess.PIPE,
365
+ stderr=asyncio.subprocess.PIPE,
366
+ )
367
+ else:
368
+ # Disconnect interface
369
+ result = await asyncio.create_subprocess_exec(
370
+ "nmcli",
371
+ "device",
372
+ "disconnect",
373
+ interface,
374
+ stdout=asyncio.subprocess.PIPE,
375
+ stderr=asyncio.subprocess.PIPE,
376
+ )
377
+
378
+ await result.communicate()
379
+
380
+ if result.returncode == 0:
381
+ self.logger.info("Successfully disconnected from WiFi")
382
+ return True
383
+
384
+ self.logger.warning("Failed to disconnect from WiFi")
385
+ return False
386
+
387
+ except Exception as e:
388
+ self.logger.error(f"Error disconnecting: {e}", exc_info=True)
389
+ return False
390
+
391
+ async def get_connection_info(self) -> WiFiConnectionInfo | None:
392
+ """Get current connection information."""
393
+ interface = await self._get_interface()
394
+ if not interface:
395
+ return None
396
+
397
+ try:
398
+ # Get device info
399
+ result = await asyncio.create_subprocess_exec(
400
+ "nmcli",
401
+ "-t",
402
+ "-f",
403
+ "GENERAL.CONNECTION,GENERAL.STATE,IP4.ADDRESS",
404
+ "device",
405
+ "show",
406
+ interface,
407
+ stdout=asyncio.subprocess.PIPE,
408
+ stderr=asyncio.subprocess.PIPE,
409
+ )
410
+ stdout, _ = await result.communicate()
411
+
412
+ if result.returncode != 0:
413
+ return None
414
+
415
+ lines = stdout.decode("utf-8").strip().split("\n")
416
+ connection_name = None
417
+ state = None
418
+ ip_address = None
419
+
420
+ for line in lines:
421
+ if "GENERAL.CONNECTION:" in line:
422
+ connection_name = line.split(":", 1)[1].strip()
423
+ elif "GENERAL.STATE:" in line:
424
+ state_str = line.split(":", 1)[1].strip()
425
+ # Extract state code (e.g., "100 (connected)" -> "100")
426
+ state = state_str.split()[0] if state_str else None
427
+ elif "IP4.ADDRESS[1]:" in line:
428
+ ip_str = line.split(":", 1)[1].strip()
429
+ # Remove subnet mask (e.g., "192.168.1.130/24" -> "192.168.1.130")
430
+ ip_address = ip_str.split("/")[0] if ip_str else None
431
+
432
+ if not connection_name or connection_name == "--":
433
+ return None
434
+
435
+ # Get detailed WiFi info for connected network
436
+ result = await asyncio.create_subprocess_exec(
437
+ "nmcli",
438
+ "-t",
439
+ "-f",
440
+ "SSID,BSSID,FREQ,CHAN,SIGNAL,IN-USE",
441
+ "device",
442
+ "wifi",
443
+ "list",
444
+ "ifname",
445
+ interface,
446
+ stdout=asyncio.subprocess.PIPE,
447
+ stderr=asyncio.subprocess.PIPE,
448
+ )
449
+ stdout, _ = await result.communicate()
450
+
451
+ ssid = connection_name.split()[0] # Remove trailing " 1" if exists
452
+ bssid = None
453
+ signal_quality = 0
454
+ signal_strength = -90
455
+ frequency = None
456
+ channel = None
457
+
458
+ if result.returncode == 0:
459
+ raw_output = stdout.decode("utf-8").strip()
460
+ lines = raw_output.split("\n")
461
+
462
+ for line in lines:
463
+ # Handle escaped colons in BSSID
464
+ line = line.replace("\\:", "<!COLON!>")
465
+ parts = line.split(":")
466
+ parts = [p.replace("<! COLON!>", ":") for p in parts]
467
+
468
+ if len(parts) >= 6:
469
+ line_ssid = parts[0].strip()
470
+ in_use = parts[5].strip()
471
+
472
+ # Find the connected network (marked with *)
473
+ if in_use == "*" or in_use == "yes" or line_ssid == ssid:
474
+ bssid = parts[1].strip() or None
475
+ freq_str = parts[2].strip()
476
+ chan_str = parts[3].strip()
477
+ sig_str = parts[4].strip()
478
+
479
+ try:
480
+ # Remove " MHz" suffix
481
+ freq_clean = freq_str.replace(" MHz", "").replace("MHz", "").strip()
482
+ frequency = int(freq_clean) if freq_clean else None
483
+
484
+ channel = int(chan_str) if chan_str else None
485
+ signal_quality = int(sig_str) if sig_str else 0
486
+ signal_strength = self._signal_to_dbm(signal_quality)
487
+ except ValueError as e:
488
+ self.logger.warning(f"Failed to parse connection info: {e}")
489
+ break
490
+
491
+ status = WiFiStatus.CONNECTED if state == "100" else WiFiStatus.UNKNOWN
492
+
493
+ return WiFiConnectionInfo(
494
+ ssid=ssid,
495
+ bssid=bssid,
496
+ signal_strength=signal_strength,
497
+ signal_quality=signal_quality,
498
+ frequency=frequency,
499
+ channel=channel,
500
+ link_speed=None,
501
+ ip_address=ip_address,
502
+ status=status,
503
+ )
504
+
505
+ except Exception as e:
506
+ self.logger.error(f"Error getting connection info: {e}", exc_info=True)
507
+ return None
508
+
509
+
510
+ class WindowsWiFiBackend(WiFiBackend):
511
+ """Windows WiFi backend using netsh."""
512
+
513
+ def __init__(self, logger: t.Any) -> None:
514
+ super().__init__(logger)
515
+ self._interface: str | None = None
516
+
517
+ async def is_available(self) -> bool:
518
+ """Check if netsh is available."""
519
+ try:
520
+ result = await asyncio.create_subprocess_exec(
521
+ "netsh",
522
+ "wlan",
523
+ "show",
524
+ "interfaces",
525
+ stdout=asyncio.subprocess.PIPE,
526
+ stderr=asyncio.subprocess.PIPE,
527
+ )
528
+ await result.communicate()
529
+ return result.returncode == 0
530
+ except FileNotFoundError:
531
+ return False
532
+
533
+ def _parse_security(self, auth: str, cipher: str) -> WiFiSecurity:
534
+ """Parse security type from Windows output (supports
535
+ Chinese)."""
536
+ auth = auth.upper()
537
+ cipher = cipher.upper()
538
+
539
+ # WPA3
540
+ if "WPA3" in auth:
541
+ return WiFiSecurity.WPA3
542
+
543
+ # WPA2 (中英文)
544
+ if "WPA2" in auth or "WPA2 - 个人" in auth or "WPA2-PERSONAL" in auth:
545
+ return WiFiSecurity.WPA2
546
+
547
+ # WPA (中英文)
548
+ if "WPA" in auth and "WPA2" not in auth:
549
+ return WiFiSecurity.WPA
550
+
551
+ # WEP
552
+ if "WEP" in cipher or "WEP" in auth:
553
+ return WiFiSecurity.WEP
554
+
555
+ # Open (中英文)
556
+ if "OPEN" in auth or "开放" in auth or not auth or auth == "--":
557
+ return WiFiSecurity.OPEN
558
+
559
+ return WiFiSecurity.UNKNOWN
560
+
561
+ def _signal_to_dbm(self, quality: int) -> int:
562
+ """Convert Windows signal quality (0-100) to dBm."""
563
+ # Approximate conversion
564
+ if quality >= 100:
565
+ return -30
566
+ if quality <= 0:
567
+ return -90
568
+ return -90 + int(quality * 0.6)
569
+
570
+ async def scan(self) -> list[WiFiNetwork]:
571
+ """Scan for available WiFi networks using netsh."""
572
+ try:
573
+ result = await asyncio.create_subprocess_exec(
574
+ "netsh",
575
+ "wlan",
576
+ "show",
577
+ "networks",
578
+ "mode=bssid",
579
+ stdout=asyncio.subprocess.PIPE,
580
+ stderr=asyncio.subprocess.PIPE,
581
+ )
582
+ stdout, stderr = await result.communicate()
583
+
584
+ if result.returncode != 0:
585
+ self.logger.error(f"WiFi scan failed: {stderr.decode('utf-8')}")
586
+ return []
587
+
588
+ output = stdout.decode("utf-8", errors="ignore")
589
+ networks: list[WiFiNetwork] = []
590
+ current_ssid = None
591
+ current_bssid = None
592
+ current_signal = 0
593
+ current_auth = ""
594
+ current_cipher = ""
595
+ current_channel = None
596
+
597
+ # Get currently connected network
598
+ connected_ssid = None
599
+ conn_info = await self.get_connection_info()
600
+ if conn_info:
601
+ connected_ssid = conn_info.ssid
602
+
603
+ for line in output.split("\n"):
604
+ line = line.strip()
605
+
606
+ # SSID (中英文兼容)
607
+ if line.startswith("SSID"):
608
+ # Save previous network
609
+ if current_ssid and current_bssid:
610
+ signal_dbm = self._signal_to_dbm(current_signal)
611
+ security = self._parse_security(current_auth, current_cipher)
612
+ is_connected = current_ssid == connected_ssid
613
+
614
+ network = WiFiNetwork(
615
+ ssid=str(current_ssid),
616
+ bssid=current_bssid,
617
+ signal_strength=signal_dbm,
618
+ signal_quality=current_signal,
619
+ frequency=None,
620
+ channel=current_channel,
621
+ security=security,
622
+ is_connected=is_connected,
623
+ )
624
+ networks.append(network)
625
+
626
+ # Start new network
627
+ match = re.search(r"SSID \d+\s*[::]\s*(.+)", line)
628
+ if match:
629
+ current_ssid = match.group(1).strip()
630
+ current_bssid = None
631
+ current_signal = 0
632
+ current_auth = ""
633
+ current_cipher = ""
634
+ current_channel = None
635
+
636
+ # BSSID (中英文兼容)
637
+ elif line.startswith("BSSID") or line.strip().startswith("BSSID"):
638
+ match = re.search(r"BSSID \d+\s*[::]\s*([0-9a-fA-F:]+)", line)
639
+ if match:
640
+ current_bssid = match.group(1).strip()
641
+
642
+ # Signal / 信号 (中英文兼容)
643
+ elif "Signal" in line or "信号" in line:
644
+ # 匹配 "信号 : 88%" 或 "Signal : 88%"
645
+ match = re.search(r"[::]\s*(\d+)%", line)
646
+ if match:
647
+ current_signal = int(match.group(1))
648
+
649
+ # Authentication / 身份验证 (中英文兼容)
650
+ elif "Authentication" in line or "身份验证" in line:
651
+ match = re.search(r"[::]\s*(.+)", line)
652
+ if match:
653
+ current_auth = match.group(1).strip()
654
+
655
+ # Encryption / 加密 (中英文兼容)
656
+ elif "Cipher" in line or "Encryption" in line or "加密" in line or "密码" in line:
657
+ match = re.search(r"[::]\s*(.+)", line)
658
+ if match:
659
+ current_cipher = match.group(1).strip()
660
+
661
+ # Channel / 频道 (中英文兼容)
662
+ elif "Channel" in line or "频道" in line or "波段" in line:
663
+ match = re.search(r"[::]\s*(\d+)", line)
664
+ if match:
665
+ current_channel = int(match.group(1))
666
+
667
+ # Save last network
668
+ if current_ssid and current_bssid:
669
+ signal_dbm = self._signal_to_dbm(current_signal)
670
+ security = self._parse_security(current_auth, current_cipher)
671
+ is_connected = current_ssid == connected_ssid
672
+
673
+ network = WiFiNetwork(
674
+ ssid=str(current_ssid),
675
+ bssid=current_bssid,
676
+ signal_strength=signal_dbm,
677
+ signal_quality=current_signal,
678
+ frequency=None,
679
+ channel=current_channel,
680
+ security=security,
681
+ is_connected=is_connected,
682
+ )
683
+ networks.append(network)
684
+
685
+ self.logger.debug(f"Found {len(networks)} WiFi networks")
686
+
687
+ # Debug: Print first few networks
688
+ for net in networks[:3]:
689
+ self.logger.debug(
690
+ f"Network: {net.ssid}, Signal: {net.signal_quality}%, "
691
+ f"Security: {net.security.value}, Auth: {current_auth}"
692
+ )
693
+
694
+ return networks
695
+
696
+ except Exception as e:
697
+ self.logger.error(f"WiFi scan error: {e}", exc_info=True)
698
+ return []
699
+
700
+ async def connect(self, ssid: str, password: str | None = None) -> bool:
701
+ """Connect to a WiFi network using netsh."""
702
+ try:
703
+ # Check if profile exists
704
+ result = await asyncio.create_subprocess_exec(
705
+ "netsh",
706
+ "wlan",
707
+ "show",
708
+ "profiles",
709
+ stdout=asyncio.subprocess.PIPE,
710
+ stderr=asyncio.subprocess.PIPE,
711
+ )
712
+ stdout, _ = await result.communicate()
713
+ profiles = stdout.decode("utf-8", errors="ignore")
714
+
715
+ profile_exists = ssid in profiles
716
+
717
+ if not profile_exists and password:
718
+ # Create profile with password
719
+ profile_xml = f"""<? xml version="1.0"?>
720
+ <WLANProfile xmlns="http://www.microsoft.com/networking/WLAN/profile/v1">
721
+ <name>{ssid}</name>
722
+ <SSIDConfig>
723
+ <SSID>
724
+ <name>{ssid}</name>
725
+ </SSID>
726
+ </SSIDConfig>
727
+ <connectionType>ESS</connectionType>
728
+ <connectionMode>auto</connectionMode>
729
+ <MSM>
730
+ <security>
731
+ <authEncryption>
732
+ <authentication>WPA2PSK</authentication>
733
+ <encryption>AES</encryption>
734
+ <useOneX>false</useOneX>
735
+ </authEncryption>
736
+ <sharedKey>
737
+ <keyType>passPhrase</keyType>
738
+ <protected>false</protected>
739
+ <keyMaterial>{password}</keyMaterial>
740
+ </sharedKey>
741
+ </security>
742
+ </MSM>
743
+ </WLANProfile>"""
744
+
745
+ # Save profile to temp file
746
+ import tempfile
747
+
748
+ with tempfile.NamedTemporaryFile(
749
+ mode="w", suffix=".xml", delete=False, encoding="utf-8"
750
+ ) as f:
751
+ f.write(profile_xml)
752
+ profile_path = f.name
753
+
754
+ try:
755
+ # Add profile
756
+ result = await asyncio.create_subprocess_exec(
757
+ "netsh",
758
+ "wlan",
759
+ "add",
760
+ "profile",
761
+ f"filename={profile_path}",
762
+ stdout=asyncio.subprocess.PIPE,
763
+ stderr=asyncio.subprocess.PIPE,
764
+ )
765
+ await result.communicate()
766
+ finally:
767
+ pathlib.Path(profile_path).unlink()
768
+
769
+ # Connect to network
770
+ result = await asyncio.create_subprocess_exec(
771
+ "netsh",
772
+ "wlan",
773
+ "connect",
774
+ f"name={ssid}",
775
+ stdout=asyncio.subprocess.PIPE,
776
+ stderr=asyncio.subprocess.PIPE,
777
+ )
778
+ stdout, stderr = await result.communicate()
779
+
780
+ if result.returncode == 0:
781
+ self.logger.info(f"Successfully connected to {ssid}")
782
+ return True
783
+ error_msg = stderr.decode("utf-8").strip()
784
+ self.logger.error(f"Failed to connect to {ssid}: {error_msg}")
785
+ return False
786
+
787
+ except Exception as e:
788
+ self.logger.error(f"Error connecting to {ssid}: {e}", exc_info=True)
789
+ return False
790
+
791
+ async def disconnect(self) -> bool:
792
+ """Disconnect from WiFi network."""
793
+ try:
794
+ result = await asyncio.create_subprocess_exec(
795
+ "netsh",
796
+ "wlan",
797
+ "disconnect",
798
+ stdout=asyncio.subprocess.PIPE,
799
+ stderr=asyncio.subprocess.PIPE,
800
+ )
801
+ await result.communicate()
802
+
803
+ if result.returncode == 0:
804
+ self.logger.info("Successfully disconnected from WiFi")
805
+ return True
806
+ self.logger.warning("Failed to disconnect from WiFi")
807
+ return False
808
+
809
+ except Exception as e:
810
+ self.logger.error(f"Error disconnecting: {e}", exc_info=True)
811
+ return False
812
+
813
+ async def get_connection_info(self) -> WiFiConnectionInfo | None:
814
+ """Get current connection information."""
815
+ try:
816
+ result = await asyncio.create_subprocess_exec(
817
+ "netsh",
818
+ "wlan",
819
+ "show",
820
+ "interfaces",
821
+ stdout=asyncio.subprocess.PIPE,
822
+ stderr=asyncio.subprocess.PIPE,
823
+ )
824
+ stdout, _ = await result.communicate()
825
+
826
+ if result.returncode != 0:
827
+ return None
828
+
829
+ output = stdout.decode("utf-8", errors="ignore")
830
+ ssid = None
831
+ bssid = None
832
+ signal_quality = 0
833
+ channel = None
834
+ link_speed = None
835
+ state = None
836
+
837
+ for line in output.split("\n"):
838
+ line = line.strip()
839
+
840
+ if "SSID" in line and "BSSID" not in line:
841
+ match = re.search(r"SSID\s+:\s+(.+)", line)
842
+ if match:
843
+ ssid = match.group(1).strip()
844
+
845
+ elif "BSSID" in line:
846
+ match = re.search(r"BSSID\s+:\s+(.+)", line)
847
+ if match:
848
+ bssid = match.group(1).strip()
849
+
850
+ elif "Signal" in line:
851
+ match = re.search(r"Signal\s+:\s+(\d+)%", line)
852
+ if match:
853
+ signal_quality = int(match.group(1))
854
+
855
+ elif "Channel" in line:
856
+ match = re.search(r"Channel\s+:\s+(\d+)", line)
857
+ if match:
858
+ channel = int(match.group(1))
859
+
860
+ elif "Receive rate" in line or "Transmit rate" in line:
861
+ match = re.search(r":\s+(\d+)", line)
862
+ if match and not link_speed:
863
+ link_speed = int(match.group(1))
864
+
865
+ elif "State" in line:
866
+ match = re.search(r"State\s+:\s+(.+)", line)
867
+ if match:
868
+ state_str = match.group(1).strip().lower()
869
+ if "connected" in state_str:
870
+ state = WiFiStatus.CONNECTED
871
+ elif "disconnected" in state_str:
872
+ state = WiFiStatus.DISCONNECTED
873
+
874
+ if not ssid:
875
+ return None
876
+
877
+ signal_strength = self._signal_to_dbm(signal_quality)
878
+
879
+ # Get IP address
880
+ ip_address = None
881
+ try:
882
+ result = await asyncio.create_subprocess_exec(
883
+ "ipconfig",
884
+ stdout=asyncio.subprocess.PIPE,
885
+ stderr=asyncio.subprocess.PIPE,
886
+ )
887
+ stdout, _ = await result.communicate()
888
+ output = stdout.decode("utf-8", errors="ignore")
889
+
890
+ # Find WiFi adapter section and extract IPv4
891
+ in_wifi_section = False
892
+ for line in output.split("\n"):
893
+ if "Wireless LAN adapter" in line or "Wi-Fi" in line:
894
+ in_wifi_section = True
895
+ elif "adapter" in line:
896
+ in_wifi_section = False
897
+ elif in_wifi_section and "IPv4 Address" in line:
898
+ match = re.search(r":\s+([\d.]+)", line)
899
+ if match:
900
+ ip_address = match.group(1)
901
+ break
902
+ except Exception:
903
+ pass
904
+
905
+ return WiFiConnectionInfo(
906
+ ssid=ssid,
907
+ bssid=bssid,
908
+ signal_strength=signal_strength,
909
+ signal_quality=signal_quality,
910
+ frequency=None,
911
+ channel=channel,
912
+ link_speed=link_speed,
913
+ ip_address=ip_address,
914
+ status=state if state else WiFiStatus.UNKNOWN,
915
+ )
916
+
917
+ except Exception as e:
918
+ self.logger.error(f"Error getting connection info: {e}", exc_info=True)
919
+ return None
920
+
921
+
922
+ class WiFiManager(LoggingMixin, AsyncContextMixin):
923
+ """Cross-platform WiFi manager for scanning and connecting to
924
+ networks.
925
+
926
+ This manager provides functionality to scan for WiFi networks, connect
927
+ and disconnect from networks, and monitor connection status.It
928
+ automatically selects the appropriate backend (Linux/Windows) based
929
+ on the platform.
930
+
931
+ Example:
932
+ ```python
933
+ # Create manager
934
+ manager = WiFiManager()
935
+ await manager.init()
936
+
937
+ # Scan for networks
938
+ networks = await manager.scan()
939
+ for network in networks:
940
+ print(
941
+ f"{network.ssid}: {network.signal_quality}% "
942
+ f"({network.security.value})"
943
+ )
944
+
945
+ # Connect to a network
946
+ success = await manager.connect("MyNetwork", "password123")
947
+
948
+ # Get current connection
949
+ info = await manager.get_connection_info()
950
+ if info:
951
+ print(
952
+ f"Connected to {info.ssid} with IP {info.ip_address}"
953
+ )
954
+
955
+ # Disconnect
956
+ await manager.disconnect()
957
+
958
+ await manager.close()
959
+ ```
960
+ """
961
+
962
+ __logtag__ = "audex.lib.wifi.manager"
963
+
964
+ def __init__(self) -> None:
965
+ super().__init__()
966
+
967
+ # Initialize platform-specific backend
968
+ system = platform.system()
969
+ if system == "Linux":
970
+ self._backend: WiFiBackend = LinuxWiFiBackend(self.logger)
971
+ self.logger.info("Initialized Linux WiFi backend")
972
+ elif system == "Windows":
973
+ self._backend = WindowsWiFiBackend(self.logger)
974
+ self.logger.info("Initialized Windows WiFi backend")
975
+ else:
976
+ raise RuntimeError(f"Unsupported platform: {system}")
977
+
978
+ self._available = False
979
+
980
+ async def init(self) -> None:
981
+ """Initialize the WiFi manager.
982
+
983
+ Checks if WiFi functionality is available on the system.
984
+
985
+ Raises:
986
+ RuntimeError: If WiFi is not available.
987
+ """
988
+ self._available = await self._backend.is_available()
989
+ if not self._available:
990
+ raise RuntimeError(
991
+ "WiFi functionality not available."
992
+ "On Linux, install NetworkManager (nmcli)."
993
+ "On Windows, ensure WiFi adapter is enabled."
994
+ )
995
+ self.logger.info("WiFi manager initialized")
996
+
997
+ async def close(self) -> None:
998
+ """Close the WiFi manager and release resources."""
999
+ self.logger.info("WiFi manager closed")
1000
+
1001
+ @property
1002
+ def is_available(self) -> bool:
1003
+ """Check if WiFi is available."""
1004
+ return self._available
1005
+
1006
+ async def scan(self) -> list[WiFiNetwork]:
1007
+ """Scan for available WiFi networks.
1008
+
1009
+ Returns:
1010
+ List of available WiFi networks, sorted by signal strength.
1011
+
1012
+ Example:
1013
+ ```python
1014
+ networks = await manager.scan()
1015
+ for network in networks:
1016
+ print(
1017
+ f"{network.ssid}: "
1018
+ f"{network.signal_quality}% "
1019
+ f"({network.signal_strength} dBm) "
1020
+ f"[{network.security.value}]"
1021
+ )
1022
+ ```
1023
+ """
1024
+ if not self._available:
1025
+ self.logger.warning("WiFi not available")
1026
+ return []
1027
+
1028
+ networks = await self._backend.scan()
1029
+
1030
+ # Sort by signal strength (strongest first)
1031
+ networks.sort(key=lambda n: n.signal_strength, reverse=True)
1032
+
1033
+ self.logger.debug(f"Scanned {len(networks)} networks")
1034
+ return networks
1035
+
1036
+ async def connect(self, ssid: str, password: str | None = None) -> bool:
1037
+ """Connect to a WiFi network.
1038
+
1039
+ Args:
1040
+ ssid: Network SSID to connect to.
1041
+ password: Network password (None for open networks).
1042
+
1043
+ Returns:
1044
+ True if connection successful, False otherwise.
1045
+
1046
+ Example:
1047
+ ```python
1048
+ # Connect to WPA2 network
1049
+ success = await manager.connect("MyNetwork", "password123")
1050
+
1051
+ # Connect to open network
1052
+ success = await manager.connect("FreeWiFi")
1053
+ ```
1054
+ """
1055
+ if not self._available:
1056
+ self.logger.error("WiFi not available")
1057
+ return False
1058
+
1059
+ self.logger.info(f"Connecting to {ssid}...")
1060
+ return await self._backend.connect(ssid, password)
1061
+
1062
+ async def disconnect(self) -> bool:
1063
+ """Disconnect from WiFi network.
1064
+
1065
+ Returns:
1066
+ True if disconnection successful, False otherwise.
1067
+
1068
+ Example:
1069
+ ```python
1070
+ # Disconnect from current network
1071
+ await manager.disconnect()
1072
+
1073
+ # Disconnect from specific network (Linux)
1074
+ await manager.disconnect("MyNetwork")
1075
+ ```
1076
+ """
1077
+ if not self._available:
1078
+ self.logger.error("WiFi not available")
1079
+ return False
1080
+
1081
+ self.logger.info("Disconnecting from WiFi...")
1082
+ return await self._backend.disconnect()
1083
+
1084
+ async def get_connection_info(self) -> WiFiConnectionInfo | None:
1085
+ """Get current WiFi connection information.
1086
+
1087
+ Returns:
1088
+ Connection info if connected, None otherwise.
1089
+
1090
+ Example:
1091
+ ```python
1092
+ info = await manager.get_connection_info()
1093
+ if info:
1094
+ print(f"SSID: {info.ssid}")
1095
+ print(f"Signal: {info.signal_quality}%")
1096
+ print(f"IP: {info.ip_address}")
1097
+ print(f"Speed: {info.link_speed} Mbps")
1098
+ else:
1099
+ print("Not connected")
1100
+ ```
1101
+ """
1102
+ if not self._available:
1103
+ return None
1104
+
1105
+ return await self._backend.get_connection_info()
1106
+
1107
+ async def is_connected(self) -> bool:
1108
+ """Check if currently connected to any WiFi network.
1109
+
1110
+ Returns:
1111
+ True if connected, False otherwise.
1112
+ """
1113
+ info = await self.get_connection_info()
1114
+ return info is not None and info.status == WiFiStatus.CONNECTED
1115
+
1116
+ async def get_current_ssid(self) -> str | None:
1117
+ """Get SSID of currently connected network.
1118
+
1119
+ Returns:
1120
+ SSID if connected, None otherwise.
1121
+ """
1122
+ info = await self.get_connection_info()
1123
+ return info.ssid if info else None
1124
+
1125
+ async def find_network(self, ssid: str) -> WiFiNetwork | None:
1126
+ """Find a specific network by SSID.
1127
+
1128
+ Args:
1129
+ ssid: Network SSID to find.
1130
+
1131
+ Returns:
1132
+ Network if found, None otherwise.
1133
+
1134
+ Example:
1135
+ ```python
1136
+ network = await manager.find_network("MyNetwork")
1137
+ if network:
1138
+ if network.signal_quality > 70:
1139
+ await manager.connect(network.ssid, "password")
1140
+ ```
1141
+ """
1142
+ networks = await self.scan()
1143
+ for network in networks:
1144
+ if network.ssid == ssid:
1145
+ return network
1146
+ return None