autoglm-gui 0.4.9__py3-none-any.whl → 0.4.12__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 (37) hide show
  1. AutoGLM_GUI/__init__.py +8 -0
  2. AutoGLM_GUI/__main__.py +64 -21
  3. AutoGLM_GUI/adb_plus/__init__.py +8 -0
  4. AutoGLM_GUI/adb_plus/device.py +50 -0
  5. AutoGLM_GUI/adb_plus/ip.py +78 -0
  6. AutoGLM_GUI/adb_plus/keyboard_installer.py +380 -0
  7. AutoGLM_GUI/adb_plus/serial.py +35 -0
  8. AutoGLM_GUI/api/__init__.py +8 -0
  9. AutoGLM_GUI/api/agents.py +132 -1
  10. AutoGLM_GUI/api/devices.py +96 -6
  11. AutoGLM_GUI/api/media.py +13 -243
  12. AutoGLM_GUI/config_manager.py +565 -0
  13. AutoGLM_GUI/exceptions.py +7 -0
  14. AutoGLM_GUI/logger.py +85 -0
  15. AutoGLM_GUI/platform_utils.py +30 -5
  16. AutoGLM_GUI/schemas.py +50 -0
  17. AutoGLM_GUI/scrcpy_protocol.py +46 -0
  18. AutoGLM_GUI/scrcpy_stream.py +208 -327
  19. AutoGLM_GUI/server.py +7 -2
  20. AutoGLM_GUI/socketio_server.py +125 -0
  21. AutoGLM_GUI/state.py +2 -1
  22. AutoGLM_GUI/static/assets/{about-BI6OV6gm.js → about-kgOkkOWe.js} +1 -1
  23. AutoGLM_GUI/static/assets/chat-CZV3RByK.js +149 -0
  24. AutoGLM_GUI/static/assets/{index-Do7ha9Kf.js → index-BPYHsweG.js} +1 -1
  25. AutoGLM_GUI/static/assets/index-Beu9cbSy.css +1 -0
  26. AutoGLM_GUI/static/assets/index-DfI_Z1Cx.js +10 -0
  27. AutoGLM_GUI/static/assets/worker-D6BRitjy.js +1 -0
  28. AutoGLM_GUI/static/index.html +2 -2
  29. {autoglm_gui-0.4.9.dist-info → autoglm_gui-0.4.12.dist-info}/METADATA +15 -2
  30. autoglm_gui-0.4.12.dist-info/RECORD +56 -0
  31. AutoGLM_GUI/static/assets/chat-C_2Cot0q.js +0 -25
  32. AutoGLM_GUI/static/assets/index-DCrxTz-A.css +0 -1
  33. AutoGLM_GUI/static/assets/index-Dn3vR6uV.js +0 -10
  34. autoglm_gui-0.4.9.dist-info/RECORD +0 -46
  35. {autoglm_gui-0.4.9.dist-info → autoglm_gui-0.4.12.dist-info}/WHEEL +0 -0
  36. {autoglm_gui-0.4.9.dist-info → autoglm_gui-0.4.12.dist-info}/entry_points.txt +0 -0
  37. {autoglm_gui-0.4.9.dist-info → autoglm_gui-0.4.12.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,35 @@
1
+ """Get device serial number using ADB."""
2
+
3
+ from AutoGLM_GUI.platform_utils import run_cmd_silently_sync
4
+
5
+
6
+ def get_device_serial(device_id: str, adb_path: str = "adb") -> str | None:
7
+ """
8
+ Get the real hardware serial number of a device.
9
+
10
+ This works for both USB and WiFi connected devices,
11
+ returning the actual hardware serial number (ro.serialno).
12
+
13
+ Args:
14
+ device_id: The device ID (can be USB serial or IP:port for WiFi)
15
+ adb_path: Path to adb executable (default: "adb")
16
+
17
+ Returns:
18
+ The device hardware serial number, or None if failed
19
+ """
20
+ try:
21
+ # Use getprop to get the actual hardware serial number
22
+ # This works for both USB and WiFi connections
23
+ result = run_cmd_silently_sync(
24
+ [adb_path, "-s", device_id, "shell", "getprop", "ro.serialno"],
25
+ timeout=3,
26
+ )
27
+ if result.returncode == 0:
28
+ serial = result.stdout.strip()
29
+ # Filter out error messages and empty values
30
+ if serial and not serial.startswith("error:") and serial != "unknown":
31
+ return serial
32
+ except Exception:
33
+ pass
34
+
35
+ return None
@@ -1,5 +1,6 @@
1
1
  """FastAPI application factory and route registration."""
2
2
 
3
+ import sys
3
4
  from importlib.resources import files
4
5
  from pathlib import Path
5
6
 
@@ -15,6 +16,13 @@ from . import agents, control, devices, media
15
16
 
16
17
  def _get_static_dir() -> Path | None:
17
18
  """Locate packaged static assets."""
19
+ # Priority 1: PyInstaller bundled path (for packaged executable)
20
+ if getattr(sys, "_MEIPASS", None):
21
+ bundled_static = Path(sys._MEIPASS) / "AutoGLM_GUI" / "static"
22
+ if bundled_static.exists():
23
+ return bundled_static
24
+
25
+ # Priority 2: importlib.resources (for installed package)
18
26
  try:
19
27
  static_dir = files("AutoGLM_GUI").joinpath("static")
20
28
  if hasattr(static_dir, "_path"):
AutoGLM_GUI/api/agents.py CHANGED
@@ -4,6 +4,7 @@ import json
4
4
 
5
5
  from fastapi import APIRouter, HTTPException
6
6
  from fastapi.responses import StreamingResponse
7
+ from pydantic import ValidationError
7
8
 
8
9
  from AutoGLM_GUI.config import config
9
10
  from AutoGLM_GUI.schemas import (
@@ -11,6 +12,8 @@ from AutoGLM_GUI.schemas import (
11
12
  APIModelConfig,
12
13
  ChatRequest,
13
14
  ChatResponse,
15
+ ConfigResponse,
16
+ ConfigSaveRequest,
14
17
  InitRequest,
15
18
  ResetRequest,
16
19
  StatusResponse,
@@ -31,6 +34,10 @@ router = APIRouter()
31
34
  @router.post("/api/init")
32
35
  def init_agent(request: InitRequest) -> dict:
33
36
  """初始化 PhoneAgent(多设备支持)。"""
37
+ from AutoGLM_GUI.adb_plus import ADBKeyboardInstaller
38
+ from AutoGLM_GUI.config_manager import config_manager
39
+ from AutoGLM_GUI.logger import logger
40
+
34
41
  req_model_config = request.model or APIModelConfig()
35
42
  req_agent_config = request.agent or APIAgentConfig()
36
43
 
@@ -39,15 +46,35 @@ def init_agent(request: InitRequest) -> dict:
39
46
  raise HTTPException(
40
47
  status_code=400, detail="device_id is required in agent_config"
41
48
  )
49
+
50
+ # 热重载配置文件(支持运行时手动修改)
51
+ config_manager.load_file_config()
52
+ config_manager.sync_to_env()
42
53
  config.refresh_from_env()
43
54
 
55
+ # 检查并自动安装 ADB Keyboard
56
+ logger.info(f"Checking ADB Keyboard for device {device_id}...")
57
+ installer = ADBKeyboardInstaller(device_id=device_id)
58
+ status = installer.get_status()
59
+
60
+ if not (status["installed"] and status["enabled"]):
61
+ logger.info(f"Setting up ADB Keyboard for device {device_id}...")
62
+ success, message = installer.auto_setup()
63
+ if success:
64
+ logger.info(f"✓ Device {device_id}: {message}")
65
+ else:
66
+ logger.warning(f"✗ Device {device_id}: {message}")
67
+ else:
68
+ logger.info(f"✓ Device {device_id}: ADB Keyboard ready")
69
+
44
70
  base_url = req_model_config.base_url or config.base_url
45
71
  api_key = req_model_config.api_key or config.api_key
46
72
  model_name = req_model_config.model_name or config.model_name
47
73
 
48
74
  if not base_url:
49
75
  raise HTTPException(
50
- status_code=400, detail="base_url is required (in model_config or env)"
76
+ status_code=400,
77
+ detail="base_url is required. Please configure via Settings or start with --base-url",
51
78
  )
52
79
 
53
80
  model_config = ModelConfig(
@@ -228,3 +255,107 @@ def reset_agent(request: ResetRequest) -> dict:
228
255
  "device_id": device_id,
229
256
  "message": f"Agent reset for device {device_id}",
230
257
  }
258
+
259
+
260
+ @router.get("/api/config", response_model=ConfigResponse)
261
+ def get_config_endpoint() -> ConfigResponse:
262
+ """获取当前有效配置."""
263
+ from AutoGLM_GUI.config_manager import config_manager
264
+
265
+ # 热重载:检查文件是否被外部修改
266
+ config_manager.load_file_config()
267
+
268
+ # 获取有效配置和来源
269
+ effective_config = config_manager.get_effective_config()
270
+ source = config_manager.get_config_source()
271
+
272
+ # 检测冲突
273
+ conflicts = config_manager.detect_conflicts()
274
+
275
+ return ConfigResponse(
276
+ base_url=effective_config.base_url,
277
+ model_name=effective_config.model_name,
278
+ api_key=effective_config.api_key if effective_config.api_key != "EMPTY" else "",
279
+ source=source.value,
280
+ conflicts=[
281
+ {
282
+ "field": c.field,
283
+ "file_value": c.file_value,
284
+ "override_value": c.override_value,
285
+ "override_source": c.override_source.value,
286
+ }
287
+ for c in conflicts
288
+ ]
289
+ if conflicts
290
+ else None,
291
+ )
292
+
293
+
294
+ @router.post("/api/config")
295
+ def save_config_endpoint(request: ConfigSaveRequest) -> dict:
296
+ """保存配置到文件."""
297
+ from AutoGLM_GUI.config_manager import ConfigModel, config_manager
298
+
299
+ try:
300
+ # Validate incoming configuration to avoid silently falling back to defaults
301
+ ConfigModel(
302
+ base_url=request.base_url,
303
+ model_name=request.model_name,
304
+ api_key=request.api_key or "EMPTY",
305
+ )
306
+
307
+ # 保存配置(合并模式,不丢失字段)
308
+ success = config_manager.save_file_config(
309
+ base_url=request.base_url,
310
+ model_name=request.model_name,
311
+ api_key=request.api_key,
312
+ merge_mode=True,
313
+ )
314
+
315
+ if not success:
316
+ raise HTTPException(status_code=500, detail="Failed to save config")
317
+
318
+ # 同步到环境变量
319
+ config_manager.sync_to_env()
320
+ config.refresh_from_env()
321
+
322
+ # 检测冲突并返回警告
323
+ conflicts = config_manager.detect_conflicts()
324
+
325
+ if conflicts:
326
+ warnings = [
327
+ f"{c.field}: file value overridden by {c.override_source.value}"
328
+ for c in conflicts
329
+ ]
330
+ return {
331
+ "success": True,
332
+ "message": f"Configuration saved to {config_manager.get_config_path()}",
333
+ "warnings": warnings,
334
+ }
335
+
336
+ return {
337
+ "success": True,
338
+ "message": f"Configuration saved to {config_manager.get_config_path()}",
339
+ }
340
+
341
+ except ValidationError as e:
342
+ raise HTTPException(status_code=400, detail=f"Invalid configuration: {e}")
343
+ except Exception as e:
344
+ raise HTTPException(status_code=500, detail=str(e))
345
+
346
+
347
+ @router.delete("/api/config")
348
+ def delete_config_endpoint() -> dict:
349
+ """删除配置文件."""
350
+ from AutoGLM_GUI.config_manager import config_manager
351
+
352
+ try:
353
+ success = config_manager.delete_file_config()
354
+
355
+ if not success:
356
+ raise HTTPException(status_code=500, detail="Failed to delete config")
357
+
358
+ return {"success": True, "message": "Configuration deleted"}
359
+
360
+ except Exception as e:
361
+ raise HTTPException(status_code=500, detail=str(e))
@@ -2,7 +2,15 @@
2
2
 
3
3
  from fastapi import APIRouter
4
4
 
5
- from AutoGLM_GUI.schemas import DeviceListResponse
5
+ from AutoGLM_GUI.adb_plus import get_wifi_ip, get_device_serial
6
+
7
+ from AutoGLM_GUI.schemas import (
8
+ DeviceListResponse,
9
+ WiFiConnectRequest,
10
+ WiFiConnectResponse,
11
+ WiFiDisconnectRequest,
12
+ WiFiDisconnectResponse,
13
+ )
6
14
  from AutoGLM_GUI.state import agents
7
15
 
8
16
  router = APIRouter()
@@ -11,19 +19,101 @@ router = APIRouter()
11
19
  @router.get("/api/devices", response_model=DeviceListResponse)
12
20
  def list_devices() -> DeviceListResponse:
13
21
  """列出所有 ADB 设备。"""
14
- from phone_agent.adb import list_devices as adb_list
22
+ from phone_agent.adb import list_devices as adb_list, ADBConnection
15
23
 
16
24
  adb_devices = adb_list()
25
+ conn = ADBConnection()
26
+
27
+ devices_with_serial = []
28
+ for d in adb_devices:
29
+ # 使用 adb_plus 的 get_device_serial 获取真实序列号
30
+ serial = get_device_serial(d.device_id, conn.adb_path)
17
31
 
18
- return DeviceListResponse(
19
- devices=[
32
+ devices_with_serial.append(
20
33
  {
21
34
  "id": d.device_id,
22
35
  "model": d.model or "Unknown",
23
36
  "status": d.status,
24
37
  "connection_type": d.connection_type.value,
25
38
  "is_initialized": d.device_id in agents,
39
+ "serial": serial, # 真实序列号
26
40
  }
27
- for d in adb_devices
28
- ]
41
+ )
42
+
43
+ return DeviceListResponse(devices=devices_with_serial)
44
+
45
+
46
+ @router.post("/api/devices/connect_wifi", response_model=WiFiConnectResponse)
47
+ def connect_wifi(request: WiFiConnectRequest) -> WiFiConnectResponse:
48
+ """从 USB 启用 TCP/IP 并连接到 WiFi。"""
49
+ from phone_agent.adb import ADBConnection, ConnectionType
50
+
51
+ conn = ADBConnection()
52
+
53
+ # 优先使用传入的 device_id,否则取第一个在线设备
54
+ device_info = conn.get_device_info(request.device_id)
55
+ if not device_info:
56
+ return WiFiConnectResponse(
57
+ success=False,
58
+ message="No connected device found",
59
+ error="device_not_found",
60
+ )
61
+
62
+ # 已经是 WiFi 连接则直接返回
63
+ if device_info.connection_type == ConnectionType.REMOTE:
64
+ address = device_info.device_id
65
+ return WiFiConnectResponse(
66
+ success=True,
67
+ message="Already connected over WiFi",
68
+ device_id=address,
69
+ address=address,
70
+ )
71
+
72
+ # 1) 启用 tcpip
73
+ ok, msg = conn.enable_tcpip(port=request.port, device_id=device_info.device_id)
74
+ if not ok:
75
+ return WiFiConnectResponse(
76
+ success=False, message=msg or "Failed to enable tcpip", error="tcpip"
77
+ )
78
+
79
+ # 2) 读取设备 IP:先用本地 adb_plus 的 WiFi 优先逻辑,失败再回退上游接口
80
+ ip = get_wifi_ip(conn.adb_path, device_info.device_id) or conn.get_device_ip(
81
+ device_info.device_id
82
+ )
83
+ if not ip:
84
+ return WiFiConnectResponse(
85
+ success=False, message="Failed to get device IP", error="ip"
86
+ )
87
+
88
+ address = f"{ip}:{request.port}"
89
+
90
+ # 3) 连接 WiFi
91
+ ok, msg = conn.connect(address)
92
+ if not ok:
93
+ return WiFiConnectResponse(
94
+ success=False,
95
+ message=msg or "Failed to connect over WiFi",
96
+ error="connect",
97
+ )
98
+
99
+ return WiFiConnectResponse(
100
+ success=True,
101
+ message="Switched to WiFi successfully",
102
+ device_id=address,
103
+ address=address,
104
+ )
105
+
106
+
107
+ @router.post("/api/devices/disconnect_wifi", response_model=WiFiDisconnectResponse)
108
+ def disconnect_wifi(request: WiFiDisconnectRequest) -> WiFiDisconnectResponse:
109
+ """断开 WiFi 连接。"""
110
+ from phone_agent.adb import ADBConnection
111
+
112
+ conn = ADBConnection()
113
+ ok, msg = conn.disconnect(request.device_id)
114
+
115
+ return WiFiDisconnectResponse(
116
+ success=ok,
117
+ message=msg,
118
+ error=None if ok else "disconnect_failed",
29
119
  )
AutoGLM_GUI/api/media.py CHANGED
@@ -1,51 +1,28 @@
1
- """Media routes: screenshot, video stream, stream reset."""
1
+ """Media routes: screenshot and stream reset."""
2
2
 
3
- import asyncio
4
- import os
5
- from pathlib import Path
3
+ from __future__ import annotations
6
4
 
7
- from fastapi import APIRouter, WebSocket, WebSocketDisconnect
5
+ from fastapi import APIRouter
8
6
 
9
7
  from AutoGLM_GUI.adb_plus import capture_screenshot
8
+ from AutoGLM_GUI.logger import logger
10
9
  from AutoGLM_GUI.schemas import ScreenshotRequest, ScreenshotResponse
11
- from AutoGLM_GUI.scrcpy_stream import ScrcpyStreamer
12
- from AutoGLM_GUI.state import scrcpy_locks, scrcpy_streamers
10
+ from AutoGLM_GUI.socketio_server import stop_streamers
13
11
 
14
12
  router = APIRouter()
15
13
 
16
- # Debug configuration: Set DEBUG_SAVE_VIDEO_STREAM=1 to save streams to debug_streams/
17
- DEBUG_SAVE_STREAM = os.getenv("DEBUG_SAVE_VIDEO_STREAM", "0") == "1"
18
-
19
14
 
20
15
  @router.post("/api/video/reset")
21
16
  async def reset_video_stream(device_id: str | None = None) -> dict:
22
- """Reset video stream (cleanup scrcpy server,多设备支持)."""
17
+ """Reset active scrcpy streams (Socket.IO)."""
18
+ stop_streamers(device_id=device_id)
23
19
  if device_id:
24
- if device_id in scrcpy_locks:
25
- async with scrcpy_locks[device_id]:
26
- if device_id in scrcpy_streamers:
27
- print(f"[video/reset] Stopping streamer for device {device_id}")
28
- scrcpy_streamers[device_id].stop()
29
- del scrcpy_streamers[device_id]
30
- print(f"[video/reset] Streamer reset for device {device_id}")
31
- return {
32
- "success": True,
33
- "message": f"Video stream reset for device {device_id}",
34
- }
35
- return {
36
- "success": True,
37
- "message": f"No active video stream for device {device_id}",
38
- }
39
- return {"success": True, "message": f"No video stream for device {device_id}"}
40
-
41
- device_ids = list(scrcpy_streamers.keys())
42
- for dev_id in device_ids:
43
- if dev_id in scrcpy_locks:
44
- async with scrcpy_locks[dev_id]:
45
- if dev_id in scrcpy_streamers:
46
- scrcpy_streamers[dev_id].stop()
47
- del scrcpy_streamers[dev_id]
48
- print("[video/reset] All streamers reset")
20
+ logger.info("Video stream reset for device %s", device_id)
21
+ return {
22
+ "success": True,
23
+ "message": f"Video stream reset for device {device_id}",
24
+ }
25
+ logger.info("All video streams reset")
49
26
  return {"success": True, "message": "All video streams reset"}
50
27
 
51
28
 
@@ -70,210 +47,3 @@ def take_screenshot(request: ScreenshotRequest) -> ScreenshotResponse:
70
47
  is_sensitive=False,
71
48
  error=str(e),
72
49
  )
73
-
74
-
75
- @router.websocket("/api/video/stream")
76
- async def video_stream_ws(
77
- websocket: WebSocket,
78
- device_id: str | None = None,
79
- ):
80
- """Stream real-time H.264 video from scrcpy server via WebSocket(多设备支持)."""
81
- await websocket.accept()
82
-
83
- if not device_id:
84
- await websocket.send_json({"error": "device_id is required"})
85
- return
86
-
87
- print(f"[video/stream] WebSocket connection for device {device_id}")
88
-
89
- # Debug: Save stream to file for analysis (controlled by DEBUG_SAVE_VIDEO_STREAM env var)
90
- debug_file = None
91
- if DEBUG_SAVE_STREAM:
92
- debug_dir = Path("debug_streams")
93
- debug_dir.mkdir(exist_ok=True)
94
- debug_file_path = (
95
- debug_dir / f"{device_id}_{int(__import__('time').time())}.h264"
96
- )
97
- debug_file = open(debug_file_path, "wb")
98
- print(f"[video/stream] DEBUG: Saving stream to {debug_file_path}")
99
-
100
- if device_id not in scrcpy_locks:
101
- scrcpy_locks[device_id] = asyncio.Lock()
102
-
103
- async with scrcpy_locks[device_id]:
104
- if device_id not in scrcpy_streamers:
105
- print(f"[video/stream] Creating streamer for device {device_id}")
106
- scrcpy_streamers[device_id] = ScrcpyStreamer(
107
- device_id=device_id, max_size=1280, bit_rate=4_000_000
108
- )
109
-
110
- try:
111
- print(f"[video/stream] Starting scrcpy server for device {device_id}")
112
- await scrcpy_streamers[device_id].start()
113
- print(f"[video/stream] Scrcpy server started for device {device_id}")
114
-
115
- # Read NAL units until we have SPS, PPS, and IDR
116
- streamer = scrcpy_streamers[device_id]
117
-
118
- print("[video/stream] Reading NAL units for initialization...")
119
- for attempt in range(20): # Max 20 NAL units for initialization
120
- try:
121
- nal_unit = await streamer.read_nal_unit(auto_cache=True)
122
- nal_type = nal_unit[4] & 0x1F if len(nal_unit) > 4 else -1
123
- nal_type_names = {5: "IDR", 7: "SPS", 8: "PPS"}
124
- print(
125
- f"[video/stream] Read NAL unit: type={nal_type_names.get(nal_type, nal_type)}, size={len(nal_unit)} bytes"
126
- )
127
-
128
- # Check if we have all required parameter sets
129
- if (
130
- streamer.cached_sps
131
- and streamer.cached_pps
132
- and streamer.cached_idr
133
- ):
134
- print(
135
- f"[video/stream] ✓ Initialization complete: SPS={len(streamer.cached_sps)}B, PPS={len(streamer.cached_pps)}B, IDR={len(streamer.cached_idr)}B"
136
- )
137
- break
138
- except Exception as e:
139
- print(f"[video/stream] Failed to read NAL unit: {e}")
140
- await asyncio.sleep(0.5)
141
- continue
142
-
143
- # Get initialization data (SPS + PPS + IDR)
144
- init_data = streamer.get_initialization_data()
145
- if not init_data:
146
- raise RuntimeError(
147
- "Failed to get initialization data (missing SPS/PPS/IDR)"
148
- )
149
-
150
- # Send initialization data as ONE message (SPS+PPS+IDR combined)
151
- await websocket.send_bytes(init_data)
152
- print(
153
- f"[video/stream] ✓ Sent initialization data to first client: {len(init_data)} bytes total"
154
- )
155
-
156
- # Debug: Save to file
157
- if debug_file:
158
- debug_file.write(init_data)
159
- debug_file.flush()
160
-
161
- except Exception as e:
162
- import traceback
163
-
164
- print(f"[video/stream] Failed to start streamer: {e}")
165
- print(f"[video/stream] Traceback:\n{traceback.format_exc()}")
166
- scrcpy_streamers[device_id].stop()
167
- del scrcpy_streamers[device_id]
168
- try:
169
- await websocket.send_json({"error": str(e)})
170
- except Exception:
171
- pass
172
- return
173
- else:
174
- print(f"[video/stream] Reusing streamer for device {device_id}")
175
-
176
- streamer = scrcpy_streamers[device_id]
177
- # CRITICAL: Send complete initialization data (SPS+PPS+IDR)
178
- # Without IDR frame, decoder cannot start and will show black screen
179
-
180
- # Wait for initialization data to be ready (max 5 seconds)
181
- init_data = None
182
- for attempt in range(10):
183
- init_data = streamer.get_initialization_data()
184
- if init_data:
185
- break
186
- print(
187
- f"[video/stream] Waiting for initialization data (attempt {attempt + 1}/10)..."
188
- )
189
- await asyncio.sleep(0.5)
190
-
191
- if init_data:
192
- # Log what we're sending
193
- print(
194
- f"[video/stream] ✓ Sending cached initialization data for device {device_id}:"
195
- )
196
- print(
197
- f" - SPS: {len(streamer.cached_sps) if streamer.cached_sps else 0}B"
198
- )
199
- print(
200
- f" - PPS: {len(streamer.cached_pps) if streamer.cached_pps else 0}B"
201
- )
202
- print(
203
- f" - IDR: {len(streamer.cached_idr) if streamer.cached_idr else 0}B"
204
- )
205
- print(f" - Total: {len(init_data)} bytes")
206
-
207
- await websocket.send_bytes(init_data)
208
- print("[video/stream] ✓ Initialization data sent successfully")
209
-
210
- # Debug: Save to file
211
- if debug_file:
212
- debug_file.write(init_data)
213
- debug_file.flush()
214
- else:
215
- error_msg = f"Initialization data not ready for device {device_id} after 5 seconds"
216
- print(f"[video/stream] ERROR: {error_msg}")
217
- try:
218
- await websocket.send_json({"error": error_msg})
219
- except Exception:
220
- pass
221
- return
222
-
223
- streamer = scrcpy_streamers[device_id]
224
-
225
- stream_failed = False
226
- try:
227
- nal_count = 0
228
- while True:
229
- try:
230
- # Read one complete NAL unit
231
- # Each WebSocket message = one complete NAL unit (clear semantic boundary)
232
- nal_unit = await streamer.read_nal_unit(auto_cache=True)
233
- await websocket.send_bytes(nal_unit)
234
-
235
- # Debug: Save to file
236
- if debug_file:
237
- debug_file.write(nal_unit)
238
- debug_file.flush()
239
-
240
- nal_count += 1
241
- if nal_count % 100 == 0:
242
- print(
243
- f"[video/stream] Device {device_id}: Sent {nal_count} NAL units"
244
- )
245
- except ConnectionError as e:
246
- print(f"[video/stream] Device {device_id}: Connection error: {e}")
247
- stream_failed = True
248
- try:
249
- await websocket.send_json({"error": f"Stream error: {str(e)}"})
250
- except Exception:
251
- pass
252
- break
253
-
254
- except WebSocketDisconnect:
255
- print(f"[video/stream] Device {device_id}: Client disconnected")
256
- except Exception as e:
257
- import traceback
258
-
259
- print(f"[video/stream] Device {device_id}: Error: {e}")
260
- print(f"[video/stream] Traceback:\n{traceback.format_exc()}")
261
- stream_failed = True
262
- try:
263
- await websocket.send_json({"error": str(e)})
264
- except Exception:
265
- pass
266
-
267
- if stream_failed:
268
- async with scrcpy_locks[device_id]:
269
- if device_id in scrcpy_streamers:
270
- print(f"[video/stream] Resetting streamer for device {device_id}")
271
- scrcpy_streamers[device_id].stop()
272
- del scrcpy_streamers[device_id]
273
-
274
- # Debug: Close file
275
- if debug_file:
276
- debug_file.close()
277
- print("[video/stream] DEBUG: Closed debug file")
278
-
279
- print(f"[video/stream] Device {device_id}: Stream ended")