This commit is contained in:
eric
2026-03-28 08:39:12 -05:00
parent 4b3c6cbee4
commit b84f9b751c
49 changed files with 3728 additions and 771 deletions

View File

@@ -1,4 +1,6 @@
# -*- encoding: utf-8 -*-
from __future__ import annotations
import math
import time

423
src/android_control.py Normal file
View 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
View 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(),
}

View File

@@ -1,4 +1,6 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import httpx
from typing import Dict, Any
from .. import utils

View File

@@ -1,4 +1,6 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import gzip
import urllib.parse
import urllib.error

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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