4d4d38fe0f
迁自 park-desktop/backend/app:config(数据目录→serverdata/park)/storage/sim_engine/ mqtt/event_bus + 精简 routers(health/settings/config/dashboard/park/companies/display/ events) + app.py 子应用(lifespan 起 MQTT hub+tick 线程, include_router prefix=/park)。 dispatcher.py 增 ROUTE_PARK_PREFIX + _prefix_app,与 app.training(/api)、app.main 分流。 pyproject 增 paho-mqtt(MQTT 必需)。AI/知识库/语音/视觉重模块延后全量迁入。
180 lines
5.4 KiB
Python
180 lines
5.4 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""园区子应用(app/park)—— 大屏控制 / 数据 / 设置 / 园区企业(骨架版)。
|
||
|
||
迁自 park-desktop/backend/app/routers.py,本骨架仅含不依赖重模块(llm/rag/asr/
|
||
s2s/vision)的大屏控制端点;AI/知识库/语音/视觉在后续全量迁移补齐。
|
||
全部端点经 dispatcher `/park` 前缀暴露(include_router 挂 `/park`)。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
import time
|
||
|
||
from fastapi import APIRouter, Request
|
||
from fastapi.responses import JSONResponse, StreamingResponse
|
||
from pydantic import BaseModel, Field
|
||
|
||
from .config import settings
|
||
from .event_bus import bus
|
||
from .mqtt import hub
|
||
from .sim_engine import sim_engine
|
||
from .storage import storage
|
||
|
||
log = logging.getLogger("dpm.api")
|
||
|
||
router = APIRouter()
|
||
|
||
|
||
# ==================== 数据模型 ====================
|
||
|
||
class SettingsBody(BaseModel):
|
||
volume: int | None = None
|
||
sfx_volume: int | None = None
|
||
play_mode: str | None = None
|
||
image_duration: int | None = None
|
||
fullscreen: bool | None = None
|
||
autostart: bool | None = None
|
||
username: str | None = None
|
||
password: str | None = None
|
||
|
||
|
||
class DisplayCommandBody(BaseModel):
|
||
action: str
|
||
params: dict = Field(default_factory=dict)
|
||
screen_id: str = ""
|
||
screen_role: str = ""
|
||
|
||
|
||
class RegisterBody(BaseModel):
|
||
device_id: str = ""
|
||
role: str = ""
|
||
parent_id: str = ""
|
||
|
||
|
||
# ==================== 健康 / 设置 / 引导 ====================
|
||
|
||
@router.get("/api/health")
|
||
async def health():
|
||
return {
|
||
"ok": True,
|
||
"service": "opc-park",
|
||
"mqtt_connected": hub.connected,
|
||
"screens_online": hub.screens_online(),
|
||
"version": "1.0.0",
|
||
}
|
||
|
||
|
||
@router.get("/api/settings")
|
||
async def get_settings():
|
||
return storage.get_settings()
|
||
|
||
|
||
@router.post("/api/settings")
|
||
async def update_settings(body: SettingsBody):
|
||
patch = body.model_dump(exclude_none=True)
|
||
storage.update_settings(**patch)
|
||
hub.publish_command("settings_changed", patch)
|
||
return storage.get_settings()
|
||
|
||
|
||
@router.get("/api/config")
|
||
async def runtime_config(request: Request):
|
||
"""大屏启动引导:下发 MQTT/语音接入地址与 tick 间隔。"""
|
||
return {
|
||
"mqtt": {
|
||
"host": settings.MQTT_HOST,
|
||
"port": settings.MQTT_PORT,
|
||
"ws_url": settings.MQTT_WS_URL,
|
||
"tick_interval": settings.MQTT_TICK_INTERVAL,
|
||
},
|
||
"voice_ws": settings.S2S_WS_URL if settings.S2S_ENABLED else "",
|
||
"media_base": f"{request.base_url}park/file",
|
||
"api_base": f"{request.base_url}park/api",
|
||
}
|
||
|
||
|
||
# ==================== 大屏数据(sim_engine) ====================
|
||
|
||
@router.get("/api/dashboard/snapshot")
|
||
async def dashboard_snapshot():
|
||
return sim_engine.snapshot()
|
||
|
||
|
||
@router.get("/api/dashboard/overview")
|
||
async def dashboard_overview():
|
||
return sim_engine.snapshot()
|
||
|
||
|
||
@router.get("/api/park/companies")
|
||
async def park_companies():
|
||
snap = sim_engine.snapshot()
|
||
return snap.get("companies", [])
|
||
|
||
|
||
@router.get("/api/park/zones")
|
||
async def park_zones():
|
||
snap = sim_engine.snapshot()
|
||
return snap.get("zones", [])
|
||
|
||
|
||
# ==================== 展示控制(管理端 → MQTT) ====================
|
||
# 全部 MQTT 前端控制命令白名单(与 park-desktop docs/mqtt-commands.md 一致)
|
||
_VALID_DISPLAY_ACTIONS = {
|
||
"navigate", "navigate_rel",
|
||
"vision_set",
|
||
"ai_input", "ai_preset", "ai_company", "ai_zone",
|
||
"voice_start", "voice_stop", "voice_refresh",
|
||
"play", "pause", "next", "prev", "set_mode", "play_target",
|
||
"dual_screen",
|
||
"alert", "show_card", "minimize", "settings_changed", "playlist_changed",
|
||
}
|
||
|
||
|
||
@router.post("/api/display/register")
|
||
async def display_register(body: RegisterBody):
|
||
hub.register_device(body.device_id, body.role, body.parent_id)
|
||
return {"ok": True, "device_id": body.device_id, "role": body.role, "parent_id": body.parent_id}
|
||
|
||
|
||
@router.post("/api/display/command")
|
||
async def display_command_publish(body: DisplayCommandBody):
|
||
if body.action not in _VALID_DISPLAY_ACTIONS:
|
||
return JSONResponse({"ok": False, "error": f"不支持的指令: {body.action}"}, status_code=400)
|
||
cmd = hub.publish_command(body.action, body.params, body.screen_id or None, body.screen_role or None)
|
||
return {"ok": cmd.get("published", False), "cmd_id": cmd["cmd_id"], "action": body.action,
|
||
"screen_id": body.screen_id or "", "screen_role": body.screen_role or "both",
|
||
"mqtt_connected": hub.connected}
|
||
|
||
|
||
@router.get("/api/display/state")
|
||
async def display_state():
|
||
return hub.status()
|
||
|
||
|
||
# ==================== SSE 兼容通道(MQTT 不可用时前端回退) ====================
|
||
|
||
@router.get("/api/events")
|
||
async def sse_events(request: Request):
|
||
async def gen():
|
||
q = bus.subscribe()
|
||
try:
|
||
while True:
|
||
if await request.is_disconnected():
|
||
break
|
||
try:
|
||
data = await asyncio_timeout(q.get(), 15)
|
||
yield f"data: {data}\n\n"
|
||
except Exception: # noqa: BLE001
|
||
yield ": keepalive\n\n"
|
||
finally:
|
||
bus.unsubscribe(q)
|
||
|
||
return StreamingResponse(gen(), media_type="text/event-stream")
|
||
|
||
|
||
def asyncio_timeout(awaitable, seconds):
|
||
"""极简超时包装:避免依赖 asyncio.timeout(3.11+ 的上下文管理器)。"""
|
||
import asyncio
|
||
return asyncio.wait_for(awaitable, timeout=seconds)
|