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.
Files changed (35) hide show
  1. AutoGLM_GUI/adb_plus/__init__.py +5 -1
  2. AutoGLM_GUI/adb_plus/serial.py +61 -2
  3. AutoGLM_GUI/adb_plus/version.py +81 -0
  4. AutoGLM_GUI/api/__init__.py +8 -1
  5. AutoGLM_GUI/api/agents.py +329 -94
  6. AutoGLM_GUI/api/devices.py +145 -164
  7. AutoGLM_GUI/api/workflows.py +70 -0
  8. AutoGLM_GUI/device_manager.py +760 -0
  9. AutoGLM_GUI/exceptions.py +18 -0
  10. AutoGLM_GUI/phone_agent_manager.py +549 -0
  11. AutoGLM_GUI/phone_agent_patches.py +146 -0
  12. AutoGLM_GUI/schemas.py +310 -2
  13. AutoGLM_GUI/state.py +21 -0
  14. AutoGLM_GUI/static/assets/{about-Crpy4Xue.js → about-BtBH1xKN.js} +1 -1
  15. AutoGLM_GUI/static/assets/chat-DPzFNNGu.js +124 -0
  16. AutoGLM_GUI/static/assets/dialog-Dwuk2Hgl.js +45 -0
  17. AutoGLM_GUI/static/assets/index-B_AaKuOT.js +1 -0
  18. AutoGLM_GUI/static/assets/index-BjYIY--m.css +1 -0
  19. AutoGLM_GUI/static/assets/index-CvQkCi2d.js +11 -0
  20. AutoGLM_GUI/static/assets/logo-Cyfm06Ym.png +0 -0
  21. AutoGLM_GUI/static/assets/workflows-xX_QH-wI.js +1 -0
  22. AutoGLM_GUI/static/favicon.ico +0 -0
  23. AutoGLM_GUI/static/index.html +9 -2
  24. AutoGLM_GUI/static/logo-192.png +0 -0
  25. AutoGLM_GUI/static/logo-512.png +0 -0
  26. AutoGLM_GUI/workflow_manager.py +181 -0
  27. {autoglm_gui-1.1.0.dist-info → autoglm_gui-1.2.1.dist-info}/METADATA +51 -6
  28. {autoglm_gui-1.1.0.dist-info → autoglm_gui-1.2.1.dist-info}/RECORD +31 -19
  29. AutoGLM_GUI/static/assets/chat-DGFuSj6_.js +0 -149
  30. AutoGLM_GUI/static/assets/index-C1k5Ch1V.js +0 -10
  31. AutoGLM_GUI/static/assets/index-COYnSjzf.js +0 -1
  32. AutoGLM_GUI/static/assets/index-QX6oy21q.css +0 -1
  33. {autoglm_gui-1.1.0.dist-info → autoglm_gui-1.2.1.dist-info}/WHEEL +0 -0
  34. {autoglm_gui-1.1.0.dist-info → autoglm_gui-1.2.1.dist-info}/entry_points.txt +0 -0
  35. {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
+ )