's'
This commit is contained in:
@@ -4,7 +4,9 @@ import asyncio
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from urllib.parse import quote
|
||||
|
||||
@@ -293,13 +295,42 @@ async def android_ui_nodes(serial: str, limit: int = 80) -> list[dict]:
|
||||
return nodes[: max(1, min(limit, 200))]
|
||||
|
||||
|
||||
def _is_png(data: bytes) -> bool:
|
||||
return bool(data and len(data) > 8 and data.startswith(b"\x89PNG\r\n\x1a\n"))
|
||||
|
||||
|
||||
async def android_screenshot(serial: str) -> bytes:
|
||||
if not serial:
|
||||
raise ValueError("Missing Android device serial")
|
||||
code, data, err = await _adb_binary(serial, "exec-out", "screencap", "-p", timeout=20.0)
|
||||
if code != 0 or not data:
|
||||
raise RuntimeError(err or "Failed to capture screenshot")
|
||||
return data.replace(b"\r\n", b"\n")
|
||||
if code == 0 and data:
|
||||
normalized = data.replace(b"\r\n", b"\n")
|
||||
if _is_png(data) or normalized.startswith(b"\x89PNG"):
|
||||
return normalized
|
||||
|
||||
last_err = err or "exec-out screencap failed"
|
||||
for remote in ("/data/local/tmp/live-cap.png", "/sdcard/live-cap.png"):
|
||||
tmp = Path(tempfile.mkstemp(suffix=".png")[1])
|
||||
try:
|
||||
sh_code, _, sh_err = await _adb_text(serial, "shell", f"screencap -p {remote}", timeout=25.0)
|
||||
if sh_code != 0:
|
||||
last_err = sh_err or f"screencap to {remote} failed"
|
||||
continue
|
||||
pull_code, _, pull_err = await _run_cmd(
|
||||
_adb_prefix(serial) + ["pull", remote, str(tmp)],
|
||||
timeout=35.0,
|
||||
)
|
||||
if pull_code != 0:
|
||||
last_err = pull_err or "adb pull failed"
|
||||
continue
|
||||
pulled = tmp.read_bytes()
|
||||
if _is_png(pulled) or pulled.startswith(b"\x89PNG"):
|
||||
return pulled.replace(b"\r\n", b"\n")
|
||||
last_err = "Pulled file is not a valid PNG"
|
||||
finally:
|
||||
tmp.unlink(missing_ok=True)
|
||||
|
||||
raise RuntimeError(last_err)
|
||||
|
||||
|
||||
def _normalize_text(text: str) -> str:
|
||||
@@ -420,4 +451,12 @@ async def android_action(action: str, serial: str, payload: Optional[dict] = Non
|
||||
output = await _shell(serial, f"pm clear '{package}'", timeout=25.0)
|
||||
return {"message": f"Cleared app data: {package}", "output": output}
|
||||
|
||||
if action == "install_apk":
|
||||
apk_path = str(payload.get("path", "")).strip()
|
||||
if not apk_path or any(ch in apk_path for ch in ";$`&|()<>\n\r"):
|
||||
raise ValueError("Invalid apk path on device (use adb push to /sdcard/ first)")
|
||||
apk_path = apk_path.replace("'", "'\\''")
|
||||
output = await _shell(serial, f"pm install -r -t '{apk_path}'", timeout=180.0)
|
||||
return {"message": "APK install finished", "output": output}
|
||||
|
||||
raise ValueError(f"Unsupported Android action: {action}")
|
||||
|
||||
118
src/hub_routes.py
Normal file
118
src/hub_routes.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""Live Hub 统一 API:仪表盘、配置中心;业务写操作经 API 落盘,前端不直接访问文件。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
|
||||
from src.android_control import adb_available, list_android_devices
|
||||
from src.control_plane import query_services, stack_summary, system_snapshot
|
||||
from src.live_config import business_config, load_hub_config_snapshot, services_config_list
|
||||
from src.web_process_backend import WebProcessBackend
|
||||
|
||||
router = APIRouter(prefix="/hub", tags=["Live Hub"])
|
||||
|
||||
_process_backend: WebProcessBackend | None = None
|
||||
_monitor_entries: list[dict[str, Any]] = []
|
||||
|
||||
|
||||
def configure_hub(process_backend: WebProcessBackend, monitor_entries: list[dict[str, Any]]) -> None:
|
||||
global _process_backend, _monitor_entries
|
||||
_process_backend = process_backend
|
||||
_monitor_entries = list(monitor_entries)
|
||||
|
||||
|
||||
class BusinessDouyinPatch(BaseModel):
|
||||
streams: Optional[dict[str, str]] = None
|
||||
ffmpeg: Optional[dict[str, Any]] = None
|
||||
|
||||
|
||||
@router.get("/dashboard")
|
||||
async def hub_dashboard() -> dict[str, Any]:
|
||||
snap = system_snapshot()
|
||||
services = await query_services()
|
||||
if adb_available():
|
||||
devices = await list_android_devices()
|
||||
else:
|
||||
devices = []
|
||||
stack = stack_summary()
|
||||
|
||||
live_status: dict[str, Any] = {
|
||||
"processes": [],
|
||||
"backend_mode": "unknown",
|
||||
"hint": "PM2 or local registry",
|
||||
}
|
||||
pb = _process_backend
|
||||
if pb is not None:
|
||||
await pb.ensure_mode()
|
||||
live_status["backend_mode"] = pb.mode
|
||||
for entry in _monitor_entries:
|
||||
name = str(entry.get("pm2", "")).strip()
|
||||
if not name:
|
||||
continue
|
||||
try:
|
||||
info = await pb.parse_pm2_describe(name)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
info = {"process_status": "error", "message": str(exc)}
|
||||
live_status["processes"].append(
|
||||
{
|
||||
"pm2": name,
|
||||
"label": entry.get("label", name),
|
||||
"script": entry.get("script", ""),
|
||||
**info,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"snapshot": snap,
|
||||
"stack": stack,
|
||||
"services": services,
|
||||
"android": {
|
||||
"adb_available": adb_available(),
|
||||
"devices": devices,
|
||||
},
|
||||
"live": live_status,
|
||||
"netdata": snap.get("netdata"),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/config")
|
||||
async def hub_config_read() -> dict[str, Any]:
|
||||
return load_hub_config_snapshot()
|
||||
|
||||
|
||||
@router.get("/services/declared")
|
||||
async def hub_services_declared() -> dict[str, Any]:
|
||||
return {"services": services_config_list()}
|
||||
|
||||
|
||||
@router.get("/business/douyinyoutube")
|
||||
async def hub_business_douyin_read() -> dict[str, Any]:
|
||||
return business_config("douyinyoutube")
|
||||
|
||||
|
||||
@router.post("/business/douyinyoutube")
|
||||
async def hub_business_douyin_patch(body: BusinessDouyinPatch) -> dict[str, str]:
|
||||
from src.live_config.loader import get_config_root
|
||||
|
||||
root = get_config_root()
|
||||
path = root / "business" / "douyinyoutube.json"
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
current: dict = {}
|
||||
if path.is_file():
|
||||
try:
|
||||
current = json.loads(path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
if body.streams is not None:
|
||||
cur_streams = current.get("streams") if isinstance(current.get("streams"), dict) else {}
|
||||
cur_streams.update({k: v for k, v in body.streams.items() if v is not None})
|
||||
current["streams"] = cur_streams
|
||||
if body.ffmpeg is not None:
|
||||
cur_ff = current.get("ffmpeg") if isinstance(current.get("ffmpeg"), dict) else {}
|
||||
cur_ff.update(body.ffmpeg)
|
||||
current["ffmpeg"] = cur_ff
|
||||
path.write_text(json.dumps(current, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
return {"message": "business/douyinyoutube.json updated"}
|
||||
8
src/live_config/__init__.py
Normal file
8
src/live_config/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from .loader import business_config, get_config_root, load_hub_config_snapshot, services_config_list
|
||||
|
||||
__all__ = [
|
||||
"get_config_root",
|
||||
"load_hub_config_snapshot",
|
||||
"services_config_list",
|
||||
"business_config",
|
||||
]
|
||||
56
src/live_config/loader.py
Normal file
56
src/live_config/loader.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def get_config_root() -> Path:
|
||||
env = os.environ.get("LIVE_CONFIG_ROOT", "").strip()
|
||||
if env:
|
||||
return Path(env)
|
||||
if Path("/opt/live/config/system.json").is_file():
|
||||
return Path("/opt/live/config")
|
||||
# 仓库内开发默认值
|
||||
base = Path(__file__).resolve().parent.parent.parent
|
||||
return base / "live-platform" / "config" / "defaults"
|
||||
|
||||
|
||||
def _read_json(path: Path) -> dict[str, Any]:
|
||||
if not path.is_file():
|
||||
return {}
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
|
||||
|
||||
def services_config_list() -> list[dict[str, Any]]:
|
||||
root = get_config_root() / "services"
|
||||
if not root.is_dir():
|
||||
return []
|
||||
out: list[dict[str, Any]] = []
|
||||
for f in sorted(root.glob("*.json")):
|
||||
data = _read_json(f)
|
||||
if data:
|
||||
data.setdefault("id", f.stem)
|
||||
out.append(data)
|
||||
return out
|
||||
|
||||
|
||||
def business_config(module_id: str) -> dict[str, Any]:
|
||||
return _read_json(get_config_root() / "business" / f"{module_id}.json")
|
||||
|
||||
|
||||
def load_hub_config_snapshot() -> dict[str, Any]:
|
||||
root = get_config_root()
|
||||
return {
|
||||
"config_root": str(root),
|
||||
"system": _read_json(root / "system.json"),
|
||||
"network": _read_json(root / "network.json"),
|
||||
"services": services_config_list(),
|
||||
"business": {
|
||||
"douyinyoutube": business_config("douyinyoutube"),
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user