feat: 拆出 FastAPI 独立后端 + MQTT 控制 + 阿里云 AI

后端(backend/,FastAPI :10085):
- 全部数据 REST 接口:dashboard 快照 / 企业分区 / 播放列表 / 媒体 / 设置
- MQTT 控制通道:opc/display/command(切页/播放/卡片/通知),
  管理端 POST /api/display/command → MQTT 广播 → 所有大屏同步响应
- 阿里云 DashScope:通义千问 LLM + 函数调用(工具经 MQTT 广播),
  paraformer-realtime-v2 语音识别 /api/ai/asr
- 媒体资源统一由后端存储返回(上传/列表/静态服务)
- SSE /api/events 保留作 MQTT 不可用时的兼容回退

前端:
- config.js + .env.local 配置后端地址与 MQTT 账号(前端 dpm / 服务端 dpmserver)
- mqtt.js 客户端 + useMqttControl(MQTT 驱动切页/媒体/卡片/通知)
- DpmOverlays 全局覆盖层(通知 toast + 企业/分区/总览卡片)
- useParkSim 改为后端 API 数据源(离线回退本地模拟)
- AiChatPanel:对话走后端 LLM(工具调用),语音走本地录音 + 后端 ASR
- MediaScreen:媒体控制走 MQTT(保留 SSE 回退),修复 useEffect TDZ

