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.
- AutoGLM_GUI/__init__.py +8 -0
- AutoGLM_GUI/__main__.py +64 -21
- AutoGLM_GUI/adb_plus/__init__.py +8 -0
- AutoGLM_GUI/adb_plus/device.py +50 -0
- AutoGLM_GUI/adb_plus/ip.py +78 -0
- AutoGLM_GUI/adb_plus/keyboard_installer.py +380 -0
- AutoGLM_GUI/adb_plus/serial.py +35 -0
- AutoGLM_GUI/api/__init__.py +8 -0
- AutoGLM_GUI/api/agents.py +132 -1
- AutoGLM_GUI/api/devices.py +96 -6
- AutoGLM_GUI/api/media.py +13 -243
- AutoGLM_GUI/config_manager.py +565 -0
- AutoGLM_GUI/exceptions.py +7 -0
- AutoGLM_GUI/logger.py +85 -0
- AutoGLM_GUI/platform_utils.py +30 -5
- AutoGLM_GUI/schemas.py +50 -0
- AutoGLM_GUI/scrcpy_protocol.py +46 -0
- AutoGLM_GUI/scrcpy_stream.py +208 -327
- AutoGLM_GUI/server.py +7 -2
- AutoGLM_GUI/socketio_server.py +125 -0
- AutoGLM_GUI/state.py +2 -1
- AutoGLM_GUI/static/assets/{about-BI6OV6gm.js → about-kgOkkOWe.js} +1 -1
- AutoGLM_GUI/static/assets/chat-CZV3RByK.js +149 -0
- AutoGLM_GUI/static/assets/{index-Do7ha9Kf.js → index-BPYHsweG.js} +1 -1
- AutoGLM_GUI/static/assets/index-Beu9cbSy.css +1 -0
- AutoGLM_GUI/static/assets/index-DfI_Z1Cx.js +10 -0
- AutoGLM_GUI/static/assets/worker-D6BRitjy.js +1 -0
- AutoGLM_GUI/static/index.html +2 -2
- {autoglm_gui-0.4.9.dist-info → autoglm_gui-0.4.12.dist-info}/METADATA +15 -2
- autoglm_gui-0.4.12.dist-info/RECORD +56 -0
- AutoGLM_GUI/static/assets/chat-C_2Cot0q.js +0 -25
- AutoGLM_GUI/static/assets/index-DCrxTz-A.css +0 -1
- AutoGLM_GUI/static/assets/index-Dn3vR6uV.js +0 -10
- autoglm_gui-0.4.9.dist-info/RECORD +0 -46
- {autoglm_gui-0.4.9.dist-info → autoglm_gui-0.4.12.dist-info}/WHEEL +0 -0
- {autoglm_gui-0.4.9.dist-info → autoglm_gui-0.4.12.dist-info}/entry_points.txt +0 -0
- {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
|
AutoGLM_GUI/api/__init__.py
CHANGED
|
@@ -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,
|
|
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))
|
AutoGLM_GUI/api/devices.py
CHANGED
|
@@ -2,7 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
from fastapi import APIRouter
|
|
4
4
|
|
|
5
|
-
from AutoGLM_GUI.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1
|
+
"""Media routes: screenshot and stream reset."""
|
|
2
2
|
|
|
3
|
-
import
|
|
4
|
-
import os
|
|
5
|
-
from pathlib import Path
|
|
3
|
+
from __future__ import annotations
|
|
6
4
|
|
|
7
|
-
from fastapi import APIRouter
|
|
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.
|
|
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
|
|
17
|
+
"""Reset active scrcpy streams (Socket.IO)."""
|
|
18
|
+
stop_streamers(device_id=device_id)
|
|
23
19
|
if device_id:
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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")
|