feat(webui): FastAPI REST + WebSocket-консоль + MCP-эндпоинт + браузерный UI
- server.py: /api/power/*, /api/status, /api/history, /ws/console, /mcp (MCPServer streamable-http) - auth: Bearer-токен (ATXPI_WEB_TOKEN) для /api/* и /mcp/*; WS — через ?token= - статика: index.html, app.js, style.css + vendored xterm.js (работает офлайн на Pi) - lightweight: один процесс uvicorn, без node/сборки - install.sh: отдаю сервис-юзеру владение atxpi/ и /etc/atxpi (pyc/tmp/audit)
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
#!/usr/bin/env python3
|
||||
"""atxpi — web-плоскость управления (FastAPI).
|
||||
|
||||
Отдаёт:
|
||||
/ браузерный UI (index.html)
|
||||
/static/* статика (в т.ч. vendored xterm.js)
|
||||
/api/power REST: GET — статус, POST on|off|reset|cycle
|
||||
/api/status сводка по устройству
|
||||
/api/history журнал действий (audit)
|
||||
/ws/console WebSocket — двунаправленная RS232-консоль
|
||||
/mcp MCP-эндпоинт (streamable-http) для агентов
|
||||
|
||||
Аутентификация: Bearer-токен (ATXPI_WEB_TOKEN) в заголовке Authorization
|
||||
(для /ws/console также принимается ?token=...).
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from atxpi.core import power as power_mod
|
||||
from atxpi.core.hardware import make_console, DRIVER, SERIAL_PORT, SERIAL_BAUD
|
||||
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
|
||||
log = logging.getLogger("atxpi.webui")
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
|
||||
STATIC = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
|
||||
WEB_TOKEN = os.environ.get("ATXPI_WEB_TOKEN", "")
|
||||
AUDIT = os.environ.get("ATXPI_AUDIT", "/etc/atxpi/audit.jsonl")
|
||||
|
||||
|
||||
def audit(action: str, **kw):
|
||||
"""Логируем действие в jsonl (для /api/history)."""
|
||||
entry = {"t": time.time(), "action": action, **kw}
|
||||
try:
|
||||
Path(AUDIT).parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(AUDIT, "a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
||||
except Exception as e:
|
||||
log.warning("audit: %s", e)
|
||||
return entry
|
||||
|
||||
|
||||
def _history(n: int = 100):
|
||||
if not os.path.exists(AUDIT):
|
||||
return []
|
||||
try:
|
||||
with open(AUDIT, "r", encoding="utf-8") as f:
|
||||
lines = f.readlines()[-n:]
|
||||
return [json.loads(l) for l in lines if l.strip()]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
app = FastAPI(title="atxpi", version="0.1.0")
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def auth_mw(request: Request, call_next):
|
||||
"""Требуем Bearer-токен для /api/* и /mcp/* (если токен задан)."""
|
||||
path = request.url.path
|
||||
if path.startswith(("/api", "/mcp")) and WEB_TOKEN:
|
||||
got = request.headers.get("authorization", "").replace("Bearer ", "").strip()
|
||||
if got != WEB_TOKEN:
|
||||
return JSONResponse({"detail": "unauthorized"}, status_code=401)
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
# ── REST: питание ──────────────────────────────────────────────────────────
|
||||
@app.get("/api/power")
|
||||
async def api_power_status():
|
||||
return power_mod.status()
|
||||
|
||||
|
||||
@app.post("/api/power/on")
|
||||
async def api_power_on():
|
||||
audit("power_on")
|
||||
return power_mod.press_power()
|
||||
|
||||
|
||||
@app.post("/api/power/off")
|
||||
async def api_power_off():
|
||||
audit("power_off")
|
||||
return power_mod.force_off()
|
||||
|
||||
|
||||
@app.post("/api/power/reset")
|
||||
async def api_power_reset():
|
||||
audit("power_reset")
|
||||
return power_mod.press_reset()
|
||||
|
||||
|
||||
@app.post("/api/power/cycle")
|
||||
async def api_power_cycle():
|
||||
audit("power_cycle")
|
||||
return power_mod.cycle()
|
||||
|
||||
|
||||
@app.get("/api/status")
|
||||
async def api_status():
|
||||
s = power_mod.status()
|
||||
return {
|
||||
"power": s["power"],
|
||||
"powered_on": s["powered_on"],
|
||||
"driver": DRIVER,
|
||||
"serial_port": SERIAL_PORT,
|
||||
"serial_baud": SERIAL_BAUD,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/history")
|
||||
async def api_history():
|
||||
return _history()
|
||||
|
||||
|
||||
# ── WebSocket консоль ───────────────────────────────────────────────────────
|
||||
@app.websocket("/ws/console")
|
||||
async def ws_console(ws: WebSocket):
|
||||
token = ws.query_params.get("token", "")
|
||||
if WEB_TOKEN and token != WEB_TOKEN:
|
||||
await ws.close(code=1008)
|
||||
return
|
||||
await ws.accept()
|
||||
console = make_console()
|
||||
try:
|
||||
console.open()
|
||||
except Exception as e:
|
||||
await ws.send_text(json.dumps({"error": f"console open failed: {e}"}))
|
||||
await ws.close()
|
||||
return
|
||||
|
||||
console.send(("\r\nATXPI console ready. driver=%s\r\n" % DRIVER).encode())
|
||||
|
||||
async def reader():
|
||||
try:
|
||||
while True:
|
||||
data = await asyncio.to_thread(console.read, 0.25)
|
||||
if data:
|
||||
await ws.send_bytes(data)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
read_task = asyncio.create_task(reader())
|
||||
try:
|
||||
while True:
|
||||
msg = await ws.receive()
|
||||
if msg["type"] == "websocket.disconnect":
|
||||
break
|
||||
data = msg.get("bytes") or msg.get("text")
|
||||
if data:
|
||||
if isinstance(data, str):
|
||||
data = data.encode()
|
||||
console.send(data)
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
read_task.cancel()
|
||||
try:
|
||||
await ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
console.close()
|
||||
|
||||
|
||||
# ── MCP (streamable-http endpoint, для агентов) ─────────────────────────────
|
||||
mcp = MCPServer("atxpi")
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def power_status() -> dict:
|
||||
"""Текущее состояние питания сервера (on/off)."""
|
||||
return power_mod.status()
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def power_on() -> dict:
|
||||
"""Нажать кнопку питания (вкл/выкл-переключатель)."""
|
||||
audit("power_on")
|
||||
return power_mod.press_power()
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def power_off() -> dict:
|
||||
"""Принудительное выключение (длинное нажатие кнопки питания)."""
|
||||
audit("power_off")
|
||||
return power_mod.force_off()
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def power_reset() -> dict:
|
||||
"""Аппаратный сброс сервера."""
|
||||
audit("power_reset")
|
||||
return power_mod.press_reset()
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def power_cycle() -> dict:
|
||||
"""Жёсткий перезапуск: выключить, пауза, включить."""
|
||||
audit("power_cycle")
|
||||
return power_mod.cycle()
|
||||
|
||||
|
||||
app.mount("/mcp", mcp.streamable_http_app())
|
||||
|
||||
|
||||
# ── статика ─────────────────────────────────────────────────────────────────
|
||||
@app.get("/")
|
||||
async def index():
|
||||
return FileResponse(os.path.join(STATIC, "index.html"))
|
||||
|
||||
|
||||
app.mount("/static", StaticFiles(directory=STATIC), name="static")
|
||||
Reference in New Issue
Block a user