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.
- audex/__init__.py +9 -0
- audex/__main__.py +7 -0
- audex/cli/__init__.py +189 -0
- audex/cli/apis/__init__.py +12 -0
- audex/cli/apis/init/__init__.py +34 -0
- audex/cli/apis/init/gencfg.py +130 -0
- audex/cli/apis/init/setup.py +330 -0
- audex/cli/apis/init/vprgroup.py +125 -0
- audex/cli/apis/serve.py +141 -0
- audex/cli/args.py +356 -0
- audex/cli/exceptions.py +44 -0
- audex/cli/helper/__init__.py +0 -0
- audex/cli/helper/ansi.py +193 -0
- audex/cli/helper/display.py +288 -0
- audex/config/__init__.py +64 -0
- audex/config/core/__init__.py +30 -0
- audex/config/core/app.py +29 -0
- audex/config/core/audio.py +45 -0
- audex/config/core/logging.py +163 -0
- audex/config/core/session.py +11 -0
- audex/config/helper/__init__.py +1 -0
- audex/config/helper/client/__init__.py +1 -0
- audex/config/helper/client/http.py +28 -0
- audex/config/helper/client/websocket.py +21 -0
- audex/config/helper/provider/__init__.py +1 -0
- audex/config/helper/provider/dashscope.py +13 -0
- audex/config/helper/provider/unisound.py +18 -0
- audex/config/helper/provider/xfyun.py +23 -0
- audex/config/infrastructure/__init__.py +31 -0
- audex/config/infrastructure/cache.py +51 -0
- audex/config/infrastructure/database.py +48 -0
- audex/config/infrastructure/recorder.py +32 -0
- audex/config/infrastructure/store.py +19 -0
- audex/config/provider/__init__.py +18 -0
- audex/config/provider/transcription.py +109 -0
- audex/config/provider/vpr.py +99 -0
- audex/container.py +40 -0
- audex/entity/__init__.py +468 -0
- audex/entity/doctor.py +109 -0
- audex/entity/doctor.pyi +51 -0
- audex/entity/fields.py +401 -0
- audex/entity/segment.py +115 -0
- audex/entity/segment.pyi +38 -0
- audex/entity/session.py +133 -0
- audex/entity/session.pyi +47 -0
- audex/entity/utterance.py +142 -0
- audex/entity/utterance.pyi +48 -0
- audex/entity/vp.py +68 -0
- audex/entity/vp.pyi +35 -0
- audex/exceptions.py +157 -0
- audex/filters/__init__.py +692 -0
- audex/filters/generated/__init__.py +21 -0
- audex/filters/generated/doctor.py +987 -0
- audex/filters/generated/segment.py +723 -0
- audex/filters/generated/session.py +978 -0
- audex/filters/generated/utterance.py +939 -0
- audex/filters/generated/vp.py +815 -0
- audex/helper/__init__.py +1 -0
- audex/helper/hash.py +33 -0
- audex/helper/mixin.py +65 -0
- audex/helper/net.py +19 -0
- audex/helper/settings/__init__.py +830 -0
- audex/helper/settings/fields.py +317 -0
- audex/helper/stream.py +153 -0
- audex/injectors/__init__.py +1 -0
- audex/injectors/config.py +12 -0
- audex/injectors/lifespan.py +7 -0
- audex/lib/__init__.py +1 -0
- audex/lib/cache/__init__.py +383 -0
- audex/lib/cache/inmemory.py +513 -0
- audex/lib/database/__init__.py +83 -0
- audex/lib/database/sqlite.py +406 -0
- audex/lib/exporter.py +189 -0
- audex/lib/injectors/__init__.py +1 -0
- audex/lib/injectors/cache.py +25 -0
- audex/lib/injectors/container.py +47 -0
- audex/lib/injectors/exporter.py +26 -0
- audex/lib/injectors/recorder.py +33 -0
- audex/lib/injectors/server.py +17 -0
- audex/lib/injectors/session.py +18 -0
- audex/lib/injectors/sqlite.py +24 -0
- audex/lib/injectors/store.py +13 -0
- audex/lib/injectors/transcription.py +42 -0
- audex/lib/injectors/usb.py +12 -0
- audex/lib/injectors/vpr.py +65 -0
- audex/lib/injectors/wifi.py +7 -0
- audex/lib/recorder.py +844 -0
- audex/lib/repos/__init__.py +149 -0
- audex/lib/repos/container.py +23 -0
- audex/lib/repos/database/__init__.py +1 -0
- audex/lib/repos/database/sqlite.py +672 -0
- audex/lib/repos/decorators.py +74 -0
- audex/lib/repos/doctor.py +286 -0
- audex/lib/repos/segment.py +302 -0
- audex/lib/repos/session.py +285 -0
- audex/lib/repos/tables/__init__.py +70 -0
- audex/lib/repos/tables/doctor.py +137 -0
- audex/lib/repos/tables/segment.py +113 -0
- audex/lib/repos/tables/session.py +140 -0
- audex/lib/repos/tables/utterance.py +131 -0
- audex/lib/repos/tables/vp.py +102 -0
- audex/lib/repos/utterance.py +288 -0
- audex/lib/repos/vp.py +286 -0
- audex/lib/restful.py +251 -0
- audex/lib/server/__init__.py +97 -0
- audex/lib/server/auth.py +98 -0
- audex/lib/server/handlers.py +248 -0
- audex/lib/server/templates/index.html.j2 +226 -0
- audex/lib/server/templates/login.html.j2 +111 -0
- audex/lib/server/templates/static/script.js +68 -0
- audex/lib/server/templates/static/style.css +579 -0
- audex/lib/server/types.py +123 -0
- audex/lib/session.py +503 -0
- audex/lib/store/__init__.py +238 -0
- audex/lib/store/localfile.py +411 -0
- audex/lib/transcription/__init__.py +33 -0
- audex/lib/transcription/dashscope.py +525 -0
- audex/lib/transcription/events.py +62 -0
- audex/lib/usb.py +554 -0
- audex/lib/vpr/__init__.py +38 -0
- audex/lib/vpr/unisound/__init__.py +185 -0
- audex/lib/vpr/unisound/types.py +469 -0
- audex/lib/vpr/xfyun/__init__.py +483 -0
- audex/lib/vpr/xfyun/types.py +679 -0
- audex/lib/websocket/__init__.py +8 -0
- audex/lib/websocket/connection.py +485 -0
- audex/lib/websocket/pool.py +991 -0
- audex/lib/wifi.py +1146 -0
- audex/lifespan.py +75 -0
- audex/service/__init__.py +27 -0
- audex/service/decorators.py +73 -0
- audex/service/doctor/__init__.py +652 -0
- audex/service/doctor/const.py +36 -0
- audex/service/doctor/exceptions.py +96 -0
- audex/service/doctor/types.py +54 -0
- audex/service/export/__init__.py +236 -0
- audex/service/export/const.py +17 -0
- audex/service/export/exceptions.py +34 -0
- audex/service/export/types.py +21 -0
- audex/service/injectors/__init__.py +1 -0
- audex/service/injectors/container.py +53 -0
- audex/service/injectors/doctor.py +34 -0
- audex/service/injectors/export.py +27 -0
- audex/service/injectors/session.py +49 -0
- audex/service/session/__init__.py +754 -0
- audex/service/session/const.py +34 -0
- audex/service/session/exceptions.py +67 -0
- audex/service/session/types.py +91 -0
- audex/types.py +39 -0
- audex/utils.py +287 -0
- audex/valueobj/__init__.py +81 -0
- audex/valueobj/common/__init__.py +1 -0
- audex/valueobj/common/auth.py +84 -0
- audex/valueobj/common/email.py +16 -0
- audex/valueobj/common/ops.py +22 -0
- audex/valueobj/common/phone.py +84 -0
- audex/valueobj/common/version.py +72 -0
- audex/valueobj/session.py +19 -0
- audex/valueobj/utterance.py +15 -0
- audex/view/__init__.py +51 -0
- audex/view/container.py +17 -0
- audex/view/decorators.py +303 -0
- audex/view/pages/__init__.py +1 -0
- audex/view/pages/dashboard/__init__.py +286 -0
- audex/view/pages/dashboard/wifi.py +407 -0
- audex/view/pages/login.py +110 -0
- audex/view/pages/recording.py +348 -0
- audex/view/pages/register.py +202 -0
- audex/view/pages/sessions/__init__.py +196 -0
- audex/view/pages/sessions/details.py +224 -0
- audex/view/pages/sessions/export.py +443 -0
- audex/view/pages/settings.py +374 -0
- audex/view/pages/voiceprint/__init__.py +1 -0
- audex/view/pages/voiceprint/enroll.py +195 -0
- audex/view/pages/voiceprint/update.py +195 -0
- audex/view/static/css/dashboard.css +452 -0
- audex/view/static/css/glass.css +22 -0
- audex/view/static/css/global.css +541 -0
- audex/view/static/css/login.css +386 -0
- audex/view/static/css/recording.css +439 -0
- audex/view/static/css/register.css +293 -0
- audex/view/static/css/sessions/styles.css +501 -0
- audex/view/static/css/settings.css +186 -0
- audex/view/static/css/voiceprint/enroll.css +43 -0
- audex/view/static/css/voiceprint/styles.css +209 -0
- audex/view/static/css/voiceprint/update.css +44 -0
- audex/view/static/images/logo.svg +95 -0
- audex/view/static/js/recording.js +42 -0
- audex-1.0.7a3.dist-info/METADATA +361 -0
- audex-1.0.7a3.dist-info/RECORD +192 -0
- audex-1.0.7a3.dist-info/WHEEL +4 -0
- 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
|