's'
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import time
|
||||
|
||||
|
||||
423
src/android_control.py
Normal file
423
src/android_control.py
Normal file
@@ -0,0 +1,423 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import Optional
|
||||
from urllib.parse import quote
|
||||
|
||||
|
||||
def adb_available() -> bool:
|
||||
return shutil.which("adb") is not None
|
||||
|
||||
|
||||
async def _run_cmd(
|
||||
command: list[str],
|
||||
*,
|
||||
timeout: float = 20.0,
|
||||
capture_binary: bool = False,
|
||||
) -> tuple[int, str | bytes, str]:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*command,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
await proc.wait()
|
||||
return 124, b"" if capture_binary else "", "Command timed out"
|
||||
|
||||
if capture_binary:
|
||||
return proc.returncode or 0, stdout or b"", (stderr or b"").decode("utf-8", "replace").strip()
|
||||
return (
|
||||
proc.returncode or 0,
|
||||
(stdout or b"").decode("utf-8", "replace").strip(),
|
||||
(stderr or b"").decode("utf-8", "replace").strip(),
|
||||
)
|
||||
|
||||
|
||||
def _adb_prefix(serial: Optional[str]) -> list[str]:
|
||||
command = ["adb"]
|
||||
if serial:
|
||||
command.extend(["-s", serial])
|
||||
return command
|
||||
|
||||
|
||||
async def _adb_text(serial: Optional[str], *args: str, timeout: float = 20.0) -> tuple[int, str, str]:
|
||||
code, out, err = await _run_cmd(_adb_prefix(serial) + list(args), timeout=timeout)
|
||||
assert isinstance(out, str)
|
||||
return code, out, err
|
||||
|
||||
|
||||
async def _adb_binary(serial: Optional[str], *args: str, timeout: float = 20.0) -> tuple[int, bytes, str]:
|
||||
code, out, err = await _run_cmd(
|
||||
_adb_prefix(serial) + list(args),
|
||||
timeout=timeout,
|
||||
capture_binary=True,
|
||||
)
|
||||
assert isinstance(out, bytes)
|
||||
return code, out, err
|
||||
|
||||
|
||||
def _parse_device_line(line: str) -> Optional[dict]:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("List of devices attached"):
|
||||
return None
|
||||
parts = line.split()
|
||||
if len(parts) < 2:
|
||||
return None
|
||||
|
||||
serial = parts[0]
|
||||
state = parts[1]
|
||||
extras: dict[str, str] = {}
|
||||
for token in parts[2:]:
|
||||
if ":" not in token:
|
||||
continue
|
||||
key, value = token.split(":", 1)
|
||||
extras[key] = value
|
||||
|
||||
return {
|
||||
"serial": serial,
|
||||
"state": state,
|
||||
"usb": extras.get("usb", ""),
|
||||
"product": extras.get("product", ""),
|
||||
"model": extras.get("model", ""),
|
||||
"device": extras.get("device", ""),
|
||||
"transport_id": extras.get("transport_id", ""),
|
||||
"raw": line,
|
||||
}
|
||||
|
||||
|
||||
async def list_android_devices() -> list[dict]:
|
||||
if not adb_available():
|
||||
return []
|
||||
code, out, _ = await _adb_text(None, "devices", "-l")
|
||||
if code != 0:
|
||||
return []
|
||||
devices: list[dict] = []
|
||||
for raw in out.splitlines():
|
||||
parsed = _parse_device_line(raw)
|
||||
if parsed:
|
||||
devices.append(parsed)
|
||||
return devices
|
||||
|
||||
|
||||
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:
|
||||
raise RuntimeError(err or out or "ADB shell command failed")
|
||||
return out or err
|
||||
|
||||
|
||||
async def _get_display_size(serial: str) -> tuple[int, int]:
|
||||
output = await _shell(serial, "wm size | tail -n 1")
|
||||
match = re.search(r"(\d+)x(\d+)", output)
|
||||
if not match:
|
||||
return 1080, 2400
|
||||
return int(match.group(1)), int(match.group(2))
|
||||
|
||||
|
||||
async def android_device_overview(serial: str) -> dict:
|
||||
if not serial:
|
||||
raise ValueError("Missing Android device serial")
|
||||
|
||||
props = {
|
||||
"model": "getprop ro.product.model",
|
||||
"brand": "getprop ro.product.brand",
|
||||
"android_version": "getprop ro.build.version.release",
|
||||
"sdk": "getprop ro.build.version.sdk",
|
||||
}
|
||||
|
||||
async def grab(name: str, cmd: str) -> tuple[str, str]:
|
||||
try:
|
||||
return name, (await _shell(serial, cmd, timeout=10.0)).strip()
|
||||
except RuntimeError as exc:
|
||||
return name, str(exc)
|
||||
|
||||
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)
|
||||
|
||||
return {
|
||||
"serial": serial,
|
||||
"model": results.get("model", ""),
|
||||
"brand": results.get("brand", ""),
|
||||
"android_version": results.get("android_version", ""),
|
||||
"sdk": results.get("sdk", ""),
|
||||
"battery": battery,
|
||||
"focus": focus,
|
||||
"resolution": resolution,
|
||||
"rotation": rotation,
|
||||
"screen_state": screen_on,
|
||||
}
|
||||
|
||||
|
||||
def _bounds_to_rect(bounds: str) -> Optional[tuple[int, int, int, int]]:
|
||||
match = re.match(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", bounds.strip())
|
||||
if not match:
|
||||
return None
|
||||
left, top, right, bottom = (int(match.group(i)) for i in range(1, 5))
|
||||
if right <= left or bottom <= top:
|
||||
return None
|
||||
return left, top, right, bottom
|
||||
|
||||
|
||||
def _node_label(node: ET.Element) -> str:
|
||||
text = (node.attrib.get("text") or "").strip()
|
||||
desc = (node.attrib.get("content-desc") or "").strip()
|
||||
resource_id = (node.attrib.get("resource-id") or "").strip()
|
||||
class_name = (node.attrib.get("class") or "").strip()
|
||||
if text:
|
||||
return text
|
||||
if desc:
|
||||
return desc
|
||||
if resource_id:
|
||||
return resource_id.split("/")[-1]
|
||||
return class_name.split(".")[-1] if class_name else "node"
|
||||
|
||||
|
||||
async def android_packages(serial: str, query: str = "", limit: int = 60) -> list[dict]:
|
||||
if not serial:
|
||||
raise ValueError("Missing Android device serial")
|
||||
|
||||
output = await _shell(serial, "pm list packages", timeout=25.0)
|
||||
needle = query.strip().lower()
|
||||
results: list[dict] = []
|
||||
for raw in output.splitlines():
|
||||
package = raw.strip()
|
||||
if package.startswith("package:"):
|
||||
package = package[len("package:") :]
|
||||
if not package:
|
||||
continue
|
||||
if needle and needle not in package.lower():
|
||||
continue
|
||||
results.append(
|
||||
{
|
||||
"package": package,
|
||||
"label": package.split(".")[-1],
|
||||
}
|
||||
)
|
||||
if len(results) >= max(1, min(limit, 200)):
|
||||
break
|
||||
return results
|
||||
|
||||
|
||||
async def android_ui_nodes(serial: str, limit: int = 80) -> list[dict]:
|
||||
if not serial:
|
||||
raise ValueError("Missing Android device serial")
|
||||
|
||||
xml_text = ""
|
||||
for dump_path in ("/data/local/tmp/live-ui.xml", "/sdcard/live-ui.xml"):
|
||||
try:
|
||||
xml_text = await _shell(
|
||||
serial,
|
||||
f"uiautomator dump {dump_path} >/dev/null 2>&1 && cat {dump_path}",
|
||||
timeout=25.0,
|
||||
)
|
||||
except RuntimeError:
|
||||
xml_text = ""
|
||||
if xml_text.strip().startswith("<?xml"):
|
||||
break
|
||||
xml_text = xml_text.strip()
|
||||
if not xml_text.startswith("<?xml"):
|
||||
raise RuntimeError("Failed to dump Android UI hierarchy")
|
||||
|
||||
try:
|
||||
root = ET.fromstring(xml_text)
|
||||
except ET.ParseError as exc:
|
||||
raise RuntimeError(f"Unable to parse Android UI dump: {exc}") from exc
|
||||
|
||||
nodes: list[dict] = []
|
||||
for node in root.iter("node"):
|
||||
bounds = (node.attrib.get("bounds") or "").strip()
|
||||
rect = _bounds_to_rect(bounds)
|
||||
if not rect:
|
||||
continue
|
||||
|
||||
text = (node.attrib.get("text") or "").strip()
|
||||
desc = (node.attrib.get("content-desc") or "").strip()
|
||||
resource_id = (node.attrib.get("resource-id") or "").strip()
|
||||
clickable = node.attrib.get("clickable") == "true"
|
||||
focusable = node.attrib.get("focusable") == "true"
|
||||
scrollable = node.attrib.get("scrollable") == "true"
|
||||
enabled = node.attrib.get("enabled") != "false"
|
||||
if not (text or desc or resource_id or clickable or focusable or scrollable):
|
||||
continue
|
||||
|
||||
left, top, right, bottom = rect
|
||||
center_x = left + (right - left) // 2
|
||||
center_y = top + (bottom - top) // 2
|
||||
nodes.append(
|
||||
{
|
||||
"label": _node_label(node),
|
||||
"text": text,
|
||||
"content_desc": desc,
|
||||
"resource_id": resource_id,
|
||||
"class_name": (node.attrib.get("class") or "").strip(),
|
||||
"package": (node.attrib.get("package") or "").strip(),
|
||||
"bounds": bounds,
|
||||
"center_x": center_x,
|
||||
"center_y": center_y,
|
||||
"clickable": clickable,
|
||||
"focusable": focusable,
|
||||
"scrollable": scrollable,
|
||||
"enabled": enabled,
|
||||
}
|
||||
)
|
||||
|
||||
def sort_key(item: dict) -> tuple[int, int, int]:
|
||||
return (
|
||||
0 if item["clickable"] else 1,
|
||||
0 if item["text"] or item["content_desc"] else 1,
|
||||
len(item["label"]),
|
||||
)
|
||||
|
||||
nodes.sort(key=sort_key)
|
||||
return nodes[: max(1, min(limit, 200))]
|
||||
|
||||
|
||||
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")
|
||||
|
||||
|
||||
def _normalize_text(text: str) -> str:
|
||||
normalized = text.replace(" ", "%s")
|
||||
normalized = normalized.replace("&", r"\&")
|
||||
normalized = normalized.replace("(", r"\(")
|
||||
normalized = normalized.replace(")", r"\)")
|
||||
return normalized
|
||||
|
||||
|
||||
async def android_action(action: str, serial: str, payload: Optional[dict] = None) -> dict:
|
||||
if not serial:
|
||||
raise ValueError("Missing Android device serial")
|
||||
if not adb_available():
|
||||
raise RuntimeError("adb is not installed")
|
||||
payload = payload or {}
|
||||
|
||||
if action == "key":
|
||||
keycode = str(payload.get("keycode", "")).strip()
|
||||
if not keycode:
|
||||
raise ValueError("Missing keycode")
|
||||
await _shell(serial, f"input keyevent {keycode}")
|
||||
return {"message": f"Sent keycode {keycode}"}
|
||||
|
||||
if action == "text":
|
||||
text = str(payload.get("text", ""))
|
||||
if not text:
|
||||
raise ValueError("Missing text")
|
||||
await _shell(serial, f"input text '{_normalize_text(text)}'")
|
||||
return {"message": "Text sent to device"}
|
||||
|
||||
if action == "open_url":
|
||||
url = str(payload.get("url", "")).strip()
|
||||
if not url:
|
||||
raise ValueError("Missing url")
|
||||
await _shell(
|
||||
serial,
|
||||
f"am start -a android.intent.action.VIEW -d '{quote(url, safe=':/?&=%#.-_')}'",
|
||||
)
|
||||
return {"message": f"Opened URL: {url}"}
|
||||
|
||||
if action == "launch_package":
|
||||
package = str(payload.get("package", "")).strip()
|
||||
if not package:
|
||||
raise ValueError("Missing package")
|
||||
await _shell(serial, f"monkey -p '{package}' -c android.intent.category.LAUNCHER 1")
|
||||
return {"message": f"Launched package: {package}"}
|
||||
|
||||
if action == "shell":
|
||||
command = str(payload.get("command", "")).strip()
|
||||
if not command:
|
||||
raise ValueError("Missing shell command")
|
||||
output = await _shell(serial, command, timeout=30.0)
|
||||
return {"message": "Shell command finished", "output": output}
|
||||
|
||||
if action == "tap":
|
||||
x = int(payload.get("x", 0))
|
||||
y = int(payload.get("y", 0))
|
||||
await _shell(serial, f"input tap {x} {y}")
|
||||
return {"message": f"Tapped at {x}, {y}"}
|
||||
|
||||
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}")
|
||||
return {"message": f"Double tapped at {x}, {y}"}
|
||||
|
||||
if action == "long_press":
|
||||
x = int(payload.get("x", 0))
|
||||
y = int(payload.get("y", 0))
|
||||
duration = int(payload.get("duration", 700))
|
||||
await _shell(serial, f"input swipe {x} {y} {x} {y} {duration}")
|
||||
return {"message": f"Long pressed at {x}, {y} for {duration}ms"}
|
||||
|
||||
if action == "swipe":
|
||||
direction = str(payload.get("direction", "")).strip().lower()
|
||||
width, height = await _get_display_size(serial)
|
||||
mid_x = max(width // 2, 1)
|
||||
mid_y = max(height // 2, 1)
|
||||
mapping = {
|
||||
"up": (mid_x, int(height * 0.75), mid_x, int(height * 0.28)),
|
||||
"down": (mid_x, int(height * 0.28), mid_x, int(height * 0.75)),
|
||||
"left": (int(width * 0.75), mid_y, int(width * 0.25), mid_y),
|
||||
"right": (int(width * 0.25), mid_y, int(width * 0.75), mid_y),
|
||||
}
|
||||
if direction not in mapping:
|
||||
raise ValueError("Unsupported swipe direction")
|
||||
start_x, start_y, end_x, end_y = mapping[direction]
|
||||
await _shell(serial, f"input swipe {start_x} {start_y} {end_x} {end_y} 240")
|
||||
return {"message": f"Swiped {direction}"}
|
||||
|
||||
if action == "wake":
|
||||
await _shell(serial, "input keyevent KEYCODE_WAKEUP")
|
||||
return {"message": "Wake command sent"}
|
||||
|
||||
if action == "rotate":
|
||||
target = str(payload.get("rotation", "0")).strip()
|
||||
if target not in {"0", "1", "2", "3"}:
|
||||
raise ValueError("Rotation must be one of 0,1,2,3")
|
||||
await _shell(
|
||||
serial,
|
||||
"settings put system accelerometer_rotation 0 && "
|
||||
f"settings put system user_rotation {target}",
|
||||
)
|
||||
return {"message": f"Rotation set to {target}"}
|
||||
|
||||
if action == "force_stop":
|
||||
package = str(payload.get("package", "")).strip()
|
||||
if not package:
|
||||
raise ValueError("Missing package")
|
||||
await _shell(serial, f"am force-stop '{package}'")
|
||||
return {"message": f"Force-stopped: {package}"}
|
||||
|
||||
if action == "clear_app":
|
||||
package = str(payload.get("package", "")).strip()
|
||||
if not package:
|
||||
raise ValueError("Missing package")
|
||||
output = await _shell(serial, f"pm clear '{package}'", timeout=25.0)
|
||||
return {"message": f"Cleared app data: {package}", "output": output}
|
||||
|
||||
raise ValueError(f"Unsupported Android action: {action}")
|
||||
533
src/control_plane.py
Normal file
533
src/control_plane.py
Normal file
@@ -0,0 +1,533 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
from urllib.request import urlopen
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
CONFIG_DIR = BASE_DIR / "config"
|
||||
LINUX_DIR = BASE_DIR / "scripts" / "linux"
|
||||
SERVICE_CTL = LINUX_DIR / "service_ctl.sh"
|
||||
HW_PROBE = BASE_DIR / "scripts" / "hardware_probe.py"
|
||||
HW_PROFILE_JSON = CONFIG_DIR / "hardware-profile.json"
|
||||
STACK_ENV = CONFIG_DIR / "system-stack.env"
|
||||
MAX_FILES_PER_ROOT = 256
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ManagedRoot:
|
||||
root_id: str
|
||||
label: str
|
||||
description: str
|
||||
path: Path
|
||||
allow_create: bool = True
|
||||
allow_delete: bool = True
|
||||
recursive: bool = False
|
||||
explicit_names: tuple[str, ...] = ()
|
||||
allowed_suffixes: tuple[str, ...] = ()
|
||||
default_file: str | None = None
|
||||
|
||||
def exists(self) -> bool:
|
||||
return self.path.is_dir()
|
||||
|
||||
def allows(self, rel_path: Path) -> bool:
|
||||
rel_posix = rel_path.as_posix()
|
||||
if self.explicit_names:
|
||||
return rel_posix in self.explicit_names
|
||||
return rel_path.suffix.lower() in self.allowed_suffixes
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ServiceSpec:
|
||||
service_id: str
|
||||
label: str
|
||||
category: str
|
||||
description: str
|
||||
|
||||
|
||||
SERVICE_SPECS: tuple[ServiceSpec, ...] = (
|
||||
ServiceSpec("mdns", "mDNS / live.local", "system", "Hostname and mDNS discovery"),
|
||||
ServiceSpec("wifi", "Wi-Fi Auto Connect", "system", "Default Wi-Fi onboarding profile"),
|
||||
ServiceSpec("shellcrash", "ShellCrash", "network", "Transparent proxy and rule engine"),
|
||||
ServiceSpec("srs", "SRS", "stream", "RTMP/SRT relay for HDMI and live workflows"),
|
||||
ServiceSpec("webtty", "WebTTY", "access", "Browser terminal for live user"),
|
||||
ServiceSpec("cockpit", "Cockpit", "admin", "System administration dashboard"),
|
||||
ServiceSpec("filebrowser", "File Browser", "admin", "Beginner-friendly file manager"),
|
||||
ServiceSpec("homepage", "Homepage", "admin", "Start page for all LAN services"),
|
||||
ServiceSpec("netdata", "Netdata", "monitor", "System metrics and health monitoring"),
|
||||
ServiceSpec("adb", "ADB", "android", "Android bridge and device inspection"),
|
||||
ServiceSpec("android_panel", "Android Web Panel", "android", "Browser-based Android control with web-scrcpy"),
|
||||
ServiceSpec("chromium", "Chromium", "browser", "Persistent browser profile launcher"),
|
||||
ServiceSpec(
|
||||
"android_gateway",
|
||||
"Android Gateway",
|
||||
"network",
|
||||
"Transparent gateway mode for Android boards",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def shellcrash_dir() -> Path:
|
||||
value = os.environ.get("SHELLCRASH_DIR", "/etc/ShellCrash")
|
||||
return Path(os.path.expanduser(os.path.expandvars(value)))
|
||||
|
||||
|
||||
def managed_roots() -> dict[str, ManagedRoot]:
|
||||
shell_dir = shellcrash_dir()
|
||||
return {
|
||||
"app-config": ManagedRoot(
|
||||
root_id="app-config",
|
||||
label="Business App Config",
|
||||
description="Core livestream app configuration files",
|
||||
path=CONFIG_DIR,
|
||||
allow_create=True,
|
||||
allow_delete=False,
|
||||
explicit_names=(
|
||||
"config.ini",
|
||||
"youtube.ini",
|
||||
"URL_config.ini",
|
||||
"runtime.env",
|
||||
"runtime.env.example",
|
||||
),
|
||||
default_file="runtime.env",
|
||||
),
|
||||
"stack-config": ManagedRoot(
|
||||
root_id="stack-config",
|
||||
label="System Stack Config",
|
||||
description="Ports, feature toggles, hostname, Wi-Fi, and gateway settings",
|
||||
path=CONFIG_DIR,
|
||||
allow_create=True,
|
||||
allow_delete=False,
|
||||
explicit_names=("system-stack.env", "system-stack.env.example"),
|
||||
default_file="system-stack.env",
|
||||
),
|
||||
"shellcrash-configs": ManagedRoot(
|
||||
root_id="shellcrash-configs",
|
||||
label="ShellCrash Configs",
|
||||
description="ShellCrash cfg/list/env style files",
|
||||
path=shell_dir / "configs",
|
||||
allow_create=True,
|
||||
allow_delete=True,
|
||||
allowed_suffixes=(".cfg", ".env", ".list", ".txt"),
|
||||
default_file="ShellCrash.cfg",
|
||||
),
|
||||
"shellcrash-yamls": ManagedRoot(
|
||||
root_id="shellcrash-yamls",
|
||||
label="ShellCrash YAML",
|
||||
description="mihomo YAML fragments and config.yaml",
|
||||
path=shell_dir / "yamls",
|
||||
allow_create=True,
|
||||
allow_delete=True,
|
||||
allowed_suffixes=(".yaml", ".yml"),
|
||||
default_file="config.yaml",
|
||||
),
|
||||
"shellcrash-jsons": ManagedRoot(
|
||||
root_id="shellcrash-jsons",
|
||||
label="ShellCrash JSON",
|
||||
description="sing-box JSON fragments",
|
||||
path=shell_dir / "jsons",
|
||||
allow_create=True,
|
||||
allow_delete=True,
|
||||
allowed_suffixes=(".json",),
|
||||
default_file="config.json",
|
||||
),
|
||||
"srs-config": ManagedRoot(
|
||||
root_id="srs-config",
|
||||
label="SRS Config",
|
||||
description="SRS compose and conf files",
|
||||
path=BASE_DIR / "services" / "srs",
|
||||
allow_create=True,
|
||||
allow_delete=True,
|
||||
recursive=True,
|
||||
allowed_suffixes=(".conf", ".json", ".env", ".yml", ".yaml"),
|
||||
default_file="conf/srs.conf",
|
||||
),
|
||||
"homepage-config": ManagedRoot(
|
||||
root_id="homepage-config",
|
||||
label="Homepage Config",
|
||||
description="Dashboard links and widgets",
|
||||
path=BASE_DIR / "services" / "homepage" / "config",
|
||||
allow_create=True,
|
||||
allow_delete=True,
|
||||
recursive=True,
|
||||
allowed_suffixes=(".yaml", ".yml", ".json"),
|
||||
default_file="services.yaml",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _safe_rel_path(raw_path: str) -> Path:
|
||||
rel = Path(raw_path.strip().replace("\\", "/"))
|
||||
if rel.is_absolute():
|
||||
raise ValueError("Absolute paths are not allowed")
|
||||
if any(part in {"", ".", ".."} for part in rel.parts):
|
||||
raise ValueError("Path traversal is not allowed")
|
||||
return rel
|
||||
|
||||
|
||||
def _resolve_managed_file(root_id: str, relative_path: str) -> tuple[ManagedRoot, Path, Path]:
|
||||
roots = managed_roots()
|
||||
root = roots.get(root_id)
|
||||
if not root:
|
||||
raise KeyError(f"Unknown root: {root_id}")
|
||||
rel = _safe_rel_path(relative_path)
|
||||
if not root.allows(rel):
|
||||
raise PermissionError(f"File type not allowed under {root_id}")
|
||||
root.path.mkdir(parents=True, exist_ok=True)
|
||||
target = (root.path / rel).resolve()
|
||||
root_real = root.path.resolve()
|
||||
if root_real not in (target, *target.parents):
|
||||
raise PermissionError("Resolved path escapes managed root")
|
||||
return root, rel, target
|
||||
|
||||
|
||||
def list_root_summaries() -> list[dict]:
|
||||
summaries: list[dict] = []
|
||||
for root in managed_roots().values():
|
||||
file_count = 0
|
||||
if root.exists():
|
||||
globber: Iterable[Path]
|
||||
globber = root.path.rglob("*") if root.recursive else root.path.glob("*")
|
||||
for file in globber:
|
||||
if file.is_file():
|
||||
rel = file.relative_to(root.path)
|
||||
if root.allows(rel):
|
||||
file_count += 1
|
||||
summaries.append(
|
||||
{
|
||||
"root_id": root.root_id,
|
||||
"label": root.label,
|
||||
"description": root.description,
|
||||
"path": str(root.path),
|
||||
"exists": root.exists(),
|
||||
"allow_create": root.allow_create,
|
||||
"allow_delete": root.allow_delete,
|
||||
"recursive": root.recursive,
|
||||
"default_file": root.default_file,
|
||||
"file_count": file_count,
|
||||
}
|
||||
)
|
||||
return summaries
|
||||
|
||||
|
||||
def list_root_files(root_id: str) -> list[dict]:
|
||||
root = managed_roots().get(root_id)
|
||||
if not root:
|
||||
raise KeyError(f"Unknown root: {root_id}")
|
||||
if not root.exists():
|
||||
return []
|
||||
files: list[dict] = []
|
||||
iterator = root.path.rglob("*") if root.recursive else root.path.glob("*")
|
||||
for file in iterator:
|
||||
if not file.is_file():
|
||||
continue
|
||||
rel = file.relative_to(root.path)
|
||||
if not root.allows(rel):
|
||||
continue
|
||||
stat = file.stat()
|
||||
files.append(
|
||||
{
|
||||
"path": rel.as_posix(),
|
||||
"size": stat.st_size,
|
||||
"mtime": int(stat.st_mtime),
|
||||
}
|
||||
)
|
||||
files.sort(key=lambda item: item["path"])
|
||||
return files[:MAX_FILES_PER_ROOT]
|
||||
|
||||
|
||||
def read_root_file(root_id: str, relative_path: str) -> dict:
|
||||
root, rel, target = _resolve_managed_file(root_id, relative_path)
|
||||
if not target.is_file():
|
||||
raise FileNotFoundError(relative_path)
|
||||
return {
|
||||
"root_id": root.root_id,
|
||||
"path": rel.as_posix(),
|
||||
"content": target.read_text(encoding="utf-8"),
|
||||
}
|
||||
|
||||
|
||||
def write_root_file(root_id: str, relative_path: str, content: str) -> dict:
|
||||
root, rel, target = _resolve_managed_file(root_id, relative_path)
|
||||
if not root.allow_create and not target.exists():
|
||||
raise PermissionError(f"Creating files is disabled for {root_id}")
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(content, encoding="utf-8")
|
||||
return {
|
||||
"root_id": root.root_id,
|
||||
"path": rel.as_posix(),
|
||||
"message": "Saved successfully",
|
||||
}
|
||||
|
||||
|
||||
def delete_root_file(root_id: str, relative_path: str) -> dict:
|
||||
root, rel, target = _resolve_managed_file(root_id, relative_path)
|
||||
if not root.allow_delete:
|
||||
raise PermissionError(f"Deleting files is disabled for {root_id}")
|
||||
if not target.exists():
|
||||
raise FileNotFoundError(relative_path)
|
||||
target.unlink()
|
||||
return {
|
||||
"root_id": root.root_id,
|
||||
"path": rel.as_posix(),
|
||||
"message": "Deleted successfully",
|
||||
}
|
||||
|
||||
|
||||
def _parse_kv_output(text: str) -> dict[str, str]:
|
||||
data: dict[str, str] = {}
|
||||
for raw in text.splitlines():
|
||||
line = raw.strip()
|
||||
if not line or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
data[key.strip().lower()] = value.strip()
|
||||
return data
|
||||
|
||||
|
||||
async def _run_command(cmd: list[str], *, timeout: float = 20.0) -> tuple[int, str]:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=str(BASE_DIR),
|
||||
)
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
await proc.wait()
|
||||
return 124, "Command timed out"
|
||||
out = (stdout or b"").decode("utf-8", "replace").strip()
|
||||
err = (stderr or b"").decode("utf-8", "replace").strip()
|
||||
merged = out if out else err
|
||||
return proc.returncode or 0, merged
|
||||
|
||||
|
||||
def _default_service_status(spec: ServiceSpec, detail: str) -> dict:
|
||||
return {
|
||||
"service_id": spec.service_id,
|
||||
"label": spec.label,
|
||||
"category": spec.category,
|
||||
"description": spec.description,
|
||||
"available": False,
|
||||
"running": False,
|
||||
"status": "unavailable",
|
||||
"detail": detail,
|
||||
"url": "",
|
||||
}
|
||||
|
||||
|
||||
async def query_service(spec: ServiceSpec) -> dict:
|
||||
if sys.platform == "win32":
|
||||
return _default_service_status(spec, "Linux-only stack integration")
|
||||
if not SERVICE_CTL.is_file():
|
||||
return _default_service_status(spec, "Service controller script is missing")
|
||||
code, output = await _run_command(["bash", str(SERVICE_CTL), spec.service_id, "status"])
|
||||
if code != 0:
|
||||
return _default_service_status(spec, output or "Status command failed")
|
||||
info = _parse_kv_output(output)
|
||||
return {
|
||||
"service_id": spec.service_id,
|
||||
"label": spec.label,
|
||||
"category": spec.category,
|
||||
"description": spec.description,
|
||||
"available": info.get("available", "0") == "1",
|
||||
"running": info.get("running", "0") == "1",
|
||||
"status": info.get("status", "unknown"),
|
||||
"detail": info.get("detail", ""),
|
||||
"url": info.get("url", ""),
|
||||
"host": info.get("host", ""),
|
||||
"port": info.get("port", ""),
|
||||
}
|
||||
|
||||
|
||||
async def query_services() -> list[dict]:
|
||||
return list(await asyncio.gather(*(query_service(spec) for spec in SERVICE_SPECS)))
|
||||
|
||||
|
||||
async def service_action(service_id: str, action: str) -> dict:
|
||||
if action not in {"start", "stop", "restart"}:
|
||||
raise ValueError(f"Unsupported action: {action}")
|
||||
spec = next((item for item in SERVICE_SPECS if item.service_id == service_id), None)
|
||||
if not spec:
|
||||
raise KeyError(f"Unknown service: {service_id}")
|
||||
if sys.platform == "win32":
|
||||
return _default_service_status(spec, "Linux-only stack integration")
|
||||
if not SERVICE_CTL.is_file():
|
||||
return _default_service_status(spec, "Service controller script is missing")
|
||||
|
||||
command = ["bash", str(SERVICE_CTL), service_id, action]
|
||||
if shutil.which("sudo"):
|
||||
command = ["sudo", "-n", "bash", str(SERVICE_CTL), service_id, action]
|
||||
code, output = await _run_command(command, timeout=45.0)
|
||||
info = _parse_kv_output(output)
|
||||
message = info.get("message", output)
|
||||
if code != 0:
|
||||
return {
|
||||
"service_id": service_id,
|
||||
"status": "error",
|
||||
"message": message or "Action failed",
|
||||
"detail": output,
|
||||
}
|
||||
status = await query_service(spec)
|
||||
status["message"] = message or f"{action} completed"
|
||||
return status
|
||||
|
||||
|
||||
def local_ips() -> list[str]:
|
||||
ips: list[str] = []
|
||||
if shutil.which("ip"):
|
||||
try:
|
||||
raw = subprocess.check_output(
|
||||
["ip", "-j", "-4", "addr", "show", "scope", "global"],
|
||||
cwd=str(BASE_DIR),
|
||||
text=True,
|
||||
)
|
||||
ignored_prefixes = (
|
||||
"docker",
|
||||
"br-",
|
||||
"veth",
|
||||
"virbr",
|
||||
"tun",
|
||||
"tap",
|
||||
"zt",
|
||||
"tailscale",
|
||||
"wg",
|
||||
"sit",
|
||||
"dummy",
|
||||
)
|
||||
for item in json.loads(raw):
|
||||
ifname = str(item.get("ifname", ""))
|
||||
if not ifname or ifname == "lo" or ifname.startswith(ignored_prefixes):
|
||||
continue
|
||||
for addr in item.get("addr_info", []):
|
||||
ip = str(addr.get("local", "")).strip()
|
||||
if ip and not ip.startswith("127.") and ip not in ips:
|
||||
ips.append(ip)
|
||||
if ips:
|
||||
return ips
|
||||
except (OSError, ValueError, subprocess.SubprocessError, json.JSONDecodeError):
|
||||
pass
|
||||
|
||||
hostname = socket.gethostname()
|
||||
try:
|
||||
for info in socket.getaddrinfo(hostname, None, family=socket.AF_INET):
|
||||
ip = info[4][0]
|
||||
if ip and not ip.startswith("127.") and ip not in ips:
|
||||
ips.append(ip)
|
||||
except OSError:
|
||||
pass
|
||||
return ips
|
||||
|
||||
|
||||
def load_stack_env() -> dict[str, str]:
|
||||
if not STACK_ENV.is_file():
|
||||
return {}
|
||||
values: dict[str, str] = {}
|
||||
for raw in STACK_ENV.read_text(encoding="utf-8").splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
values[key.strip()] = value.strip().strip('"').strip("'")
|
||||
return values
|
||||
|
||||
|
||||
def stack_summary() -> dict:
|
||||
env = load_stack_env()
|
||||
hostname = env.get("HOSTNAME_ALIAS") or env.get("HOSTNAME") or socket.gethostname()
|
||||
web_port = env.get("PORT", os.environ.get("PORT", "8001"))
|
||||
homepage_port = env.get("HOMEPAGE_PORT", "80")
|
||||
mdns_host = f"{hostname}.local"
|
||||
return {
|
||||
"hostname": hostname,
|
||||
"mdns_host": mdns_host,
|
||||
"platform": sys.platform,
|
||||
"machine": platform.machine(),
|
||||
"kernel": platform.release(),
|
||||
"python": platform.python_version(),
|
||||
"ips": local_ips(),
|
||||
"dashboard_url": f"http://{mdns_host}" if homepage_port in {"80", ""} else f"http://{mdns_host}:{homepage_port}",
|
||||
"control_url": f"http://{mdns_host}:{web_port}",
|
||||
"stack_env_present": STACK_ENV.is_file(),
|
||||
}
|
||||
|
||||
|
||||
def _read_meminfo() -> dict[str, int]:
|
||||
info: dict[str, int] = {}
|
||||
meminfo = Path("/proc/meminfo")
|
||||
if not meminfo.is_file():
|
||||
return info
|
||||
for raw in meminfo.read_text(encoding="utf-8").splitlines():
|
||||
if ":" not in raw:
|
||||
continue
|
||||
key, _, value = raw.partition(":")
|
||||
parts = value.strip().split()
|
||||
if not parts:
|
||||
continue
|
||||
try:
|
||||
info[key] = int(parts[0]) * 1024
|
||||
except ValueError:
|
||||
continue
|
||||
return info
|
||||
|
||||
|
||||
def system_snapshot() -> dict:
|
||||
disk = shutil.disk_usage(BASE_DIR.anchor if sys.platform == "win32" else "/")
|
||||
meminfo = _read_meminfo()
|
||||
load = os.getloadavg() if hasattr(os, "getloadavg") else (0.0, 0.0, 0.0)
|
||||
snapshot = {
|
||||
"cpu_load": {"1m": load[0], "5m": load[1], "15m": load[2]},
|
||||
"disk": {"total": disk.total, "used": disk.used, "free": disk.free},
|
||||
"memory": {
|
||||
"total": meminfo.get("MemTotal", 0),
|
||||
"available": meminfo.get("MemAvailable", 0),
|
||||
"free": meminfo.get("MemFree", 0),
|
||||
},
|
||||
"netdata": {"available": False},
|
||||
}
|
||||
if sys.platform != "win32":
|
||||
try:
|
||||
with urlopen("http://127.0.0.1:19999/api/v1/info", timeout=1.5) as response:
|
||||
payload = json.loads(response.read().decode("utf-8", "replace"))
|
||||
snapshot["netdata"] = {
|
||||
"available": True,
|
||||
"version": payload.get("version", ""),
|
||||
"mirrored_hosts": payload.get("mirrored_hosts", []),
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
return snapshot
|
||||
|
||||
|
||||
async def load_hardware_profile(force_refresh: bool = False) -> dict:
|
||||
if force_refresh and HW_PROBE.is_file() and shutil.which(sys.executable):
|
||||
await _run_command([sys.executable, str(HW_PROBE), "--write"], timeout=30.0)
|
||||
if HW_PROFILE_JSON.is_file():
|
||||
try:
|
||||
return json.loads(HW_PROFILE_JSON.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
if HW_PROBE.is_file():
|
||||
code, output = await _run_command([sys.executable, str(HW_PROBE)], timeout=30.0)
|
||||
if code == 0 and output:
|
||||
try:
|
||||
return json.loads(output)
|
||||
except json.JSONDecodeError:
|
||||
return {"status": "error", "detail": output}
|
||||
return {
|
||||
"status": "unavailable",
|
||||
"detail": "Hardware probe not available",
|
||||
"arch": platform.machine(),
|
||||
"kernel": platform.release(),
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
from typing import Dict, Any
|
||||
from .. import utils
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import gzip
|
||||
import urllib.parse
|
||||
import urllib.error
|
||||
|
||||
@@ -6,6 +6,8 @@ GitHub:https://github.com/ihmily
|
||||
Copyright (c) 2024 by Hmily, All Rights Reserved.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
@@ -7,6 +7,9 @@ Date: 2023-07-17 23:52:05
|
||||
Update: 2025-02-04 04:57:00
|
||||
Copyright (c) 2023 by Hmily, All Rights Reserved.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import urllib.parse
|
||||
import execjs
|
||||
|
||||
@@ -9,6 +9,8 @@ Copyright (c) 2023-2025 by Hmily, All Rights Reserved.
|
||||
Function: Get live stream data.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import random
|
||||
import subprocess
|
||||
@@ -3392,4 +3394,4 @@ async def get_picarto_stream_url(url: str, proxy_addr: OptionalStr = None, cooki
|
||||
title = json_data['channel']['title']
|
||||
m3u8_url = f"https://1-edge1-us-newyork.picarto.tv/stream/hls/golive+{anchor_name}/index.m3u8"
|
||||
result |= {'is_live': True, 'title': title, 'm3u8_url': m3u8_url, 'record_url': m3u8_url}
|
||||
return result
|
||||
return result
|
||||
|
||||
@@ -8,6 +8,9 @@ Update: 2025-02-06 02:28:00
|
||||
Copyright (c) 2023-2025 by Hmily, All Rights Reserved.
|
||||
Function: Get live stream data.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
@@ -443,4 +446,4 @@ async def get_stream_url(json_data: dict, video_quality: str, url_type: str = 'm
|
||||
data |= {"flv_url": flv_url, "record_url": flv_url}
|
||||
data['title'] = json_data.get('title')
|
||||
data['quality'] = video_quality
|
||||
return data
|
||||
return data
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
@@ -203,4 +205,4 @@ def get_query_params(url: str, param_name: OptionalStr) -> dict | list[str]:
|
||||
else:
|
||||
values = query_params.get(param_name, [])
|
||||
return values
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user