's'
This commit is contained in:
@@ -7,7 +7,7 @@ import shutil
|
||||
import tempfile
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import quote
|
||||
|
||||
|
||||
@@ -94,6 +94,24 @@ def _parse_device_line(line: str) -> Optional[dict]:
|
||||
}
|
||||
|
||||
|
||||
def device_transport_hint(serial: str) -> str:
|
||||
"""USB 板子 / Redroid·无线 adb / 模拟器 的粗分类,供前端策略与展示。"""
|
||||
s = (serial or "").strip()
|
||||
if not s:
|
||||
return "unknown"
|
||||
if s.startswith("emulator-"):
|
||||
return "emulator"
|
||||
if ":" in s:
|
||||
return "tcp"
|
||||
return "usb"
|
||||
|
||||
|
||||
def enrich_device_row(row: dict) -> dict[str, Any]:
|
||||
out = dict(row)
|
||||
out["transport"] = device_transport_hint(str(row.get("serial", "")))
|
||||
return out
|
||||
|
||||
|
||||
async def list_android_devices() -> list[dict]:
|
||||
if not adb_available():
|
||||
return []
|
||||
@@ -104,10 +122,79 @@ async def list_android_devices() -> list[dict]:
|
||||
for raw in out.splitlines():
|
||||
parsed = _parse_device_line(raw)
|
||||
if parsed:
|
||||
devices.append(parsed)
|
||||
devices.append(enrich_device_row(parsed))
|
||||
return devices
|
||||
|
||||
|
||||
async def adb_connect_address(address: str) -> dict[str, Any]:
|
||||
"""adb connect host[:port],用于 Redroid / 无线调试 / 局域网设备。"""
|
||||
if not adb_available():
|
||||
raise RuntimeError("adb is not installed")
|
||||
addr = (address or "").strip()
|
||||
if not addr or len(addr) > 200:
|
||||
raise ValueError("Invalid address")
|
||||
if any(ch in addr for ch in ";|&$\n\r`"):
|
||||
raise ValueError("Invalid characters in address")
|
||||
code, out, err = await _adb_text(None, "connect", addr, timeout=30.0)
|
||||
text = (out or err or "").strip()
|
||||
tl = text.lower()
|
||||
ok = code == 0 and (
|
||||
"connected" in tl or "already" in tl
|
||||
) and "failed" not in tl and "unable to connect" not in tl
|
||||
return {"ok": ok, "code": code, "message": text}
|
||||
|
||||
|
||||
async def adb_disconnect_address(address: str = "") -> dict[str, Any]:
|
||||
"""adb disconnect [serial];空则断开全部 TCP 会话。"""
|
||||
if not adb_available():
|
||||
raise RuntimeError("adb is not installed")
|
||||
addr = (address or "").strip()
|
||||
if addr and len(addr) > 200:
|
||||
raise ValueError("Invalid address")
|
||||
if addr and any(ch in addr for ch in ";|&$\n\r`"):
|
||||
raise ValueError("Invalid characters in address")
|
||||
if addr:
|
||||
code, out, err = await _adb_text(None, "disconnect", addr, timeout=20.0)
|
||||
else:
|
||||
code, out, err = await _adb_text(None, "disconnect", timeout=20.0)
|
||||
text = (out or err or "").strip()
|
||||
return {"ok": code == 0, "code": code, "message": text}
|
||||
|
||||
|
||||
async def adb_pair_code(host: str, port: int, pairing_code: str) -> dict[str, Any]:
|
||||
"""无线配对(Android 11+):向 `adb pair host:port` 标准输入写入配对码。"""
|
||||
if not adb_available():
|
||||
raise RuntimeError("adb is not installed")
|
||||
h = (host or "").strip()
|
||||
pc = (pairing_code or "").strip()
|
||||
if not h or not pc or len(pc) > 16:
|
||||
raise ValueError("Invalid host or pairing code")
|
||||
if any(ch in h for ch in ";|&$\n\r`"):
|
||||
raise ValueError("Invalid host")
|
||||
pp = max(1, min(int(port), 65535))
|
||||
target = f"{h}:{pp}"
|
||||
adb_bin = shutil.which("adb") or "adb"
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
adb_bin,
|
||||
"pair",
|
||||
target,
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(proc.communicate((pc + "\n").encode("utf-8")), timeout=50.0)
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
await proc.wait()
|
||||
raise RuntimeError("adb pair timed out") from None
|
||||
text = ((stdout or b"") + b"\n" + (stderr or b"")).decode("utf-8", "replace").strip()
|
||||
rc = int(proc.returncode or 0)
|
||||
tl = text.lower()
|
||||
ok = rc == 0 and ("successfully paired" in tl or "pairing successful" in tl or "paired with" in tl)
|
||||
return {"ok": ok, "code": rc, "message": text}
|
||||
|
||||
|
||||
async def _shell(serial: str, command: str, timeout: float = 20.0) -> str:
|
||||
code, out, err = await _adb_text(serial, "shell", command, timeout=timeout)
|
||||
if code != 0:
|
||||
@@ -123,6 +210,14 @@ async def _get_display_size(serial: str) -> tuple[int, int]:
|
||||
return int(match.group(1)), int(match.group(2))
|
||||
|
||||
|
||||
async def android_display_metrics(serial: str) -> dict[str, int]:
|
||||
"""逻辑分辨率(wm size),与截图 PNG 像素可能略有差异;触控映射优先用截图 natural 尺寸。"""
|
||||
if not serial:
|
||||
raise ValueError("Missing Android device serial")
|
||||
w, h = await _get_display_size(serial)
|
||||
return {"width": w, "height": h}
|
||||
|
||||
|
||||
async def android_device_overview(serial: str) -> dict:
|
||||
if not serial:
|
||||
raise ValueError("Missing Android device serial")
|
||||
@@ -142,20 +237,35 @@ async def android_device_overview(serial: str) -> dict:
|
||||
|
||||
results = dict(await asyncio.gather(*(grab(key, cmd) for key, cmd in props.items())))
|
||||
|
||||
battery = await _shell(
|
||||
serial,
|
||||
"dumpsys battery | grep -E 'level|status|powered' | sed 's/^[[:space:]]*//'",
|
||||
timeout=10.0,
|
||||
)
|
||||
focus = await _shell(
|
||||
serial,
|
||||
"(dumpsys window windows | grep -E 'mCurrentFocus' | tail -n 1) || "
|
||||
"(dumpsys activity activities | grep -E 'mResumedActivity' | tail -n 1)",
|
||||
timeout=10.0,
|
||||
)
|
||||
resolution = await _shell(serial, "wm size | tail -n 1", timeout=10.0)
|
||||
rotation = await _shell(serial, "settings get system user_rotation", timeout=10.0)
|
||||
screen_on = await _shell(serial, "dumpsys power | grep -E 'Display Power|mHoldingDisplaySuspendBlocker'", timeout=10.0)
|
||||
async def bat() -> str:
|
||||
return await _shell(
|
||||
serial,
|
||||
"dumpsys battery | grep -E 'level|status|powered' | sed 's/^[[:space:]]*//'",
|
||||
timeout=12.0,
|
||||
)
|
||||
|
||||
async def foc() -> str:
|
||||
return await _shell(
|
||||
serial,
|
||||
"(dumpsys window windows | grep -E 'mCurrentFocus' | tail -n 1) || "
|
||||
"(dumpsys activity activities | grep -E 'mResumedActivity' | tail -n 1)",
|
||||
timeout=12.0,
|
||||
)
|
||||
|
||||
async def res() -> str:
|
||||
return await _shell(serial, "wm size | tail -n 1", timeout=8.0)
|
||||
|
||||
async def rot() -> str:
|
||||
return await _shell(serial, "settings get system user_rotation", timeout=8.0)
|
||||
|
||||
async def pwr() -> str:
|
||||
return await _shell(
|
||||
serial,
|
||||
"dumpsys power | grep -E 'Display Power|mHoldingDisplaySuspendBlocker'",
|
||||
timeout=10.0,
|
||||
)
|
||||
|
||||
battery, focus, resolution, rotation, screen_on = await asyncio.gather(bat(), foc(), res(), rot(), pwr())
|
||||
|
||||
return {
|
||||
"serial": serial,
|
||||
@@ -199,8 +309,22 @@ async def android_packages(serial: str, query: str = "", limit: int = 60) -> lis
|
||||
if not serial:
|
||||
raise ValueError("Missing Android device serial")
|
||||
|
||||
output = await _shell(serial, "pm list packages", timeout=25.0)
|
||||
needle = query.strip().lower()
|
||||
needle_raw = query.strip()
|
||||
needle = needle_raw.lower()
|
||||
lim = max(1, min(limit, 200))
|
||||
output = ""
|
||||
if needle_raw and all(c.isalnum() or c in "._-" for c in needle_raw):
|
||||
try:
|
||||
safe = needle_raw.replace("'", "'\\''")
|
||||
output = await _shell(
|
||||
serial,
|
||||
f"pm list packages | grep -Fi -- '{safe}' | head -n {lim + 40}",
|
||||
timeout=22.0,
|
||||
)
|
||||
except RuntimeError:
|
||||
output = ""
|
||||
if not output.strip():
|
||||
output = await _shell(serial, "pm list packages", timeout=28.0)
|
||||
results: list[dict] = []
|
||||
for raw in output.splitlines():
|
||||
package = raw.strip()
|
||||
@@ -307,10 +431,10 @@ async def android_logcat_recent(serial: str, lines: int = 200) -> str:
|
||||
return await _shell(serial, f"logcat -d -t {n}", timeout=60.0)
|
||||
|
||||
|
||||
async def android_screenshot(serial: str) -> bytes:
|
||||
async def android_screenshot(serial: str, *, exec_timeout: float = 14.0) -> bytes:
|
||||
if not serial:
|
||||
raise ValueError("Missing Android device serial")
|
||||
code, data, err = await _adb_binary(serial, "exec-out", "screencap", "-p", timeout=20.0)
|
||||
code, data, err = await _adb_binary(serial, "exec-out", "screencap", "-p", timeout=exec_timeout)
|
||||
if code == 0 and data:
|
||||
normalized = data.replace(b"\r\n", b"\n")
|
||||
if _is_png(data) or normalized.startswith(b"\x89PNG"):
|
||||
@@ -403,7 +527,7 @@ async def android_action(action: str, serial: str, payload: Optional[dict] = Non
|
||||
if action == "double_tap":
|
||||
x = int(payload.get("x", 0))
|
||||
y = int(payload.get("y", 0))
|
||||
await _shell(serial, f"input tap {x} {y} && sleep 0.08 && input tap {x} {y}")
|
||||
await _shell(serial, f"input tap {x} {y} && sleep 0.05 && input tap {x} {y}")
|
||||
return {"message": f"Double tapped at {x}, {y}"}
|
||||
|
||||
if action == "long_press":
|
||||
|
||||
@@ -95,12 +95,6 @@ SERVICE_SPECS: tuple[ServiceSpec, ...] = (
|
||||
"browser",
|
||||
"可选:Docker 内嵌 Chromium 桌面,适合远程开 YouTube Studio;ENABLE_NEKO=1 时安装",
|
||||
),
|
||||
ServiceSpec(
|
||||
"neko_firefox",
|
||||
"Neko Firefox (第二桌面)",
|
||||
"browser",
|
||||
"可选:第二套 Neko 桌面(Firefox,arm64/x86 多架构);ENABLE_NEKO_FF=1;口令与 Chromium 相同",
|
||||
),
|
||||
ServiceSpec(
|
||||
"android_gateway",
|
||||
"Android Gateway",
|
||||
@@ -583,8 +577,6 @@ async def stack_stream_probes() -> dict[str, Any]:
|
||||
srs_p = int(env.get("SRS_HTTP_PORT", "8080") or 8080)
|
||||
srs_api_p = int(env.get("SRS_API_PORT", "1985") or 1985)
|
||||
neko_p = int(env.get("NEKO_HTTP_PORT", "9200") or 9200)
|
||||
neko_ff_on = env.get("ENABLE_NEKO_FF", "0") == "1"
|
||||
neko_ff_p = int(env.get("NEKO_FF_HTTP_PORT", "9201") or 9201)
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
def run_srs() -> dict[str, Any]:
|
||||
@@ -609,18 +601,6 @@ async def stack_stream_probes() -> dict[str, Any]:
|
||||
http = _probe_http_get(f"http://127.0.0.1:{neko_p}/", timeout=2.5)
|
||||
return {"port": neko_p, "tcp": tcp, "http": http}
|
||||
|
||||
def run_neko_ff() -> dict[str, Any]:
|
||||
tcp = _probe_tcp_port("127.0.0.1", neko_ff_p)
|
||||
http = _probe_http_get(f"http://127.0.0.1:{neko_ff_p}/", timeout=2.5)
|
||||
return {"port": neko_ff_p, "tcp": tcp, "http": http}
|
||||
|
||||
if neko_ff_on:
|
||||
srs_d, neko_d, neko_ff_d = await asyncio.gather(
|
||||
loop.run_in_executor(None, run_srs),
|
||||
loop.run_in_executor(None, run_neko),
|
||||
loop.run_in_executor(None, run_neko_ff),
|
||||
)
|
||||
return {"srs": srs_d, "neko": neko_d, "neko_firefox": neko_ff_d}
|
||||
srs_d, neko_d = await asyncio.gather(
|
||||
loop.run_in_executor(None, run_srs),
|
||||
loop.run_in_executor(None, run_neko),
|
||||
@@ -642,8 +622,6 @@ def stack_summary() -> dict:
|
||||
android_panel = env.get("ANDROID_PANEL_PORT", "5000")
|
||||
chrome_dbg = env.get("BROWSER_REMOTE_DEBUG_PORT", "9222")
|
||||
neko_port = env.get("NEKO_HTTP_PORT", "9200")
|
||||
neko_ff_port = env.get("NEKO_FF_HTTP_PORT", "9201")
|
||||
neko_ff_on = env.get("ENABLE_NEKO_FF", "0") == "1"
|
||||
ql: dict[str, str] = {
|
||||
"control_plane": f"http://{mdns_host}:{web_port}",
|
||||
"webtty": f"http://{mdns_host}:{webtty}",
|
||||
@@ -655,8 +633,6 @@ def stack_summary() -> dict:
|
||||
"chromium_remote_debug": f"http://{mdns_host}:{chrome_dbg}",
|
||||
"neko_browser": f"http://{mdns_host}:{neko_port}",
|
||||
}
|
||||
if neko_ff_on:
|
||||
ql["neko_firefox_browser"] = f"http://{mdns_host}:{neko_ff_port}"
|
||||
return {
|
||||
"hostname": hostname,
|
||||
"mdns_host": mdns_host,
|
||||
@@ -679,7 +655,6 @@ def stack_summary() -> dict:
|
||||
" WebRTC 需 NEKO_WEBRTC_NAT1TO1 为板子局域网 IP,并放行 UDP 端口段。"
|
||||
),
|
||||
},
|
||||
"neko_firefox_enabled": neko_ff_on,
|
||||
"quick_links": ql,
|
||||
}
|
||||
|
||||
|
||||
59
src/hub_kv_store.py
Normal file
59
src/hub_kv_store.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""Hub 轻量状态(SQLite):多频道 Pro 等可随控制台落盘,浏览器仅作缓存。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from src.control_plane import CONFIG_DIR
|
||||
|
||||
_DB_NAME = "hub_state.sqlite"
|
||||
_KEY_YOUTUBE_PRO = "youtube_pro_channels_v1"
|
||||
|
||||
|
||||
def _db_path() -> Path:
|
||||
return CONFIG_DIR / _DB_NAME
|
||||
|
||||
|
||||
def _connect() -> sqlite3.Connection:
|
||||
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(str(_db_path()), timeout=12)
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS kv (k TEXT PRIMARY KEY, v TEXT NOT NULL, ts REAL NOT NULL)"
|
||||
)
|
||||
conn.commit()
|
||||
return conn
|
||||
|
||||
|
||||
def get_youtube_pro_channels() -> list[dict[str, Any]]:
|
||||
path = _db_path()
|
||||
if not path.is_file():
|
||||
return []
|
||||
try:
|
||||
with _connect() as c:
|
||||
row = c.execute("SELECT v FROM kv WHERE k = ?", (_KEY_YOUTUBE_PRO,)).fetchone()
|
||||
except sqlite3.Error:
|
||||
return []
|
||||
if not row:
|
||||
return []
|
||||
try:
|
||||
data = json.loads(row[0])
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
return data if isinstance(data, list) else []
|
||||
|
||||
|
||||
def set_youtube_pro_channels(channels: list[dict[str, Any]]) -> None:
|
||||
raw = json.dumps(channels, ensure_ascii=False)
|
||||
if len(raw) > 600_000:
|
||||
raise ValueError("channels payload too large")
|
||||
ts = time.time()
|
||||
with _connect() as c:
|
||||
c.execute(
|
||||
"INSERT INTO kv(k, v, ts) VALUES(?,?,?) "
|
||||
"ON CONFLICT(k) DO UPDATE SET v = excluded.v, ts = excluded.ts",
|
||||
(_KEY_YOUTUBE_PRO, raw, ts),
|
||||
)
|
||||
c.commit()
|
||||
@@ -5,8 +5,8 @@ import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from src.android_control import adb_available, list_android_devices
|
||||
from src.control_plane import (
|
||||
@@ -46,6 +46,24 @@ class BusinessDouyinPatch(BaseModel):
|
||||
sync_youtube_ini: Optional[bool] = None
|
||||
|
||||
|
||||
class YoutubeProChannelsBody(BaseModel):
|
||||
channels: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
@field_validator("channels")
|
||||
@classmethod
|
||||
def _validate_channels(cls, v: list[Any]) -> list[Any]:
|
||||
if len(v) > 64:
|
||||
raise ValueError("at most 64 channels")
|
||||
for item in v:
|
||||
if not isinstance(item, dict):
|
||||
raise ValueError("each channel must be an object")
|
||||
if not str(item.get("id", "")).strip():
|
||||
raise ValueError("channel.id required")
|
||||
if not str(item.get("name", "")).strip():
|
||||
raise ValueError("channel.name required")
|
||||
return v
|
||||
|
||||
|
||||
@router.get("/dashboard")
|
||||
async def hub_dashboard() -> dict[str, Any]:
|
||||
snap = system_snapshot()
|
||||
@@ -119,6 +137,24 @@ async def hub_config_read() -> dict[str, Any]:
|
||||
return load_hub_config_snapshot()
|
||||
|
||||
|
||||
@router.get("/youtube_pro_channels")
|
||||
async def hub_youtube_pro_channels_read() -> dict[str, Any]:
|
||||
from src.hub_kv_store import get_youtube_pro_channels
|
||||
|
||||
return {"channels": get_youtube_pro_channels()}
|
||||
|
||||
|
||||
@router.post("/youtube_pro_channels")
|
||||
async def hub_youtube_pro_channels_write(body: YoutubeProChannelsBody) -> dict[str, str]:
|
||||
from src.hub_kv_store import set_youtube_pro_channels
|
||||
|
||||
try:
|
||||
set_youtube_pro_channels(body.channels)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return {"message": "ok"}
|
||||
|
||||
|
||||
@router.get("/services/declared")
|
||||
async def hub_services_declared() -> dict[str, Any]:
|
||||
return {"services": services_config_list()}
|
||||
|
||||
Reference in New Issue
Block a user