Rust:lib.rs 移除内嵌 HTTP 服务器,只保留薄壳(窗口/权限/自启/npc 隧道)
安全:backend/.env、.env.local、media、data.json 已 gitignore
This commit is contained in:
Pine
2026-08-17 21:25:46 +08:00
parent 7f1c2ac4f0
commit 856ff88440
34 changed files with 3639 additions and 378 deletions
View File
+174
View File
@@ -0,0 +1,174 @@
# -*- coding: utf-8 -*-
"""AI 助手工具调用引擎
后端收到对话 → 规则引擎判断意图 → 产出文字回复 + 工具调用列表
工具执行时通过 MQTT 广播 opc/display/command,前端(所有大屏)同步响应
工具:navigate(切页) / control(媒体控制) / alert(通知) / show_card(展示卡片)
"""
from .mqtt import hub
# ---------------- 工具执行 ----------------
def execute_tool(tool):
"""执行一个工具调用:发布 MQTT 控制指令,返回是否发布成功"""
t = tool.get("type")
params = tool.get("params") or {}
if t == "navigate":
return hub.publish_command("navigate", {"page": params.get("page", "home")}).get("published", False)
if t == "control":
action = params.get("action")
if action in ("play", "pause", "next", "prev"):
return hub.publish_command(action).get("published", False)
if action == "set_mode":
return hub.publish_command("set_mode", {"mode": params.get("mode", "auto")}).get("published", False)
return False
if t == "alert":
return hub.publish_command("alert", {"title": params.get("title", "提示"), "content": params.get("content", "")}).get("published", False)
if t == "show_card":
return hub.publish_command("show_card", params).get("published", False)
return False
def run_tools(tools):
"""执行一组工具,返回 [{type, ok}] 并汇总是否全部发布成功"""
results = []
all_ok = True
for tool in tools or []:
ok = bool(execute_tool(tool))
results.append({"type": tool.get("type"), "ok": ok})
all_ok = all_ok and ok
return results, all_ok
# ---------------- 意图 → 回复 + 工具 ----------------
PAGES = {
"home": "/", "数据大屏": "/", "首页": "/",
"twin": "/twin", "数字孪生": "/twin", "3d": "/twin", "孪生": "/twin",
"ai": "/ai", "ai助手": "/ai", "智能助手": "/ai",
"screen": "/screen", "媒体": "/screen", "轮播": "/screen", "媒体轮播": "/screen",
}
RULES = [
{
"match": ["入驻", "申请", "入孵", "入园", "如何加入", "怎样加入"],
"reply": "入驻流程如下:\n\n1. 提交入驻申请(线上表单或到园区服务中心现场办理)\n2. 项目评审(园区组织专家对项目可行性、团队背景进行评估)\n3. 签订入驻协议,办理入驻手续\n4. 安排办公场地,对接创业导师与孵化服务\n\n目前园区在园项目 158 家,累计孵化企业 208 余家。如需协助,可直接联系园区服务中心。",
"tools": [{"type": "show_card", "params": {"card": "overview"}}],
},
{
"match": ["政策", "补贴", "优惠", "扶持", "减免", "奖励", "资助"],
"reply": "园区政策服务覆盖「七位一体」中的政策维度:\n\n· 场地租金减免:符合条件的大学生创业项目可享受场地优惠\n· 创业补贴:协助对接省、市两级创业补贴与一次性创业资助\n· 税费优惠:对接小微企业所得税减免、增值税优惠等政策\n· 项目申报:指导高企认定(目前在园高新技术企业 32 家)、科技型中小企业备案\n\n更多政策细节可咨询园区政策服务窗口。",
"tools": [],
},
{
"match": ["场地", "办公室", "工位", "租金", "收费", "面积", "多少钱", "价格"],
"reply": "园区总建筑面积 1.2 万㎡,按功能划分为三个区域:\n\n· 加速区(A1–A12):成长型项目办公室,共 11 间独立办公室 + 1 间园区管理办公室\n· 国际区(I1–I7):面向国际合作的办公空间(7 间)\n· 成长区(G1–G8):初创团队成长空间(8 间)\n\n租金按创业项目阶段与政策给予差异化优惠,具体可到园区服务中心咨询。",
"tools": [{"type": "show_card", "params": {"card": "zones"}}],
},
{
"match": ["企业", "公司", "名录", "名单", "入驻企业", "有哪些企业", "查企业"],
"reply": "园区目前有 39 家入驻企业,分布在三个区域:\n\n· 加速区:云南派音人工智能科技、米勒克尔蓝宝石珠宝、云南宸中低空经济等 9 家\n· 国际区:仰光客厅、中越生物医疗、酷享野农AI农业等 7 家\n· 成长区:Facebook越南跨境电商、昆明云韵体育、云南星瑞航空等 8 家\n\n我已在屏幕上为您展示企业分布卡片。",
"tools": [{"type": "show_card", "params": {"card": "companies"}}],
},
{
"match": ["融资", "投资", "资金", "贷款", "估值", "资本"],
"reply": "园区融资服务包含:\n\n· 融资对接:定期组织路演与投资机构对接活动\n· 孵化估值:累计孵化企业估值约 5.2 亿元\n· 金融支持:协助对接银行创业贷款、政府创业担保贷款\n· 财务辅导:提供融资方案设计与商业计划书辅导",
"tools": [],
},
{
"match": ["就业", "招聘", "岗位", "人才", "用工"],
"reply": "园区累计带动就业 2,186 人(其中高校毕业生 1,680 人,实习岗位 506 个)。\n\n· 企业招聘:在驻企业持续发布岗位\n· 人才对接:与在昆高校共建实习实训基地\n· 创业带动:园区鼓励以创业带动就业",
"tools": [],
},
{
"match": ["导师", "培训", "课程", "辅导", "孵化服务", "孵化器"],
"reply": "园区配备创业导师 58 位,提供全周期孵化服务:\n\n· 一对一辅导:创业导师定期坐诊,覆盖商业模式、法律财税、市场营销\n· 培训课程:创业训练营、行业专题沙龙、政策解读会\n· 资源对接:技术、供应链、渠道等产业资源链接\n· 公共服务:工商注册、知识产权、法律咨询一站式代办",
"tools": [],
},
{
"match": ["AI", "模型", "算力", "tokens", "token", "工具", "智能", "赋能", "大模型"],
"reply": "园区 AI 赋能服务是「七位一体」的核心特色:\n\n· 大模型推理:接入 DeepSeek-V3、通义千问、智谱 GLM-4、豆包、讯飞星火等多模型\n· 工具调用:文档生成、数据查询、图像创作、代码执行、语音合成等智能工具\n· 算力与成本:实时提供 token 消耗统计与速率监控\n\n今日园区 AI 推理任务持续运行,服务节点 12/12 在线。",
"tools": [],
},
{
"match": ["加速区", "国际区", "成长区", "分区", "区域", "区别", "布局"],
"reply": "园区按企业成长阶段划分三个区域:\n\n· 加速区(蓝色):成长型项目独立办公室(A1–A12),在驻 9 家\n· 国际区(青色):国际合作与跨境业务(I1–I7),在驻 7 家\n· 成长区(绿色):初创团队共享空间(G1–G8),在驻 8 家\n\n园区管理办公室位于加速区 M 房间。",
"tools": [{"type": "show_card", "params": {"card": "zones"}}],
},
{
"match": ["切", "去", "打开", "显示", "进入"],
"reply": None, # 由下方页面/控制规则补充
},
{
"match": ["你好", "您好", "hi", "hello", "嗨", "在吗"],
"reply": "您好!我是 OPC 园区 AI 智能助手。\n\n可以问我:入驻流程、政策扶持、场地收费、AI 赋能、企业名录、融资服务等问题;也可以让我「切换到数字孪生页」「打开媒体轮播」或「展示企业卡片」。",
"tools": [],
},
{
"match": ["谢谢", "感谢", "好的", "明白"],
"reply": "不客气,随时为您服务。您也可以让我展示园区数据卡片,或切换到任意展示页面。",
"tools": [],
},
]
DEFAULT_REPLY = "园区提供「空间 + 孵化 + 融资 + 政策 + 资源 + AI 赋能 + 综合服务」七位一体服务,在园项目 158 家,累计带动就业 2,186 人,累计营收 2.08 亿元。\n\n您可以这样问我:\n· 如何申请入驻园区?\n· 展示入驻企业卡片\n· 切换到数字孪生页面"
def _detect_page(text):
for key, path in PAGES.items():
if key in text:
return path
return None
def _detect_control(text):
if any(k in text for k in ["播放", "继续播放", "开始播放"]):
return "play"
if any(k in text for k in ["暂停", "停止播放"]):
return "pause"
if any(k in text for k in ["下一个", "下一张", "下一屏", "切换下一"]):
return "next"
if any(k in text for k in ["上一个", "上一张", "上一屏", "切换上一"]):
return "prev"
return None
def run_chat(messages):
"""输入对话消息列表([{"role","content"}]),返回 {reply, tools}"""
text = ""
for m in reversed(messages or []):
if m.get("role") in ("user", "me"):
text = (m.get("content") or "").strip()
break
reply = None
tools = []
# 1) 页面切换意图(优先级最高)
page = _detect_page(text)
if page:
label = {"/": "数据大屏", "/twin": "数字孪生", "/ai": "AI 助手", "/screen": "媒体轮播"}.get(page, page)
reply = f"好的,已为您切换到「{label}」页面。"
tools = [{"type": "navigate", "params": {"page": page}}]
return {"reply": reply, "tools": tools}
# 2) 媒体控制意图
ctrl = _detect_control(text)
if ctrl:
names = {"play": "播放", "pause": "暂停", "next": "切换下一项", "prev": "切换上一项"}
reply = f"好的,已执行:{names[ctrl]}。"
tools = [{"type": "control", "params": {"action": ctrl}}]
return {"reply": reply, "tools": tools}
# 3) 知识库规则
for rule in RULES:
if any(k in text for k in rule["match"]):
reply = rule["reply"]
tools = rule["tools"]
break
if reply is None:
reply = DEFAULT_REPLY
# 工具已在后端通过 MQTT 广播;返回值供请求端本地同步执行
return {"reply": reply, "tools": tools}
+93
View File
@@ -0,0 +1,93 @@
# -*- coding: utf-8 -*-
"""语音识别 —— 阿里云 DashScope paraformer-realtime-v2(流式)
前端录音上传(m4a/wav/ogg/opus/pcm)→ 这里转写为文本
"""
import logging
import os
import time
import certifi
# macOS 系统 Python 无系统 CA:必须在导入 dashscope 之前设置,
# aiohttp 才会使用 certifi 根证书(否则 WS 连接报 SSL 证书错误)
os.environ.setdefault("SSL_CERT_FILE", certifi.where())
import dashscope # noqa: E402
from dashscope.audio.asr import Recognition, RecognitionCallback # noqa: E402
from .config import settings # noqa: E402
log = logging.getLogger("dpm.asr")
def _err_text(result):
"""安全提取 RecognitionResult 的错误描述(其 __str__ 有缺陷)"""
for attr in ("message", "code"):
v = getattr(result, attr, None)
if v:
return f"{attr}={v}"
return repr(result)
# 前端 MediaRecorder 可能产生的容器/编码 → paraformer 格式名
FORMAT_ALIASES = {
"mp4": "m4a", "m4a": "m4a", "aac": "m4a",
"webm": "ogg", "opus": "opus", "ogg": "ogg",
"wav": "wav", "pcm": "pcm", "mp3": "mp3",
}
# m4a/mp3 等压缩格式采样率由服务端自动识别
_AUTO_SAMPLE_FORMATS = {"m4a", "mp3", "ogg", "opus", "wav"}
class _Callback(RecognitionCallback):
def __init__(self):
self.sentences = []
self.error = None
def on_message(self, message):
try:
header = message.get("header", {})
if header.get("action") != "result":
return
payload = message.get("payload", {})
sentences = payload.get("sentence", {}).get("sentences") or []
for s in sentences:
if s.get("sentence_end"):
self.sentences.append(s.get("text", ""))
except Exception as e: # noqa: BLE001
log.warning("ASR 消息解析异常: %s", e)
def on_error(self, result):
self.error = result
log.warning("ASR 错误: %s", _err_text(result))
def transcribe(audio_bytes: bytes, fmt: str = "m4a") -> str:
"""上传音频字节 → 返回转写文本(空串表示未识别到内容)"""
if not settings.DASHSCOPE_API_KEY:
raise RuntimeError("未配置 DASHSCOPE_API_KEY,无法使用阿里云语音识别")
dashscope.api_key = settings.DASHSCOPE_API_KEY
fmt = FORMAT_ALIASES.get((fmt or "m4a").lower().lstrip("."), "m4a")
cb = _Callback()
rec = Recognition(
model=settings.ASR_MODEL,
format=fmt,
sample_rate=16000 if fmt in ("pcm", "wav") else 0, # 压缩格式自动识别
callback=cb,
)
# 分块发送,模拟流式(适当间隔,避免触发服务端 batching 报错)
rec.start()
chunk = 16 * 1024
for i in range(0, len(audio_bytes), chunk):
rec.send_audio_frame(audio_bytes[i:i + chunk])
time.sleep(0.08)
time.sleep(0.5) # 等待尾部识别
rec.stop()
if cb.error:
raise RuntimeError(f"阿里云语音识别失败: {_err_text(cb.error)}")
return "".join(cb.sentences).strip()
+69
View File
@@ -0,0 +1,69 @@
# -*- coding: utf-8 -*-
"""OPC 智能园区后端 —— 全局配置(backend/.env 可覆盖,未配置时用默认值)"""
import os
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent # backend/
def _load_dotenv(path):
"""极简 .env 解析:KEY=VALUE,支持 # 注释与引号"""
if not Path(path).exists():
return
for raw in Path(path).read_text("utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
key = key.strip()
value = value.strip().strip('"').strip("'")
if key and key not in os.environ:
os.environ[key] = value
_load_dotenv(BASE_DIR / ".env")
def _env(key, default):
return os.getenv(key, default)
class Settings:
# ---- HTTP ----
HOST = _env("DPM_HOST", _env("FASTAPI_HOST", "0.0.0.0"))
PORT = int(_env("DPM_PORT", _env("FASTAPI_PORT", "8000")))
# ---- 数据目录 ----
MEDIA_DIR = Path(_env("DPM_MEDIA_DIR", str(BASE_DIR / "media")))
DATA_FILE = Path(_env("DPM_DATA_FILE", str(BASE_DIR / "data.json")))
DIST_DIR = Path(_env("DPM_DIST_DIR", str(BASE_DIR.parent / "dist"))) # 前端构建产物(可选托管)
# ---- MQTT(优先使用 .env 中的局域网 Broker 配置) ----
MQTT_ENABLED = _env("DPM_MQTT_ENABLED", "1") == "1"
MQTT_HOST = _env("DPM_MQTT_HOST", _env("MQTT_BROKER_HOST", "127.0.0.1"))
MQTT_PORT = int(_env("DPM_MQTT_PORT", _env("MQTT_BROKER_PORT", "1883")))
MQTT_USERNAME = _env("DPM_MQTT_USERNAME", _env("MQTT_USERNAME", "")) or None
MQTT_PASSWORD = _env("DPM_MQTT_PASSWORD", _env("MQTT_PASSWORD", "")) or None
MQTT_CLIENT_ID = _env("DPM_MQTT_CLIENT_ID", "dpm-backend")
# 前端 WebSocket 接入地址(默认按 .env 的 broker 主机 + EMQX 常规 ws 端口)
MQTT_WS_URL = _env(
"DPM_MQTT_WS",
f"ws://{_env('MQTT_PUBLIC_HOST', _env('MQTT_BROKER_HOST', 'localhost'))}:8083/mqtt",
)
MQTT_TICK_INTERVAL = float(_env("DPM_MQTT_TICK_INTERVAL", "2.2"))
# ---- 主题 ----
TOPIC_COMMAND = _env("DPM_TOPIC_COMMAND", "opc/display/command")
TOPIC_TICK = _env("DPM_TOPIC_TICK", "opc/dashboard/tick")
TOPIC_ACK = _env("DPM_TOPIC_ACK", "opc/display/ack")
# ---- 阿里云 DashScope(LLM + 语音识别) ----
DASHSCOPE_API_KEY = _env("DASHSCOPE_API_KEY", "")
LLM_MODEL = _env("DPM_LLM_MODEL", "qwen-plus") # 通义千问
ASR_MODEL = _env("DPM_ASR_MODEL", "paraformer-realtime-v2") # 流式语音识别
LLM_BASE_URL = _env("DPM_LLM_BASE_URL", "https://dashscope.aliyuncs.com/compatible-mode/v1")
ASR_TIMEOUT = float(_env("DPM_ASR_TIMEOUT", "30"))
settings = Settings()
+44
View File
@@ -0,0 +1,44 @@
# -*- coding: utf-8 -*-
"""SSE 事件总线 —— 兼容旧通道(前端 MQTT 不可用时回退)
所有 MQTT 控制指令同时推送到 SSE /api/events
"""
import asyncio
import json
class EventBus:
def __init__(self):
self._queues = set()
self._loop = None
def set_loop(self, loop):
self._loop = loop
def subscribe(self):
q = asyncio.Queue(maxsize=256)
self._queues.add(q)
return q
def unsubscribe(self, q):
self._queues.discard(q)
def emit(self, payload: dict):
"""线程安全:从任意线程广播事件"""
if not self._loop:
return
try:
asyncio.run_coroutine_threadsafe(self._emit(payload), self._loop)
except Exception: # noqa: BLE001
pass
async def _emit(self, payload):
data = json.dumps(payload, ensure_ascii=False)
for q in list(self._queues):
try:
q.put_nowait(data)
except asyncio.QueueFull:
pass
bus = EventBus()
+197
View File
@@ -0,0 +1,197 @@
# -*- coding: utf-8 -*-
"""AI 对话引擎 —— 阿里云 DashScope(通义千问)+ 函数调用
工具调用执行后通过 MQTT 广播,所有大屏同步响应
DashScope 不可用时自动回退本地规则引擎(离线兜底)
"""
import json
import logging
import ssl
import urllib.error
import urllib.request
import certifi
from .config import settings
log = logging.getLogger("dpm.llm")
# macOS 系统 Python 无系统 CA,使用 certifi 提供的根证书
_SSL_CTX = ssl.create_default_context(cafile=certifi.where())
SYSTEM_PROMPT = (
"你是「昆明市大学生创业园 · OPC 智能园区」的 AI 智能助手,运行在大屏展播系统上。"
"园区提供空间、孵化、融资、政策、资源、AI 赋能、综合服务七位一体服务,"
"在园项目 158 家,累计带动就业 2,186 人,累计营收 2.08 亿元。"
"当用户要求「切换页面 / 控制播放 / 展示卡片 / 弹出通知」时,必须调用对应工具;"
"其余园区相关问题用简洁、专业的中文回答,可适当使用列表。"
)
# 暴露给大模型的工具定义(执行时经 MQTT 广播到前端)
TOOLS = [
{
"type": "function",
"function": {
"name": "navigate_page",
"description": "切换大屏展示页面(数据大屏 / 数字孪生 / AI 助手 / 媒体轮播)",
"parameters": {
"type": "object",
"properties": {
"page": {"type": "string", "enum": ["/", "/twin", "/ai", "/screen"],
"description": "目标页面路径"},
},
"required": ["page"],
},
},
},
{
"type": "function",
"function": {
"name": "media_control",
"description": "控制媒体播放(播放/暂停/下一项/上一项)",
"parameters": {
"type": "object",
"properties": {
"action": {"type": "string", "enum": ["play", "pause", "next", "prev"]},
},
"required": ["action"],
},
},
},
{
"type": "function",
"function": {
"name": "show_card",
"description": "在大屏上展示信息卡片(企业分布 / 分区介绍 / 园区总览 / 自定义内容)",
"parameters": {
"type": "object",
"properties": {
"card": {"type": "string", "enum": ["companies", "zones", "overview", "custom"]},
"title": {"type": "string"},
"content": {"type": "string"},
},
"required": ["card"],
},
},
},
{
"type": "function",
"function": {
"name": "send_alert",
"description": "在大屏上弹出通知提示",
"parameters": {
"type": "object",
"properties": {
"title": {"type": "string"},
"content": {"type": "string"},
},
"required": ["title", "content"],
},
},
},
]
_TOOL_MAP = {
"navigate_page": "navigate",
"media_control": "control",
"show_card": "show_card",
"send_alert": "alert",
}
def _chat_once(messages, with_tools=True):
payload = {
"model": settings.LLM_MODEL,
"messages": messages,
"temperature": 0.6,
}
if with_tools:
payload["tools"] = TOOLS
req = urllib.request.Request(
f"{settings.LLM_BASE_URL}/chat/completions",
data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {settings.DASHSCOPE_API_KEY}",
},
)
with urllib.request.urlopen(req, timeout=60, context=_SSL_CTX) as resp:
return json.loads(resp.read().decode("utf-8"))
def _fallback(messages):
"""DashScope 不可用时回退本地规则引擎"""
from .ai_tools import run_chat as rule_run
result = rule_run(messages)
# 规则引擎产出的工具同样执行(MQTT 广播),并汇总发布结果
_, all_ok = _exec_all(result.get("tools", []))
result["model"] = "rule-engine"
result["mqtt_published"] = all_ok
return result
def _exec_all(tools):
"""执行工具列表,返回 (results, all_ok)"""
from .ai_tools import run_tools
results, all_ok = run_tools(tools)
return results, all_ok
def run_chat(messages):
"""入口:{reply, tools, model}
tools 已在后端执行(MQTT 广播),返回值供请求端本地同步执行"""
if not settings.DASHSCOPE_API_KEY:
log.warning("未配置 DASHSCOPE_API_KEY,使用本地规则引擎")
return _fallback(messages)
msgs = [{"role": "system", "content": SYSTEM_PROMPT}] + [
{"role": m.get("role") == "me" and "user" or m.get("role", "user"), "content": m.get("content", "")}
for m in (messages or [])
]
try:
data = _chat_once(msgs)
choice = data["choices"][0]["message"]
reply = choice.get("content") or ""
tool_calls = choice.get("tool_calls") or []
executed = []
if tool_calls:
for tc in tool_calls:
fn = tc.get("function", {})
name = fn.get("name", "")
try:
args = json.loads(fn.get("arguments") or "{}")
except json.JSONDecodeError:
args = {}
tool = {"type": _TOOL_MAP.get(name, name), "params": args}
executed.append(tool)
msgs.append({"role": "assistant", "content": None, "tool_calls": tool_calls})
msgs.append({
"role": "tool",
"tool_call_id": tc.get("id", ""),
"content": json.dumps({"ok": True}, ensure_ascii=False),
})
# 工具执行(MQTT 广播),并汇总发布结果
_, all_ok = _exec_all(executed)
# 二次调用:携带工具结果生成最终回复
try:
data2 = _chat_once(msgs, with_tools=False)
reply = data2["choices"][0]["message"].get("content") or reply
except Exception as e: # noqa: BLE001
log.warning("LLM 二次调用失败(保留工具回复): %s", e)
else:
all_ok = True
if not reply:
reply = "已完成操作。您还可以让我切换页面、控制播放或展示园区卡片。"
return {"reply": reply, "tools": executed, "model": settings.LLM_MODEL, "mqtt_published": all_ok}
except urllib.error.HTTPError as e:
log.warning("DashScope HTTP %s: %s", e.code, e.read()[:300])
except Exception as e: # noqa: BLE001
log.warning("DashScope 调用失败,回退规则引擎: %s", e)
return _fallback(messages)
+94
View File
@@ -0,0 +1,94 @@
# -*- coding: utf-8 -*-
"""OPC 智能园区后端 —— FastAPI 入口
启动:uvicorn app.main:app --host 0.0.0.0 --port 8000
或:python main.py
"""
import asyncio
import logging
import threading
import time
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from .config import settings
from .event_bus import bus
from .mqtt import hub
from .routers import router
from .sim_engine import sim_engine
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
log = logging.getLogger("dpm.main")
DIST = Path(settings.DIST_DIR)
@asynccontextmanager
async def lifespan(app: FastAPI):
# 启动准备
settings.MEDIA_DIR.mkdir(parents=True, exist_ok=True)
bus.set_loop(asyncio.get_running_loop())
hub.start()
stop = threading.Event()
def tick_loop():
"""数据引擎每 2.2s 推进一次;MQTT 连接时同步推送快照"""
while not stop.is_set():
try:
snap = sim_engine.tick()
hub.publish_tick(snap)
except Exception as e: # noqa: BLE001
log.warning("数据 tick 异常: %s", e)
stop.wait(settings.MQTT_TICK_INTERVAL)
threading.Thread(target=tick_loop, daemon=True).start()
log.info("OPC 智能园区后端启动于 http://%s:%s", settings.HOST, settings.PORT)
if settings.MQTT_ENABLED:
log.info("MQTT Broker: %s:%s(前端 WS: %s)", settings.MQTT_HOST, settings.MQTT_PORT, settings.MQTT_WS_URL)
try:
yield
finally:
stop.set()
hub.stop()
log.info("OPC 智能园区后端已停止")
app = FastAPI(title="OPC 智能园区后端", version="1.0.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(router)
# 媒体资源静态服务(上传/播放的文件)
settings.MEDIA_DIR.mkdir(parents=True, exist_ok=True)
app.mount("/file", StaticFiles(directory=str(settings.MEDIA_DIR)), name="media")
# ---------- 前端静态托管 + SPA 回退(浏览器直接访问 :8000 即可) ----------
@app.get("/{path:path}", include_in_schema=False)
async def spa_fallback(path: str):
if not DIST.exists():
return JSONResponse({"detail": "前端未构建(dist 目录不存在)"}, status_code=404)
dist = DIST.resolve()
if not path:
target = dist / "index.html"
else:
target = (dist / path).resolve()
if target.is_file() and target.is_relative_to(dist):
return FileResponse(target)
index = dist / "index.html"
if index.exists():
return FileResponse(index)
return JSONResponse({"detail": "Not Found"}, status_code=404)
+105
View File
@@ -0,0 +1,105 @@
# -*- coding: utf-8 -*-
"""MQTT 发布中心 —— 后端 → 前端控制通道
页面控制 / 媒体控制 / 卡片展示指令统一走 opc/display/command
数据快照按 DPM_MQTT_TICK_INTERVAL 推送 opc/dashboard/tick
"""
import json
import logging
import threading
import time
import uuid
import paho.mqtt.client as mqtt
from .config import settings
from .event_bus import bus
log = logging.getLogger("dpm.mqtt")
def new_cmd_id():
return uuid.uuid4().hex[:12]
class MqttHub:
def __init__(self):
self.client = None
self.connected = False
self._lock = threading.RLock()
# ---------- 生命周期 ----------
def start(self):
if not settings.MQTT_ENABLED:
log.info("MQTT 已禁用(DPM_MQTT_ENABLED=0)")
return
try:
self.client = mqtt.Client(
client_id=settings.MQTT_CLIENT_ID,
clean_session=True,
protocol=mqtt.MQTTv311,
)
if settings.MQTT_USERNAME:
self.client.username_pw_set(settings.MQTT_USERNAME, settings.MQTT_PASSWORD)
self.client.on_connect = self._on_connect
self.client.on_disconnect = self._on_disconnect
self.client.connect_async(settings.MQTT_HOST, settings.MQTT_PORT, keepalive=30)
self.client.loop_start()
log.info("MQTT 连接中 %s:%s ...", settings.MQTT_HOST, settings.MQTT_PORT)
except Exception as e: # noqa: BLE001
log.warning("MQTT 初始化失败(数据 API 不受影响): %s", e)
def stop(self):
if self.client:
try:
self.client.loop_stop()
self.client.disconnect()
except Exception: # noqa: BLE001
pass
self.client = None
def _on_connect(self, client, userdata, flags, rc):
if rc == 0:
self.connected = True
log.info("MQTT 已连接 %s:%s", settings.MQTT_HOST, settings.MQTT_PORT)
else:
log.warning("MQTT 连接失败 rc=%s", rc)
def _on_disconnect(self, client, userdata, rc):
self.connected = False
if rc != 0:
log.warning("MQTT 断开(rc=%s),自动重连中...", rc)
# ---------- 发布 ----------
def publish(self, topic, payload, qos=1, retain=False):
if not self.client or not self.connected:
log.debug("MQTT 未连接,丢弃发布 %s", topic)
return False
try:
info = self.client.publish(topic, json.dumps(payload, ensure_ascii=False), qos=qos, retain=retain)
return info.rc == mqtt.MQTT_ERR_SUCCESS
except Exception as e: # noqa: BLE001
log.warning("MQTT 发布失败: %s", e)
return False
def publish_command(self, action, params=None):
"""统一指令信封:{cmd_id, ts, action, params, published}
MQTT 广播 + SSE 兼容推送;published 标记是否真正发布成功"""
payload = {
"cmd_id": new_cmd_id(),
"ts": int(time.time() * 1000),
"action": action,
"params": params or {},
}
ok = self.publish(settings.TOPIC_COMMAND, payload)
payload["published"] = ok
if ok:
bus.emit(payload) # SSE 兼容通道(仅发布成功时推送)
return payload
def publish_tick(self, snapshot):
payload = {"ts": int(time.time() * 1000), "snapshot": snapshot}
self.publish(settings.TOPIC_TICK, payload, qos=0)
hub = MqttHub()
+383
View File
@@ -0,0 +1,383 @@
# -*- coding: utf-8 -*-
"""全部 REST 路由 —— 数据 / 媒体 / 播放列表 / 设置 / AI / 展示控制"""
import asyncio
import json
import logging
import time
from pathlib import Path
from fastapi import APIRouter, Request, UploadFile, File
from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import BaseModel, Field
from .ai_tools import run_chat, run_tools
from .asr import transcribe as asr_transcribe
from .config import settings
from .event_bus import bus
from .llm import run_chat as llm_run_chat
from .mqtt import hub
from .sim_engine import sim_engine
from .storage import storage
log = logging.getLogger("dpm.api")
router = APIRouter()
ALLOWED_EXT = {".mp4", ".mkv", ".avi", ".jpg", ".jpeg", ".png"}
def _media_type(path):
lower = path.lower()
if lower.endswith((".mp4", ".mkv", ".avi")):
return "video"
return "image"
# ==================== 数据模型 ====================
class SettingsBody(BaseModel):
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 ActionBody(BaseModel):
action: str
class PathBody(BaseModel):
path: str
class UrlBody(BaseModel):
url: str
class StatePayload(BaseModel):
status: str = ""
index: int = 0
name: str = ""
media_type: str = ""
class StateBody(BaseModel):
state: StatePayload
class DisplayCommandBody(BaseModel):
action: str
params: dict = Field(default_factory=dict)
class ChatMessage(BaseModel):
role: str
content: str
class ChatBody(BaseModel):
messages: list[ChatMessage]
# ==================== 认证 / 设置 ====================
@router.post("/api/login")
async def login(request: Request):
form = await request.form()
s = storage.get_settings()
return JSONResponse({"success": form.get("username") == s["username"] and form.get("password") == s["password"]})
@router.get("/api/settings")
async def get_settings():
s = storage.get_settings()
return {k: s[k] for k in ("volume", "play_mode", "image_duration", "fullscreen", "autostart")}
@router.post("/api/settings")
async def update_settings(body: SettingsBody):
storage.update_settings(**body.model_dump(exclude_none=True))
s = storage.get_settings()
hub.publish_command("settings_changed", {"volume": s["volume"], "play_mode": s["play_mode"]})
return {"ok": True}
# ==================== 媒体 ====================
def _list_media():
files = []
media_dir = Path(settings.MEDIA_DIR)
if media_dir.exists():
for p in sorted(media_dir.iterdir()):
if not p.is_file() or p.suffix.lower() not in ALLOWED_EXT:
continue
files.append({
"name": p.name, "relative_path": p.name,
"type": _media_type(p.name), "url": f"/file/{p.name}", "source": "local",
})
for item in storage.get_url_media():
files.append({
"name": item.get("name", item.get("url", "")),
"relative_path": item.get("url", ""),
"type": item.get("type", "image"),
"url": item.get("url", ""),
"source": "url",
})
return {"files": files}
@router.get("/media")
async def list_media():
return _list_media()
@router.post("/upload")
async def upload(file: UploadFile = File(...)):
name = file.filename or "file"
ext = Path(name).suffix.lower()
if ext not in ALLOWED_EXT:
return JSONResponse({"ok": False, "error": "不支持的文件类型"}, status_code=400)
settings.MEDIA_DIR.mkdir(parents=True, exist_ok=True)
stem = Path(name).stem
new_name = f"{stem}_{time.strftime('%Y%m%d%H%M%S')}{ext}"
save_path = settings.MEDIA_DIR / new_name
with save_path.open("wb") as f:
while True:
chunk = await file.read(1024 * 1024)
if not chunk:
break
f.write(chunk)
hub.publish_command("playlist_changed")
return {"ok": True, "name": new_name}
@router.post("/api/delete")
async def delete_media(body: PathBody):
path = body.path
if not path.startswith(("http://", "https://")):
target = settings.MEDIA_DIR / path
try:
if target.exists() and target.is_file():
target.unlink()
except OSError:
pass
storage.delete_media(path)
hub.publish_command("playlist_changed")
return {"ok": True}
@router.post("/api/media/add-url")
async def add_url(body: UrlBody):
url = body.url.strip()
if not url:
return {"ok": False}
name = url.rsplit("/", 1)[-1].split("?", 1)[0] or url
t = _media_type(url)
is_new = storage.add_url_media(url, name, t)
return {"ok": True, "duplicate": None if is_new else True}
# ==================== 播放列表 ====================
def _playlist_files():
files = []
media_dir = Path(settings.MEDIA_DIR)
for item in storage.get_playlist():
path = item.get("path", "")
if item.get("source") == "url":
files.append({
"name": item.get("name") or path,
"relative_path": path, "type": _media_type(path), "url": path, "source": "url",
})
else:
fp = media_dir / path
if fp.exists():
files.append({
"name": fp.name, "relative_path": path,
"type": _media_type(path), "url": f"/file/{path}", "source": "local",
})
s = storage.get_settings()
return {"files": files, "volume": s["volume"], "play_mode": s["play_mode"], "image_duration": s["image_duration"]}
@router.get("/api/playlist")
async def get_playlist():
return _playlist_files()
@router.post("/api/playlist/add")
async def add_playlist(body: PathBody):
storage.add_to_playlist(body.path)
hub.publish_command("playlist_changed")
return {"ok": True}
@router.post("/api/playlist/remove")
async def remove_playlist(body: PathBody):
storage.remove_from_playlist(body.path)
hub.publish_command("playlist_changed")
return {"ok": True}
# ==================== 播放控制 / 状态 ====================
_CONTROL_ACTIONS = {"play", "pause", "next", "prev"}
@router.post("/api/control")
async def control(body: ActionBody):
if body.action not in _CONTROL_ACTIONS:
return JSONResponse({"ok": False, "error": "action 必须是 play/pause/next/prev"}, status_code=400)
hub.publish_command(body.action)
return {"ok": True, "action": body.action}
@router.get("/api/state")
async def get_state():
return {"state": {"status": "unknown", "index": 0, "name": "", "media_type": ""}}
@router.post("/api/state")
async def update_state(body: StateBody):
hub.publish_command("state_update", body.state.model_dump())
return {"ok": True}
@router.post("/api/display-command")
async def display_command(body: ActionBody):
if body.action == "minimize":
hub.publish_command("minimize")
return {"ok": True, "action": "minimize"}
return JSONResponse({"ok": False, "error": "不支持的显示命令"}, status_code=400)
# ==================== 数据(大屏全部数据来自后端) ====================
@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()
# ==================== 园区企业 ====================
_COMPANIES = [
{"name": "云南派音人工智能科技", "zone": "加速区", "room": "A1", "color": "#4c8dff"},
{"name": "米勒克尔蓝宝石珠宝", "zone": "加速区", "room": "A2", "color": "#4c8dff"},
{"name": "中泰研学合作", "zone": "加速区", "room": "A3", "color": "#4c8dff"},
{"name": "云南宸中低空经济", "zone": "加速区", "room": "A4", "color": "#4c8dff"},
{"name": "昆明智海银高文化科技", "zone": "加速区", "room": "A5", "color": "#4c8dff"},
{"name": "AI机器人大模型训练", "zone": "加速区", "room": "A6", "color": "#4c8dff"},
{"name": "云南廷秀文旅康养", "zone": "加速区", "room": "A7", "color": "#4c8dff"},
{"name": "瀚颖AI+教育信息咨询", "zone": "加速区", "room": "A8", "color": "#4c8dff"},
{"name": "云南大学AI+创业平台", "zone": "加速区", "room": "A9", "color": "#4c8dff"},
{"name": "仰光客厅", "zone": "国际区", "room": "I1", "color": "#22d3ee"},
{"name": "云南上古绝学文化", "zone": "国际区", "room": "I2", "color": "#22d3ee"},
{"name": "中越生物医疗", "zone": "国际区", "room": "I3", "color": "#22d3ee"},
{"name": "酷享野农AI农业", "zone": "国际区", "room": "I4", "color": "#22d3ee"},
{"name": "滇缅国际设计", "zone": "国际区", "room": "I5", "color": "#22d3ee"},
{"name": "昆明舒诺生物科技", "zone": "国际区", "room": "I6", "color": "#22d3ee"},
{"name": "达岸教育管理", "zone": "国际区", "room": "I7", "color": "#22d3ee"},
{"name": "Facebook越南跨境电商", "zone": "成长区", "room": "G1", "color": "#34d399"},
{"name": "研X同行者网络", "zone": "成长区", "room": "G2", "color": "#34d399"},
{"name": "朵哈·玫瑰特色产业链", "zone": "成长区", "room": "G3", "color": "#34d399"},
{"name": "昆明云韵体育", "zone": "成长区", "room": "G4", "color": "#34d399"},
{"name": "南菌优培食用菌", "zone": "成长区", "room": "G5", "color": "#34d399"},
{"name": "五华区丽裳文化", "zone": "成长区", "room": "G6", "color": "#34d399"},
{"name": "云南星瑞航空", "zone": "成长区", "room": "G7", "color": "#34d399"},
{"name": "综合直播私域平台", "zone": "成长区", "room": "G8", "color": "#34d399"},
{"name": "园区管理办公室", "zone": "园区管理", "room": "M", "color": "#fbbf24"},
]
@router.get("/api/park/companies")
async def park_companies():
return {"companies": _COMPANIES}
@router.get("/api/park/zones")
async def park_zones():
zones = ["加速区", "国际区", "成长区"]
counts = {z: sum(1 for c in _COMPANIES if c["zone"] == z) for z in zones}
colors = {"加速区": "#4c8dff", "国际区": "#22d3ee", "成长区": "#34d399"}
return {"zones": [{"name": z, "count": counts[z], "color": colors[z]} for z in zones]}
# ==================== AI 对话(通义千问 + 工具调用) ====================
@router.post("/api/ai/chat")
async def ai_chat(body: ChatBody):
"""大模型对话:工具调用(切页/控制/卡片/通知)经 MQTT 广播到所有大屏
返回 {reply, tools, model};tools 供请求端本地同步执行"""
messages = [m.model_dump() for m in body.messages]
result = llm_run_chat(messages)
return result
@router.post("/api/ai/asr")
async def ai_asr(file: UploadFile = File(...), format: str = "m4a"):
"""语音识别:上传录音 → 阿里云 paraformer 转写为文本"""
data = await file.read()
if not data:
return JSONResponse({"ok": False, "error": "空音频"}, status_code=400)
try:
text = asr_transcribe(data, fmt=format)
return {"ok": True, "text": text}
except Exception as e: # noqa: BLE001
log.warning("ASR 转写失败: %s", e)
return JSONResponse({"ok": False, "error": str(e)}, status_code=502)
# ==================== 展示控制(管理端 → MQTT) ====================
_VALID_DISPLAY_ACTIONS = {"navigate", "navigate_rel", "play", "pause", "next", "prev",
"alert", "show_card", "set_mode", "minimize"}
@router.post("/api/display/command")
async def display_command_publish(body: DisplayCommandBody):
"""管理端/任意客户端通过 REST 发控制指令 → 后端转 MQTT 广播给所有大屏"""
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)
return {"ok": cmd.get("published", False), "cmd_id": cmd["cmd_id"], "action": body.action,
"mqtt_connected": hub.connected}
@router.get("/api/display/state")
async def display_state():
return {
"mqtt_connected": hub.connected,
"mqtt_host": f"{settings.MQTT_HOST}:{settings.MQTT_PORT}",
"page": None, # 前端可通过 MQTT 回执上报当前页(后续扩展)
}
# ==================== SSE 兼容通道(MQTT 不可用时前端回退) ====================
@router.get("/api/events")
async def sse_events(request: Request):
q = bus.subscribe()
async def gen():
try:
while True:
if await request.is_disconnected():
break
try:
data = await asyncio.wait_for(q.get(), timeout=15)
yield f"data: {data}\n\n"
except asyncio.TimeoutError:
yield ": keepalive\n\n"
finally:
bus.unsubscribe(q)
return StreamingResponse(gen(), media_type="text/event-stream")
+156
View File
@@ -0,0 +1,156 @@
# -*- coding: utf-8 -*-
"""园区数据模拟引擎 —— 由前端 parkData.js 移植,后端统一产生数据
前端通过 GET /api/dashboard/snapshot 获取,或订阅 MQTT opc/dashboard/tick
"""
import random
import threading
import time
MODEL_NAMES = ["DeepSeek-V3", "通义千问", "智谱 GLM-4", "豆包", "讯飞星火"]
TOOL_NAMES = ["文档生成", "数据查询", "图像创作", "代码执行", "语音合成"]
COMPANY_NAMES = [
"云南派音人工智能科技", "米勒克尔蓝宝石珠宝", "中泰研学合作", "云南宸中低空经济",
"昆明智海银高文化科技", "云南廷秀文旅康养", "瀚颖AI+教育信息咨询",
"仰光客厅", "云南上古绝学文化", "中越生物医疗", "酷享野农AI农业",
"滇缅国际设计", "昆明舒诺生物科技", "达岸教育管理", "花仙子园艺肥料",
"研X同行者网络", "鬼才明AI创意工作室", "朵哈·玫瑰特色产业链", "昆明云韵体育",
"南菌优培食用菌", "五华区丽裳文化", "云南星瑞航空", "综合直播私域平台",
"昆明屿澈电商", "蓝智科技", "云品出滇·纸享万家", "启元人工智能科技",
]
def _pick(arr):
return arr[random.randrange(len(arr))]
def _now_time():
return time.strftime("%H:%M:%S")
def make_series(base, growth, noise, n=30):
out = []
v = base
for _ in range(n):
v = v * (1 + growth) + (random.random() - 0.5) * noise
out.append(round(max(1, v * 100) / 100, 2))
return out
def gen_event():
r = random.random
pool = [
{"icon": "bolt", "text": f"AI 推理任务完成 · 消耗 {round(800 + r() * 9000):,} tokens({_pick(MODEL_NAMES)})"},
{"icon": "wrench", "text": f"工具「{_pick(TOOL_NAMES)}」被调用 {round(10 + r() * 90)} 次"},
{"icon": "building", "text": f"「{_pick(COMPANY_NAMES)}」提交入驻申请 · 进入评审流程"},
{"icon": "users", "text": f"「{_pick(COMPANY_NAMES)}」新增招聘岗位 {round(1 + r() * 5)} 个"},
{"icon": "coin", "text": f"园区企业完成一笔 ¥{(0.5 + r() * 9):.1f}万 交易"},
{"icon": "robot", "text": f"「{_pick(MODEL_NAMES)}」模型完成一次微调任务"},
]
e = _pick(pool)
return {"id": f"{int(time.time()*1000)}-{random.random()}", "icon": e["icon"], "text": e["text"], "time": _now_time()}
def init_feed():
return [
{"id": 1, "icon": "bolt", "text": "云南派音AI 完成音频向量嵌入任务 · 消耗 12,480 tokens", "time": _now_time()},
{"id": 2, "icon": "building", "text": "「启元人工智能科技」通过评审 · 正式入驻 OPC 创业空间", "time": _now_time()},
{"id": 3, "icon": "users", "text": "「昆明舒护安养老服务」新增招聘岗位 2 个", "time": _now_time()},
{"id": 4, "icon": "coin", "text": "园区企业完成一笔 ¥3.6万 交易", "time": _now_time()},
]
def init_snapshot():
return {
"t": 0,
"token": {"today": 128.64, "total": 12840, "rate": 84.6, "series": make_series(82, 0.012, 9)},
"tools": {"today": 3568, "total": 365204, "success": 98.7},
"projects": {"inPark": 158, "cum": 208, "todayNew": 2},
"jobs": {"total": 2186, "todayNew": 3},
"revenue": {"today": 38.6, "total": 20800, "growth": 8.2, "series": make_series(30, 0.006, 4)},
"park": {"devices": 98.6, "energy": 386, "people": 127, "desk": 76, "meeting": 3, "nodes": 12},
"feed": init_feed(),
}
def next_snapshot(s):
r = random.random
t_delta = round(0.26 + r() * 0.34, 2)
token = {
"today": round(s["token"]["today"] + t_delta, 2),
"total": round(s["token"]["total"] + t_delta, 2),
"rate": round(76 + r() * 20, 1),
}
tool_delta = round(13 + r() * 22)
tools = {
"today": s["tools"]["today"] + tool_delta,
"total": s["tools"]["total"] + tool_delta,
"success": round(98.1 + r() * 1.2, 1),
}
rev_delta = round(0.6 + r() * 1.7, 1)
revenue = {
"today": round(s["revenue"]["today"] + rev_delta, 1),
"total": round(s["revenue"]["total"] + rev_delta, 1),
"growth": round(7.2 + r() * 2.2, 1),
}
park = {
"devices": round(97.6 + r() * 1.6, 1),
"energy": round(320 + r() * 130),
"people": round(80 + r() * 95),
"desk": round(62 + r() * 24),
"meeting": round(2 + r() * 4),
"nodes": 12,
}
projects = dict(s["projects"])
if r() < 0.055:
projects["todayNew"] += 1
if r() < 0.035:
projects["inPark"] += 1
projects["cum"] += 1
jobs = dict(s["jobs"])
if r() < 0.08:
jobs["todayNew"] += 1
if r() < 0.05:
jobs["total"] += 1
t_series = list(s["token"]["series"])
t_series[-1] = round(t_series[-1] + t_delta, 2)
if s["t"] % 12 == 11:
t_series = t_series[1:] + [round(72 + r() * 30, 2)]
token["series"] = t_series
r_series = list(s["revenue"]["series"])
r_series[-1] = round(r_series[-1] + rev_delta, 1)
if s["t"] % 12 == 11:
r_series = r_series[1:] + [round(26 + r() * 9, 1)]
revenue["series"] = r_series
feed = [gen_event()] + s["feed"][:6] if s["t"] % 3 == 2 else s["feed"]
return {
"t": s["t"] + 1,
"token": token, "tools": tools, "revenue": revenue,
"park": park, "projects": projects, "jobs": jobs, "feed": feed,
}
class SimEngine:
"""带锁的快照引擎:单例供 API 与 MQTT tick 共用"""
def __init__(self):
self._lock = threading.RLock()
self._snap = init_snapshot()
def snapshot(self):
with self._lock:
return dict(self._snap)
def tick(self):
with self._lock:
self._snap = next_snapshot(self._snap)
return dict(self._snap)
sim_engine = SimEngine()
+106
View File
@@ -0,0 +1,106 @@
# -*- coding: utf-8 -*-
"""JSON 持久化:设置 / 播放列表 / URL 媒体(对齐原 Rust storage 语义)"""
import json
import threading
import time
from pathlib import Path
from .config import settings
DEFAULT_SETTINGS = {
"username": "admin",
"password": "123456",
"volume": 80,
"auto_play": True,
"play_mode": "sequential",
"image_duration": 5,
"fullscreen": True,
"autostart": False,
}
class Storage:
def __init__(self, data_file: Path):
self.data_file = data_file
self._lock = threading.RLock()
self._data = self._load()
# ---------- 底层 ----------
def _load(self):
if self.data_file.exists():
try:
return json.loads(self.data_file.read_text("utf-8"))
except Exception:
pass
return {"settings": dict(DEFAULT_SETTINGS), "playlist": [], "url_media": []}
def _save(self):
self.data_file.parent.mkdir(parents=True, exist_ok=True)
tmp = self.data_file.with_suffix(".tmp")
tmp.write_text(json.dumps(self._data, ensure_ascii=False, indent=2), "utf-8")
tmp.replace(self.data_file)
# ---------- 设置 ----------
def get_settings(self):
with self._lock:
s = dict(DEFAULT_SETTINGS)
s.update(self._data.get("settings", {}))
return s
def update_settings(self, **kw):
with self._lock:
s = dict(DEFAULT_SETTINGS)
s.update(self._data.get("settings", {}))
for k, v in kw.items():
if v is not None and k in s:
s[k] = v
self._data["settings"] = s
self._save()
# ---------- 播放列表 ----------
def get_playlist(self):
with self._lock:
return list(self._data.get("playlist", []))
def add_to_playlist(self, path):
with self._lock:
items = self._data.setdefault("playlist", [])
if not any(it.get("path") == path for it in items):
items.append({"path": path, "source": None, "name": None})
self._save()
def remove_from_playlist(self, path):
with self._lock:
self._data.setdefault("playlist", [])
self._data["playlist"] = [it for it in self._data["playlist"] if it.get("path") != path]
self._save()
# ---------- URL 媒体 ----------
def get_url_media(self):
with self._lock:
return list(self._data.get("url_media", []))
def add_url_media(self, url, name, media_type):
"""返回 True 表示新增,False 表示已存在(重复)"""
with self._lock:
items = self._data.setdefault("url_media", [])
if any(it.get("url") == url for it in items):
return False
items.append({
"url": url, "name": name, "type": media_type,
"added_at": time.strftime("%Y-%m-%d %H:%M:%S"),
})
self._save()
return True
def delete_media(self, path):
with self._lock:
self._data.setdefault("url_media", [])
self._data["url_media"] = [it for it in self._data["url_media"] if it.get("url") != path]
self._data.setdefault("playlist", [])
self._data["playlist"] = [it for it in self._data["playlist"] if it.get("path") != path]
self._save()
storage = Storage(settings.DATA_FILE)