autoglm-gui 1.1.0__py3-none-any.whl → 1.2.1__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.
- AutoGLM_GUI/adb_plus/__init__.py +5 -1
- AutoGLM_GUI/adb_plus/serial.py +61 -2
- AutoGLM_GUI/adb_plus/version.py +81 -0
- AutoGLM_GUI/api/__init__.py +8 -1
- AutoGLM_GUI/api/agents.py +329 -94
- AutoGLM_GUI/api/devices.py +145 -164
- AutoGLM_GUI/api/workflows.py +70 -0
- AutoGLM_GUI/device_manager.py +760 -0
- AutoGLM_GUI/exceptions.py +18 -0
- AutoGLM_GUI/phone_agent_manager.py +549 -0
- AutoGLM_GUI/phone_agent_patches.py +146 -0
- AutoGLM_GUI/schemas.py +310 -2
- AutoGLM_GUI/state.py +21 -0
- AutoGLM_GUI/static/assets/{about-Crpy4Xue.js → about-BtBH1xKN.js} +1 -1
- AutoGLM_GUI/static/assets/chat-DPzFNNGu.js +124 -0
- AutoGLM_GUI/static/assets/dialog-Dwuk2Hgl.js +45 -0
- AutoGLM_GUI/static/assets/index-B_AaKuOT.js +1 -0
- AutoGLM_GUI/static/assets/index-BjYIY--m.css +1 -0
- AutoGLM_GUI/static/assets/index-CvQkCi2d.js +11 -0
- AutoGLM_GUI/static/assets/logo-Cyfm06Ym.png +0 -0
- AutoGLM_GUI/static/assets/workflows-xX_QH-wI.js +1 -0
- AutoGLM_GUI/static/favicon.ico +0 -0
- AutoGLM_GUI/static/index.html +9 -2
- AutoGLM_GUI/static/logo-192.png +0 -0
- AutoGLM_GUI/static/logo-512.png +0 -0
- AutoGLM_GUI/workflow_manager.py +181 -0
- {autoglm_gui-1.1.0.dist-info → autoglm_gui-1.2.1.dist-info}/METADATA +51 -6
- {autoglm_gui-1.1.0.dist-info → autoglm_gui-1.2.1.dist-info}/RECORD +31 -19
- AutoGLM_GUI/static/assets/chat-DGFuSj6_.js +0 -149
- AutoGLM_GUI/static/assets/index-C1k5Ch1V.js +0 -10
- AutoGLM_GUI/static/assets/index-COYnSjzf.js +0 -1
- AutoGLM_GUI/static/assets/index-QX6oy21q.css +0 -1
- {autoglm_gui-1.1.0.dist-info → autoglm_gui-1.2.1.dist-info}/WHEEL +0 -0
- {autoglm_gui-1.1.0.dist-info → autoglm_gui-1.2.1.dist-info}/entry_points.txt +0 -0
- {autoglm_gui-1.1.0.dist-info → autoglm_gui-1.2.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,760 @@
|
|
|
1
|
+
"""Global device manager with background polling and state caching."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from collections import defaultdict
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
from phone_agent.adb.connection import ADBConnection, ConnectionType, DeviceInfo
|
|
13
|
+
|
|
14
|
+
from AutoGLM_GUI.logger import logger
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DeviceState(str, Enum):
|
|
18
|
+
"""Device availability state."""
|
|
19
|
+
|
|
20
|
+
ONLINE = "online" # Device connected and responsive
|
|
21
|
+
OFFLINE = "offline" # Device connected but not responsive
|
|
22
|
+
DISCONNECTED = "disconnected" # Device not in ADB device list
|
|
23
|
+
AVAILABLE_MDNS = "available" # Discovered via mDNS but not connected
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class DeviceConnection:
|
|
28
|
+
"""Single connection method for a device (USB, WiFi, mDNS, etc.)."""
|
|
29
|
+
|
|
30
|
+
device_id: str # USB serial OR IP:port
|
|
31
|
+
connection_type: ConnectionType
|
|
32
|
+
status: str # "device" | "offline" | "unauthorized"
|
|
33
|
+
last_seen: float = field(default_factory=time.time)
|
|
34
|
+
|
|
35
|
+
def priority_score(self) -> int:
|
|
36
|
+
"""Calculate connection priority for sorting.
|
|
37
|
+
|
|
38
|
+
Priority:
|
|
39
|
+
1. Connection type (USB > WiFi/Remote > mDNS)
|
|
40
|
+
2. Status (device > offline > unauthorized)
|
|
41
|
+
"""
|
|
42
|
+
# Type priority (higher is better)
|
|
43
|
+
type_priority = {
|
|
44
|
+
ConnectionType.USB: 300,
|
|
45
|
+
ConnectionType.WIFI: 200,
|
|
46
|
+
ConnectionType.REMOTE: 200,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
# Status priority
|
|
50
|
+
status_priority = {
|
|
51
|
+
"device": 30,
|
|
52
|
+
"offline": 20,
|
|
53
|
+
"unauthorized": 10,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return type_priority.get(self.connection_type, 0) + status_priority.get(
|
|
57
|
+
self.status, 0
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class ManagedDevice:
|
|
63
|
+
"""Device information aggregated by serial (multiple connections supported)."""
|
|
64
|
+
|
|
65
|
+
# Core identity (indexed by serial now)
|
|
66
|
+
serial: str # Hardware serial number (ro.serialno)
|
|
67
|
+
|
|
68
|
+
# Connections (multiple connection methods)
|
|
69
|
+
connections: list[DeviceConnection] = field(default_factory=list)
|
|
70
|
+
primary_connection_idx: int = 0 # Index of primary connection
|
|
71
|
+
|
|
72
|
+
# Device metadata
|
|
73
|
+
model: Optional[str] = None
|
|
74
|
+
|
|
75
|
+
# Device-level state
|
|
76
|
+
state: DeviceState = DeviceState.ONLINE
|
|
77
|
+
|
|
78
|
+
# Timestamps
|
|
79
|
+
first_seen: float = field(default_factory=time.time)
|
|
80
|
+
last_seen: float = field(default_factory=time.time)
|
|
81
|
+
error_count: int = 0 # Consecutive polling errors
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def primary_connection(self) -> DeviceConnection:
|
|
85
|
+
"""Get the primary connection."""
|
|
86
|
+
if not self.connections:
|
|
87
|
+
raise ValueError(f"Device {self.serial} has no connections")
|
|
88
|
+
return self.connections[self.primary_connection_idx]
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def primary_device_id(self) -> str:
|
|
92
|
+
"""Get the device_id of the primary connection (used in API)."""
|
|
93
|
+
return self.primary_connection.device_id
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def status(self) -> str:
|
|
97
|
+
"""Status of primary connection."""
|
|
98
|
+
return self.primary_connection.status
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def connection_type(self) -> ConnectionType:
|
|
102
|
+
"""Type of primary connection."""
|
|
103
|
+
return self.primary_connection.connection_type
|
|
104
|
+
|
|
105
|
+
def select_primary_connection(self) -> None:
|
|
106
|
+
"""Select best connection as primary based on priority."""
|
|
107
|
+
if not self.connections:
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
# Sort by priority (descending)
|
|
111
|
+
sorted_conns = sorted(
|
|
112
|
+
enumerate(self.connections),
|
|
113
|
+
key=lambda x: x[1].priority_score(),
|
|
114
|
+
reverse=True,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
self.primary_connection_idx = sorted_conns[0][0]
|
|
118
|
+
|
|
119
|
+
def to_dict(self) -> dict:
|
|
120
|
+
"""转换为纯设备信息字典(不包含 Agent 状态)。
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
dict: 设备基础信息,匹配 DeviceResponse schema(无 agent 字段)
|
|
124
|
+
"""
|
|
125
|
+
return {
|
|
126
|
+
"id": self.primary_device_id,
|
|
127
|
+
"serial": self.serial,
|
|
128
|
+
"model": self.model or "Unknown",
|
|
129
|
+
"status": self.status,
|
|
130
|
+
"connection_type": self.connection_type.value,
|
|
131
|
+
"state": self.state.value,
|
|
132
|
+
"is_available_only": self.state == DeviceState.AVAILABLE_MDNS,
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# Helper functions
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _is_mdns_connection(device_id: str) -> bool:
|
|
140
|
+
"""Check if device_id is from mDNS discovery."""
|
|
141
|
+
mdns_patterns = [
|
|
142
|
+
"._adb-tls-connect._tcp",
|
|
143
|
+
"._adb-tls-pairing._tcp",
|
|
144
|
+
".local.", # mDNS hostname suffix
|
|
145
|
+
]
|
|
146
|
+
return any(pattern in device_id for pattern in mdns_patterns)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _create_managed_device(
|
|
150
|
+
serial: str, device_infos: list[DeviceInfo]
|
|
151
|
+
) -> ManagedDevice:
|
|
152
|
+
"""Create ManagedDevice from DeviceInfo list."""
|
|
153
|
+
connections = [
|
|
154
|
+
DeviceConnection(
|
|
155
|
+
device_id=d.device_id,
|
|
156
|
+
connection_type=d.connection_type,
|
|
157
|
+
status=d.status,
|
|
158
|
+
last_seen=time.time(),
|
|
159
|
+
)
|
|
160
|
+
for d in device_infos
|
|
161
|
+
]
|
|
162
|
+
|
|
163
|
+
# Extract model (prefer device with model info)
|
|
164
|
+
model = None
|
|
165
|
+
for device_info in device_infos:
|
|
166
|
+
if device_info.model:
|
|
167
|
+
model = device_info.model
|
|
168
|
+
break
|
|
169
|
+
|
|
170
|
+
# Create managed device
|
|
171
|
+
managed = ManagedDevice(
|
|
172
|
+
serial=serial,
|
|
173
|
+
connections=connections,
|
|
174
|
+
model=model,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
# Select primary connection
|
|
178
|
+
managed.select_primary_connection()
|
|
179
|
+
|
|
180
|
+
# Set state
|
|
181
|
+
managed.state = (
|
|
182
|
+
DeviceState.ONLINE if managed.status == "device" else DeviceState.OFFLINE
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
return managed
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class DeviceManager:
|
|
189
|
+
"""Singleton manager for ADB device discovery and state management.
|
|
190
|
+
|
|
191
|
+
Features:
|
|
192
|
+
- Background polling thread (every 10s)
|
|
193
|
+
- Thread-safe device state cache
|
|
194
|
+
- Exponential backoff on ADB failures
|
|
195
|
+
- Integration with existing state.agents
|
|
196
|
+
"""
|
|
197
|
+
|
|
198
|
+
_instance: Optional[DeviceManager] = None
|
|
199
|
+
_lock = threading.Lock()
|
|
200
|
+
|
|
201
|
+
def __init__(self, adb_path: str = "adb"):
|
|
202
|
+
"""Private constructor. Use get_instance() instead."""
|
|
203
|
+
# Device state storage (indexed by serial now)
|
|
204
|
+
self._devices: dict[str, ManagedDevice] = {} # Key: serial
|
|
205
|
+
self._devices_lock = threading.RLock() # Reentrant for nested calls
|
|
206
|
+
|
|
207
|
+
# Reverse mapping for backward compatibility
|
|
208
|
+
self._device_id_to_serial: dict[str, str] = {} # Key: device_id -> serial
|
|
209
|
+
|
|
210
|
+
# Polling thread control
|
|
211
|
+
self._poll_thread: Optional[threading.Thread] = None
|
|
212
|
+
self._stop_event = threading.Event()
|
|
213
|
+
self._poll_interval = 10.0 # seconds
|
|
214
|
+
|
|
215
|
+
# Exponential backoff state
|
|
216
|
+
self._current_interval = 10.0
|
|
217
|
+
self._min_interval = 10.0
|
|
218
|
+
self._max_interval = 60.0
|
|
219
|
+
self._backoff_multiplier = 2.0
|
|
220
|
+
self._consecutive_failures = 0
|
|
221
|
+
|
|
222
|
+
# ADB connection
|
|
223
|
+
self._adb_path = adb_path
|
|
224
|
+
self._adb_conn = ADBConnection(adb_path=adb_path)
|
|
225
|
+
|
|
226
|
+
# mDNS discovery support
|
|
227
|
+
self._mdns_supported: Optional[bool] = None # Lazy check
|
|
228
|
+
self._mdns_devices: dict[str, ManagedDevice] = {} # Key: serial
|
|
229
|
+
self._enable_mdns_discovery: bool = True # Feature toggle
|
|
230
|
+
|
|
231
|
+
@classmethod
|
|
232
|
+
def get_instance(cls, adb_path: str = "adb") -> DeviceManager:
|
|
233
|
+
"""Get singleton instance (thread-safe)."""
|
|
234
|
+
if cls._instance is None:
|
|
235
|
+
with cls._lock:
|
|
236
|
+
if cls._instance is None:
|
|
237
|
+
cls._instance = cls(adb_path=adb_path)
|
|
238
|
+
logger.info("DeviceManager singleton created")
|
|
239
|
+
return cls._instance
|
|
240
|
+
|
|
241
|
+
def start_polling(self) -> None:
|
|
242
|
+
"""Start background polling thread."""
|
|
243
|
+
with self._devices_lock:
|
|
244
|
+
if self._poll_thread and self._poll_thread.is_alive():
|
|
245
|
+
logger.warning("Polling thread already running")
|
|
246
|
+
return
|
|
247
|
+
|
|
248
|
+
self._stop_event.clear()
|
|
249
|
+
self._poll_thread = threading.Thread(
|
|
250
|
+
target=self._polling_loop, name="DeviceManager-Poll", daemon=True
|
|
251
|
+
)
|
|
252
|
+
self._poll_thread.start()
|
|
253
|
+
logger.info(
|
|
254
|
+
f"DeviceManager polling started (interval: {self._poll_interval:.1f}s)"
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
def stop_polling(self) -> None:
|
|
258
|
+
"""Stop background polling thread (graceful shutdown)."""
|
|
259
|
+
if not self._poll_thread:
|
|
260
|
+
return
|
|
261
|
+
|
|
262
|
+
logger.info("Stopping DeviceManager polling...")
|
|
263
|
+
self._stop_event.set()
|
|
264
|
+
|
|
265
|
+
if self._poll_thread.is_alive():
|
|
266
|
+
self._poll_thread.join(timeout=5.0)
|
|
267
|
+
if self._poll_thread.is_alive():
|
|
268
|
+
logger.warning("Polling thread did not stop gracefully")
|
|
269
|
+
else:
|
|
270
|
+
logger.info("DeviceManager polling stopped")
|
|
271
|
+
|
|
272
|
+
def get_devices(self) -> list[ManagedDevice]:
|
|
273
|
+
"""Get all cached devices (connected + available mDNS)."""
|
|
274
|
+
with self._devices_lock:
|
|
275
|
+
# Merge connected and mDNS devices
|
|
276
|
+
all_devices = list(self._devices.values())
|
|
277
|
+
|
|
278
|
+
# Add mDNS devices that aren't already connected
|
|
279
|
+
connected_serials = set(self._devices.keys())
|
|
280
|
+
mdns_only = [
|
|
281
|
+
dev
|
|
282
|
+
for serial, dev in self._mdns_devices.items()
|
|
283
|
+
if serial not in connected_serials
|
|
284
|
+
]
|
|
285
|
+
|
|
286
|
+
all_devices.extend(mdns_only)
|
|
287
|
+
return all_devices
|
|
288
|
+
|
|
289
|
+
def get_device(self, device_id: str) -> Optional[ManagedDevice]:
|
|
290
|
+
"""Get single device info by ID (deprecated, use get_device_by_serial)."""
|
|
291
|
+
# For backward compatibility, try to interpret as serial
|
|
292
|
+
with self._devices_lock:
|
|
293
|
+
return self._devices.get(device_id)
|
|
294
|
+
|
|
295
|
+
def get_device_by_device_id(self, device_id: str) -> Optional[ManagedDevice]:
|
|
296
|
+
"""Get device by any of its connection device_ids (backward compatibility).
|
|
297
|
+
|
|
298
|
+
This method supports looking up devices by either:
|
|
299
|
+
- Serial number (direct lookup)
|
|
300
|
+
- Any device_id from any connection (reverse mapping)
|
|
301
|
+
"""
|
|
302
|
+
with self._devices_lock:
|
|
303
|
+
# First try direct serial lookup (if device_id IS a serial)
|
|
304
|
+
if device_id in self._devices:
|
|
305
|
+
return self._devices[device_id]
|
|
306
|
+
|
|
307
|
+
# Use reverse mapping
|
|
308
|
+
serial = self._device_id_to_serial.get(device_id)
|
|
309
|
+
if serial:
|
|
310
|
+
return self._devices.get(serial)
|
|
311
|
+
|
|
312
|
+
return None
|
|
313
|
+
|
|
314
|
+
def force_refresh(self) -> None:
|
|
315
|
+
"""Trigger immediate device list refresh (blocking)."""
|
|
316
|
+
logger.info("Force refreshing device list...")
|
|
317
|
+
self._poll_devices()
|
|
318
|
+
|
|
319
|
+
# Internal methods
|
|
320
|
+
|
|
321
|
+
def _check_mdns_support(self) -> bool:
|
|
322
|
+
"""
|
|
323
|
+
Check if ADB supports mDNS discovery (lazy initialization).
|
|
324
|
+
|
|
325
|
+
Returns:
|
|
326
|
+
True if supported, False otherwise
|
|
327
|
+
"""
|
|
328
|
+
if self._mdns_supported is None:
|
|
329
|
+
from AutoGLM_GUI.adb_plus.version import supports_mdns_services
|
|
330
|
+
|
|
331
|
+
self._mdns_supported = supports_mdns_services(self._adb_path)
|
|
332
|
+
|
|
333
|
+
if self._mdns_supported:
|
|
334
|
+
logger.info("ADB mDNS discovery is supported")
|
|
335
|
+
else:
|
|
336
|
+
logger.info("ADB mDNS discovery not available (requires ADB 30.0.0+)")
|
|
337
|
+
|
|
338
|
+
return self._mdns_supported
|
|
339
|
+
|
|
340
|
+
def _polling_loop(self) -> None:
|
|
341
|
+
"""Background polling loop (runs in thread)."""
|
|
342
|
+
logger.debug("Polling loop started")
|
|
343
|
+
|
|
344
|
+
while not self._stop_event.is_set():
|
|
345
|
+
try:
|
|
346
|
+
self._poll_devices()
|
|
347
|
+
|
|
348
|
+
# Reset backoff on success
|
|
349
|
+
if self._consecutive_failures > 0:
|
|
350
|
+
logger.info("Polling recovered, resetting backoff")
|
|
351
|
+
self._consecutive_failures = 0
|
|
352
|
+
self._current_interval = self._min_interval
|
|
353
|
+
|
|
354
|
+
except Exception as e:
|
|
355
|
+
self._handle_poll_error(e)
|
|
356
|
+
|
|
357
|
+
# Sleep with interruptible wait
|
|
358
|
+
self._stop_event.wait(timeout=self._current_interval)
|
|
359
|
+
|
|
360
|
+
def _poll_devices(self) -> None:
|
|
361
|
+
"""Poll ADB device list and update cache (serial-based aggregation)."""
|
|
362
|
+
from AutoGLM_GUI.adb_plus import get_device_serial
|
|
363
|
+
|
|
364
|
+
# Step 1: Get ADB devices and fetch serials
|
|
365
|
+
adb_devices = self._adb_conn.list_devices()
|
|
366
|
+
device_with_serials: list[tuple[DeviceInfo, str]] = []
|
|
367
|
+
|
|
368
|
+
for device_info in adb_devices:
|
|
369
|
+
serial = get_device_serial(device_info.device_id, self._adb_path)
|
|
370
|
+
|
|
371
|
+
if not serial:
|
|
372
|
+
# CRITICAL: Log error and skip this device
|
|
373
|
+
logger.error(
|
|
374
|
+
f"Failed to get serial for device {device_info.device_id}. "
|
|
375
|
+
f"Skipping this device. Check ADB access."
|
|
376
|
+
)
|
|
377
|
+
continue
|
|
378
|
+
|
|
379
|
+
device_with_serials.append((device_info, serial))
|
|
380
|
+
|
|
381
|
+
# Step 2: Group devices by serial
|
|
382
|
+
grouped_by_serial: dict[str, list[DeviceInfo]] = defaultdict(list)
|
|
383
|
+
|
|
384
|
+
for device_info, serial in device_with_serials:
|
|
385
|
+
grouped_by_serial[serial].append(device_info)
|
|
386
|
+
|
|
387
|
+
# Step 3: Filter mDNS connections (if other connections exist)
|
|
388
|
+
for serial, device_infos in grouped_by_serial.items():
|
|
389
|
+
filtered = []
|
|
390
|
+
has_non_mdns = False
|
|
391
|
+
|
|
392
|
+
# First pass: check if we have non-mDNS connections
|
|
393
|
+
for device_info in device_infos:
|
|
394
|
+
if not _is_mdns_connection(device_info.device_id):
|
|
395
|
+
has_non_mdns = True
|
|
396
|
+
break
|
|
397
|
+
|
|
398
|
+
# Second pass: filter out mDNS if non-mDNS exists
|
|
399
|
+
for device_info in device_infos:
|
|
400
|
+
if has_non_mdns and _is_mdns_connection(device_info.device_id):
|
|
401
|
+
logger.debug(
|
|
402
|
+
f"Filtering mDNS connection {device_info.device_id} "
|
|
403
|
+
f"(device has clearer connection)"
|
|
404
|
+
)
|
|
405
|
+
continue
|
|
406
|
+
filtered.append(device_info)
|
|
407
|
+
|
|
408
|
+
grouped_by_serial[serial] = filtered
|
|
409
|
+
|
|
410
|
+
# Step 4: Update device cache
|
|
411
|
+
with self._devices_lock:
|
|
412
|
+
current_serials = set(grouped_by_serial.keys())
|
|
413
|
+
previous_serials = set(self._devices.keys())
|
|
414
|
+
|
|
415
|
+
added_serials = current_serials - previous_serials
|
|
416
|
+
removed_serials = previous_serials - current_serials
|
|
417
|
+
existing_serials = current_serials & previous_serials
|
|
418
|
+
|
|
419
|
+
# Add new devices
|
|
420
|
+
for serial in added_serials:
|
|
421
|
+
device_infos = grouped_by_serial[serial]
|
|
422
|
+
managed = _create_managed_device(serial, device_infos)
|
|
423
|
+
self._devices[serial] = managed
|
|
424
|
+
|
|
425
|
+
# Update reverse mapping
|
|
426
|
+
for conn in managed.connections:
|
|
427
|
+
self._device_id_to_serial[conn.device_id] = serial
|
|
428
|
+
|
|
429
|
+
logger.info(
|
|
430
|
+
f"Device added: {serial} ({managed.model or 'Unknown'}) "
|
|
431
|
+
f"via {managed.connection_type.value} ({managed.primary_device_id})"
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
# Update existing devices
|
|
435
|
+
for serial in existing_serials:
|
|
436
|
+
device_infos = grouped_by_serial[serial]
|
|
437
|
+
managed = self._devices[serial]
|
|
438
|
+
|
|
439
|
+
# Rebuild connections
|
|
440
|
+
old_device_ids = {conn.device_id for conn in managed.connections}
|
|
441
|
+
new_connections = [
|
|
442
|
+
DeviceConnection(
|
|
443
|
+
device_id=d.device_id,
|
|
444
|
+
connection_type=d.connection_type,
|
|
445
|
+
status=d.status,
|
|
446
|
+
last_seen=time.time(),
|
|
447
|
+
)
|
|
448
|
+
for d in device_infos
|
|
449
|
+
]
|
|
450
|
+
|
|
451
|
+
managed.connections = new_connections
|
|
452
|
+
managed.last_seen = time.time()
|
|
453
|
+
managed.error_count = 0
|
|
454
|
+
|
|
455
|
+
# Update model if available
|
|
456
|
+
for device_info in device_infos:
|
|
457
|
+
if device_info.model:
|
|
458
|
+
managed.model = device_info.model
|
|
459
|
+
break
|
|
460
|
+
|
|
461
|
+
# Re-select primary connection
|
|
462
|
+
managed.select_primary_connection()
|
|
463
|
+
|
|
464
|
+
# Update state
|
|
465
|
+
managed.state = (
|
|
466
|
+
DeviceState.ONLINE
|
|
467
|
+
if managed.status == "device"
|
|
468
|
+
else DeviceState.OFFLINE
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
# Update reverse mapping
|
|
472
|
+
new_device_ids = {conn.device_id for conn in managed.connections}
|
|
473
|
+
|
|
474
|
+
# Remove stale mappings
|
|
475
|
+
for old_id in old_device_ids - new_device_ids:
|
|
476
|
+
self._device_id_to_serial.pop(old_id, None)
|
|
477
|
+
|
|
478
|
+
# Add new mappings
|
|
479
|
+
for new_id in new_device_ids:
|
|
480
|
+
self._device_id_to_serial[new_id] = serial
|
|
481
|
+
|
|
482
|
+
# Mark removed devices as disconnected
|
|
483
|
+
for serial in removed_serials:
|
|
484
|
+
managed = self._devices[serial]
|
|
485
|
+
managed.state = DeviceState.DISCONNECTED
|
|
486
|
+
managed.last_seen = time.time()
|
|
487
|
+
logger.warning(
|
|
488
|
+
f"Device disconnected: {serial} ({managed.model or 'Unknown'})"
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
# Remove reverse mappings
|
|
492
|
+
for conn in managed.connections:
|
|
493
|
+
self._device_id_to_serial.pop(conn.device_id, None)
|
|
494
|
+
|
|
495
|
+
# Step 5: Discover mDNS devices (if enabled and supported)
|
|
496
|
+
if self._enable_mdns_discovery and self._check_mdns_support():
|
|
497
|
+
from AutoGLM_GUI.adb_plus import (
|
|
498
|
+
discover_mdns_devices,
|
|
499
|
+
extract_serial_from_mdns,
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
try:
|
|
503
|
+
mdns_devices = discover_mdns_devices(self._adb_path)
|
|
504
|
+
|
|
505
|
+
with self._devices_lock:
|
|
506
|
+
connected_serials = set(self._devices.keys())
|
|
507
|
+
|
|
508
|
+
# Process discovered mDNS devices
|
|
509
|
+
for mdns_dev in mdns_devices:
|
|
510
|
+
# Extract serial from mDNS name
|
|
511
|
+
serial = extract_serial_from_mdns(mdns_dev.name)
|
|
512
|
+
|
|
513
|
+
if not serial:
|
|
514
|
+
logger.debug(
|
|
515
|
+
f"Could not extract serial from mDNS device: {mdns_dev.name}"
|
|
516
|
+
)
|
|
517
|
+
continue
|
|
518
|
+
|
|
519
|
+
# Skip if already connected
|
|
520
|
+
if serial in connected_serials:
|
|
521
|
+
logger.debug(
|
|
522
|
+
f"mDNS device {mdns_dev.name} already connected as {serial}"
|
|
523
|
+
)
|
|
524
|
+
continue
|
|
525
|
+
|
|
526
|
+
# Create or update AVAILABLE_MDNS device
|
|
527
|
+
if serial not in self._mdns_devices:
|
|
528
|
+
# Create minimal device info
|
|
529
|
+
available_device = ManagedDevice(
|
|
530
|
+
serial=serial,
|
|
531
|
+
connections=[
|
|
532
|
+
DeviceConnection(
|
|
533
|
+
device_id=f"{mdns_dev.ip}:{mdns_dev.port}",
|
|
534
|
+
connection_type=ConnectionType.REMOTE,
|
|
535
|
+
status="available", # Not connected yet
|
|
536
|
+
last_seen=time.time(),
|
|
537
|
+
)
|
|
538
|
+
],
|
|
539
|
+
state=DeviceState.AVAILABLE_MDNS,
|
|
540
|
+
model=None, # Unknown until connected
|
|
541
|
+
)
|
|
542
|
+
self._mdns_devices[serial] = available_device
|
|
543
|
+
logger.info(
|
|
544
|
+
f"Discovered mDNS device: {mdns_dev.name} at {mdns_dev.ip}:{mdns_dev.port}"
|
|
545
|
+
)
|
|
546
|
+
else:
|
|
547
|
+
# Update last_seen
|
|
548
|
+
self._mdns_devices[serial].last_seen = time.time()
|
|
549
|
+
|
|
550
|
+
# Clean up stale mDNS devices (not seen for 60s)
|
|
551
|
+
current_time = time.time()
|
|
552
|
+
stale_serials = [
|
|
553
|
+
serial
|
|
554
|
+
for serial, dev in self._mdns_devices.items()
|
|
555
|
+
if current_time - dev.last_seen > 60
|
|
556
|
+
]
|
|
557
|
+
for serial in stale_serials:
|
|
558
|
+
del self._mdns_devices[serial]
|
|
559
|
+
logger.debug(f"Removed stale mDNS device: {serial}")
|
|
560
|
+
|
|
561
|
+
except Exception as e:
|
|
562
|
+
logger.debug(f"mDNS discovery failed: {e}")
|
|
563
|
+
|
|
564
|
+
def _handle_poll_error(self, error: Exception) -> None:
|
|
565
|
+
"""Handle polling failure with exponential backoff."""
|
|
566
|
+
self._consecutive_failures += 1
|
|
567
|
+
|
|
568
|
+
# Calculate new interval
|
|
569
|
+
self._current_interval = min(
|
|
570
|
+
self._min_interval * (self._backoff_multiplier**self._consecutive_failures),
|
|
571
|
+
self._max_interval,
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
logger.warning(
|
|
575
|
+
f"Device polling failed (attempt {self._consecutive_failures}): {error}. "
|
|
576
|
+
f"Retrying in {self._current_interval:.1f}s"
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
# WiFi Connection Methods
|
|
580
|
+
|
|
581
|
+
def connect_wifi(
|
|
582
|
+
self, device_id: str, port: int = 5555
|
|
583
|
+
) -> tuple[bool, str, Optional[str]]:
|
|
584
|
+
"""Connect to device over WiFi (from USB connection).
|
|
585
|
+
|
|
586
|
+
Args:
|
|
587
|
+
device_id: Device ID (USB serial or IP:port)
|
|
588
|
+
port: TCP port for WiFi connection (default: 5555)
|
|
589
|
+
|
|
590
|
+
Returns:
|
|
591
|
+
Tuple of (success, message, wifi_device_id)
|
|
592
|
+
"""
|
|
593
|
+
from phone_agent.adb.connection import ADBConnection, ConnectionType
|
|
594
|
+
|
|
595
|
+
from AutoGLM_GUI.adb_plus import get_wifi_ip
|
|
596
|
+
|
|
597
|
+
conn = ADBConnection(adb_path=self._adb_path)
|
|
598
|
+
|
|
599
|
+
# Get device info
|
|
600
|
+
device_info = conn.get_device_info(device_id)
|
|
601
|
+
if not device_info:
|
|
602
|
+
return (False, "No connected device found", None)
|
|
603
|
+
|
|
604
|
+
# Already WiFi connection
|
|
605
|
+
if device_info.connection_type == ConnectionType.REMOTE:
|
|
606
|
+
address = device_info.device_id
|
|
607
|
+
return (True, "Already connected over WiFi", address)
|
|
608
|
+
|
|
609
|
+
# 1) Enable tcpip
|
|
610
|
+
ok, msg = conn.enable_tcpip(port=port, device_id=device_info.device_id)
|
|
611
|
+
if not ok:
|
|
612
|
+
return (False, msg or "Failed to enable tcpip", None)
|
|
613
|
+
|
|
614
|
+
# 2) Get device IP
|
|
615
|
+
ip = get_wifi_ip(conn.adb_path, device_info.device_id) or conn.get_device_ip(
|
|
616
|
+
device_info.device_id
|
|
617
|
+
)
|
|
618
|
+
if not ip:
|
|
619
|
+
return (False, "Failed to get device IP", None)
|
|
620
|
+
|
|
621
|
+
address = f"{ip}:{port}"
|
|
622
|
+
|
|
623
|
+
# 3) Connect WiFi
|
|
624
|
+
ok, msg = conn.connect(address)
|
|
625
|
+
if not ok:
|
|
626
|
+
return (False, msg or "Failed to connect over WiFi", None)
|
|
627
|
+
|
|
628
|
+
logger.info(f"Successfully switched device {device_id} to WiFi: {address}")
|
|
629
|
+
return (True, "Switched to WiFi successfully", address)
|
|
630
|
+
|
|
631
|
+
def disconnect_wifi(self, device_id: str) -> tuple[bool, str]:
|
|
632
|
+
"""Disconnect WiFi connection.
|
|
633
|
+
|
|
634
|
+
Args:
|
|
635
|
+
device_id: Device ID (IP:port)
|
|
636
|
+
|
|
637
|
+
Returns:
|
|
638
|
+
Tuple of (success, message)
|
|
639
|
+
"""
|
|
640
|
+
from phone_agent.adb.connection import ADBConnection
|
|
641
|
+
|
|
642
|
+
conn = ADBConnection(adb_path=self._adb_path)
|
|
643
|
+
ok, msg = conn.disconnect(device_id)
|
|
644
|
+
|
|
645
|
+
if ok:
|
|
646
|
+
logger.info(f"Successfully disconnected WiFi device: {device_id}")
|
|
647
|
+
else:
|
|
648
|
+
logger.warning(f"Failed to disconnect WiFi device {device_id}: {msg}")
|
|
649
|
+
|
|
650
|
+
return (ok, msg)
|
|
651
|
+
|
|
652
|
+
def connect_wifi_manual(
|
|
653
|
+
self, ip: str, port: int
|
|
654
|
+
) -> tuple[bool, str, Optional[str]]:
|
|
655
|
+
"""Manually connect to WiFi device (without USB).
|
|
656
|
+
|
|
657
|
+
Args:
|
|
658
|
+
ip: Device IP address
|
|
659
|
+
port: TCP port (1-65535)
|
|
660
|
+
|
|
661
|
+
Returns:
|
|
662
|
+
Tuple of (success, message, device_id)
|
|
663
|
+
"""
|
|
664
|
+
import re
|
|
665
|
+
|
|
666
|
+
from phone_agent.adb.connection import ADBConnection
|
|
667
|
+
|
|
668
|
+
# IP format validation
|
|
669
|
+
ip_pattern = r"^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$"
|
|
670
|
+
if not re.match(ip_pattern, ip):
|
|
671
|
+
return (False, "Invalid IP address format", None)
|
|
672
|
+
|
|
673
|
+
# Port range validation
|
|
674
|
+
if not (1 <= port <= 65535):
|
|
675
|
+
return (False, "Port must be between 1 and 65535", None)
|
|
676
|
+
|
|
677
|
+
conn = ADBConnection(adb_path=self._adb_path)
|
|
678
|
+
address = f"{ip}:{port}"
|
|
679
|
+
|
|
680
|
+
# Direct connect
|
|
681
|
+
ok, msg = conn.connect(address)
|
|
682
|
+
if not ok:
|
|
683
|
+
return (False, msg or f"Failed to connect to {address}", None)
|
|
684
|
+
|
|
685
|
+
logger.info(f"Successfully connected to WiFi device manually: {address}")
|
|
686
|
+
return (True, f"Successfully connected to {address}", address)
|
|
687
|
+
|
|
688
|
+
def pair_wifi(
|
|
689
|
+
self, ip: str, pairing_port: int, pairing_code: str, connection_port: int
|
|
690
|
+
) -> tuple[bool, str, Optional[str]]:
|
|
691
|
+
"""Pair and connect to WiFi device using wireless debugging (Android 11+).
|
|
692
|
+
|
|
693
|
+
Args:
|
|
694
|
+
ip: Device IP address
|
|
695
|
+
pairing_port: Wireless debugging pairing port (1-65535)
|
|
696
|
+
pairing_code: 6-digit pairing code
|
|
697
|
+
connection_port: Wireless debugging connection port (1-65535)
|
|
698
|
+
|
|
699
|
+
Returns:
|
|
700
|
+
Tuple of (success, message, device_id)
|
|
701
|
+
"""
|
|
702
|
+
import re
|
|
703
|
+
|
|
704
|
+
from phone_agent.adb.connection import ADBConnection
|
|
705
|
+
|
|
706
|
+
from AutoGLM_GUI.adb_plus import pair_device
|
|
707
|
+
|
|
708
|
+
# IP format validation
|
|
709
|
+
ip_pattern = r"^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$"
|
|
710
|
+
if not re.match(ip_pattern, ip):
|
|
711
|
+
return (False, "Invalid IP address format", None)
|
|
712
|
+
|
|
713
|
+
# Pairing port validation
|
|
714
|
+
if not (1 <= pairing_port <= 65535):
|
|
715
|
+
return (False, "Pairing port must be between 1 and 65535", None)
|
|
716
|
+
|
|
717
|
+
# Connection port validation
|
|
718
|
+
if not (1 <= connection_port <= 65535):
|
|
719
|
+
return (False, "Connection port must be between 1 and 65535", None)
|
|
720
|
+
|
|
721
|
+
# Pairing code validation (6 digits)
|
|
722
|
+
if not pairing_code.isdigit() or len(pairing_code) != 6:
|
|
723
|
+
return (False, "Pairing code must be 6 digits", None)
|
|
724
|
+
|
|
725
|
+
conn = ADBConnection(adb_path=self._adb_path)
|
|
726
|
+
|
|
727
|
+
# Step 1: Pair device
|
|
728
|
+
ok, msg = pair_device(
|
|
729
|
+
ip=ip,
|
|
730
|
+
port=pairing_port,
|
|
731
|
+
pairing_code=pairing_code,
|
|
732
|
+
adb_path=conn.adb_path,
|
|
733
|
+
)
|
|
734
|
+
|
|
735
|
+
if not ok:
|
|
736
|
+
logger.warning(f"Failed to pair WiFi device {ip}:{pairing_port}: {msg}")
|
|
737
|
+
return (False, msg, None)
|
|
738
|
+
|
|
739
|
+
# Step 2: Connect to device
|
|
740
|
+
connection_address = f"{ip}:{connection_port}"
|
|
741
|
+
ok, connect_msg = conn.connect(connection_address)
|
|
742
|
+
|
|
743
|
+
if not ok:
|
|
744
|
+
logger.warning(
|
|
745
|
+
f"Paired successfully but connection failed to {connection_address}: {connect_msg}"
|
|
746
|
+
)
|
|
747
|
+
return (
|
|
748
|
+
False,
|
|
749
|
+
f"Paired successfully but connection failed: {connect_msg}",
|
|
750
|
+
None,
|
|
751
|
+
)
|
|
752
|
+
|
|
753
|
+
logger.info(
|
|
754
|
+
f"Successfully paired and connected to WiFi device: {connection_address}"
|
|
755
|
+
)
|
|
756
|
+
return (
|
|
757
|
+
True,
|
|
758
|
+
f"Successfully paired and connected to {connection_address}",
|
|
759
|
+
connection_address,
|
|
760
|
+
)
|