380 lines
14 KiB
Python
380 lines
14 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""本地预览:UTF-8 静态资源 + 配置落盘 API。用法: python serve.py"""
|
||
from __future__ import annotations
|
||
|
||
import http.server
|
||
import json
|
||
import os
|
||
import queue
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
from playback_hub import HUB
|
||
|
||
ROOT = Path(__file__).resolve().parent
|
||
AGV_DIR = (ROOT / "agv").resolve()
|
||
PORT = int(os.environ.get("PORT", "8765"))
|
||
|
||
UTF8_TYPES = {
|
||
".html": "text/html; charset=utf-8",
|
||
".js": "text/javascript; charset=utf-8",
|
||
".json": "application/json; charset=utf-8",
|
||
".css": "text/css; charset=utf-8",
|
||
".svg": "image/svg+xml; charset=utf-8",
|
||
}
|
||
|
||
|
||
class Handler(http.server.SimpleHTTPRequestHandler):
|
||
def __init__(self, *args, **kwargs):
|
||
super().__init__(*args, directory=str(ROOT), **kwargs)
|
||
|
||
def end_headers(self):
|
||
ext = Path(self.path.split("?", 1)[0]).suffix.lower()
|
||
if ext in UTF8_TYPES:
|
||
self.send_header("Content-Type", UTF8_TYPES[ext])
|
||
if ext in {".html", ".js", ".json", ".css"}:
|
||
self.send_header("Cache-Control", "no-store, must-revalidate")
|
||
super().end_headers()
|
||
|
||
def do_OPTIONS(self):
|
||
path = self.path.split("?", 1)[0]
|
||
if path.startswith("/api/"):
|
||
self.send_response(204)
|
||
self._cors_headers()
|
||
self.end_headers()
|
||
return
|
||
self.send_error(404, "Not Found")
|
||
|
||
def do_GET(self):
|
||
path = self.path.split("?", 1)[0]
|
||
if path == "/api/catalog":
|
||
self._json_response(200, self._build_catalog_response())
|
||
return
|
||
if path == "/api/agv/state":
|
||
state = HUB.get_state()
|
||
self._json_response(
|
||
200,
|
||
{
|
||
"version": 1,
|
||
"ok": True,
|
||
"data": {**state, "subscribers": HUB.subscriber_count()},
|
||
"error": None,
|
||
},
|
||
)
|
||
return
|
||
if path == "/api/agv/events":
|
||
self._agv_events_sse()
|
||
return
|
||
super().do_GET()
|
||
|
||
def do_POST(self):
|
||
path = self.path.split("?", 1)[0]
|
||
if path == "/api/save-config":
|
||
self._save_config()
|
||
return
|
||
if path == "/api/agv":
|
||
self._agv_api()
|
||
return
|
||
if path == "/api/agv/report":
|
||
self._agv_report()
|
||
return
|
||
self.send_error(404, "Not Found")
|
||
|
||
def _save_config(self):
|
||
try:
|
||
length = int(self.headers.get("Content-Length", 0))
|
||
raw = self.rfile.read(length).decode("utf-8")
|
||
payload = json.loads(raw)
|
||
filename = payload["file"]
|
||
config = payload["config"]
|
||
except (KeyError, json.JSONDecodeError, UnicodeDecodeError) as e:
|
||
self._json_response(400, {"ok": False, "error": f"请求体无效: {e}"})
|
||
return
|
||
|
||
if not isinstance(filename, str) or not filename.endswith(".json"):
|
||
self._json_response(400, {"ok": False, "error": "仅允许保存 agv/*.json"})
|
||
return
|
||
if "/" in filename or "\\" in filename or ".." in filename:
|
||
self._json_response(400, {"ok": False, "error": "非法文件名"})
|
||
return
|
||
|
||
target = (AGV_DIR / filename).resolve()
|
||
if not str(target).startswith(str(AGV_DIR)):
|
||
self._json_response(403, {"ok": False, "error": "路径越界"})
|
||
return
|
||
|
||
try:
|
||
AGV_DIR.mkdir(parents=True, exist_ok=True)
|
||
text = json.dumps(config, ensure_ascii=False, indent=2) + "\n"
|
||
target.write_text(text, encoding="utf-8")
|
||
except OSError as e:
|
||
self._json_response(500, {"ok": False, "error": str(e)})
|
||
return
|
||
|
||
self._json_response(200, {"ok": True, "file": filename, "path": str(target.relative_to(ROOT))})
|
||
|
||
def _read_manifest_models(self) -> list[dict]:
|
||
manifest_path = AGV_DIR / "manifest.json"
|
||
if not manifest_path.exists():
|
||
return []
|
||
try:
|
||
data = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||
except (OSError, json.JSONDecodeError):
|
||
return []
|
||
models = data.get("models")
|
||
return models if isinstance(models, list) else []
|
||
|
||
def _map_actions(self, cfg: dict) -> list[dict]:
|
||
actions = cfg.get("actions") or {}
|
||
if not isinstance(actions, dict):
|
||
return []
|
||
out = []
|
||
for action_id, act in actions.items():
|
||
if not isinstance(act, dict):
|
||
continue
|
||
out.append(
|
||
{
|
||
"id": action_id,
|
||
"label": act.get("label") or action_id,
|
||
"uiClass": act.get("uiClass") or "mode-idle",
|
||
}
|
||
)
|
||
return out
|
||
|
||
def _build_catalog_response(self) -> dict:
|
||
vehicles = []
|
||
for m in self._read_manifest_models():
|
||
if not isinstance(m, dict):
|
||
continue
|
||
model_id = m.get("id") or ""
|
||
cfg_file = m.get("config") or (f"{model_id}.json" if model_id else "")
|
||
item = {
|
||
"modelId": model_id,
|
||
"name": m.get("name") or model_id,
|
||
"file": m.get("file"),
|
||
"config": cfg_file,
|
||
"root": m.get("root"),
|
||
"actions": [],
|
||
"configError": None,
|
||
}
|
||
if not cfg_file:
|
||
item["configError"] = "缺少 config 字段"
|
||
vehicles.append(item)
|
||
continue
|
||
cfg_path = (AGV_DIR / cfg_file).resolve()
|
||
if not str(cfg_path).startswith(str(AGV_DIR)) or not cfg_path.exists():
|
||
item["configError"] = f"缺少配置 {cfg_file}"
|
||
vehicles.append(item)
|
||
continue
|
||
try:
|
||
cfg = json.loads(cfg_path.read_text(encoding="utf-8"))
|
||
item["actions"] = self._map_actions(cfg)
|
||
item["root"] = m.get("root") or (cfg.get("model") or {}).get("root") or item["root"]
|
||
except (OSError, json.JSONDecodeError) as e:
|
||
item["configError"] = str(e)
|
||
vehicles.append(item)
|
||
return {"version": 1, "ok": True, "data": {"vehicles": vehicles}, "error": None}
|
||
|
||
def _agv_api(self):
|
||
try:
|
||
length = int(self.headers.get("Content-Length", 0))
|
||
raw = self.rfile.read(length).decode("utf-8")
|
||
payload = json.loads(raw)
|
||
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
||
self._json_response(400, {"version": 1, "ok": False, "data": None, "error": {"code": "INVALID_REQUEST", "message": str(e)}})
|
||
return
|
||
|
||
req_id = payload.get("id")
|
||
version = payload.get("version")
|
||
method = (payload.get("method") or "").strip()
|
||
if version != 1:
|
||
self._json_response(400, {"version": 1, "id": req_id, "ok": False, "data": None, "error": {"code": "INVALID_REQUEST", "message": "version 须为 1"}})
|
||
return
|
||
if not method:
|
||
self._json_response(400, {"version": 1, "id": req_id, "ok": False, "data": None, "error": {"code": "INVALID_REQUEST", "message": "缺少 method"}})
|
||
return
|
||
|
||
if method not in {"playAction", "stopAction", "clearActions"}:
|
||
self._json_response(
|
||
400,
|
||
{
|
||
"version": 1,
|
||
"id": req_id,
|
||
"ok": False,
|
||
"data": None,
|
||
"error": {
|
||
"code": "UNKNOWN_METHOD",
|
||
"message": "POST /api/agv 仅支持 playAction、stopAction、clearActions;查询请用 GET /api/catalog",
|
||
},
|
||
},
|
||
)
|
||
return
|
||
|
||
try:
|
||
params = payload.get("params") or {}
|
||
self._validate_remote(method, params)
|
||
data = HUB.dispatch(method, params, req_id)
|
||
hint = None
|
||
if data.get("subscribers", 0) == 0:
|
||
hint = "无播放页在线,命令已缓存;打开 play.html 后将自动同步"
|
||
self._json_response(
|
||
200,
|
||
{
|
||
"version": 1,
|
||
"id": req_id,
|
||
"ok": True,
|
||
"data": data,
|
||
"hint": hint,
|
||
"error": None,
|
||
},
|
||
)
|
||
except ValueError as e:
|
||
self._json_response(
|
||
400,
|
||
{
|
||
"version": 1,
|
||
"id": req_id,
|
||
"ok": False,
|
||
"data": None,
|
||
"error": {"code": "INVALID_REQUEST", "message": str(e)},
|
||
},
|
||
)
|
||
|
||
def _find_model(self, model_id: str) -> dict | None:
|
||
for m in self._read_manifest_models():
|
||
if isinstance(m, dict) and m.get("id") == model_id:
|
||
return m
|
||
return None
|
||
|
||
def _load_model_config(self, model_id: str) -> tuple[dict | None, str | None]:
|
||
entry = self._find_model(model_id)
|
||
if not entry:
|
||
return None, f"模型不存在: {model_id}"
|
||
cfg_file = entry.get("config") or f"{model_id}.json"
|
||
cfg_path = (AGV_DIR / cfg_file).resolve()
|
||
if not str(cfg_path).startswith(str(AGV_DIR)) or not cfg_path.exists():
|
||
return None, f"缺少配置 {cfg_file}"
|
||
try:
|
||
return json.loads(cfg_path.read_text(encoding="utf-8")), None
|
||
except (OSError, json.JSONDecodeError) as e:
|
||
return None, str(e)
|
||
|
||
@staticmethod
|
||
def _pick_action_ids(params: dict) -> list[str]:
|
||
ids = params.get("actionIds") or params.get("actions")
|
||
if isinstance(ids, list):
|
||
return [str(x).strip() for x in ids if str(x).strip()]
|
||
if isinstance(ids, str):
|
||
return [s.strip() for s in ids.replace("+", ",").split(",") if s.strip()]
|
||
one = params.get("actionId") or params.get("action")
|
||
return [str(one).strip()] if one else []
|
||
|
||
def _validate_remote(self, method: str, params: dict) -> None:
|
||
model_id = (params.get("modelId") or params.get("model") or "").strip()
|
||
if not model_id:
|
||
raise ValueError("params.modelId 必填")
|
||
if not self._find_model(model_id):
|
||
raise ValueError(f"模型不存在: {model_id}")
|
||
|
||
if method == "clearActions":
|
||
return
|
||
|
||
cfg, err = self._load_model_config(model_id)
|
||
if err:
|
||
raise ValueError(err)
|
||
if method in {"playAction", "stopAction"}:
|
||
action_ids = self._pick_action_ids(params)
|
||
if not action_ids:
|
||
raise ValueError("params.actionId 必填")
|
||
known = set((cfg.get("actions") or {}).keys())
|
||
for action_id in action_ids:
|
||
if action_id not in known:
|
||
raise ValueError(f"动作不存在: {action_id}(模型 {model_id})")
|
||
|
||
def _agv_report(self):
|
||
try:
|
||
length = int(self.headers.get("Content-Length", 0))
|
||
raw = self.rfile.read(length).decode("utf-8")
|
||
payload = json.loads(raw)
|
||
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
||
self._json_response(400, {"ok": False, "error": str(e)})
|
||
return
|
||
|
||
client_id = str(payload.get("clientId") or "").strip()
|
||
if not client_id:
|
||
self._json_response(400, {"ok": False, "error": "clientId 必填"})
|
||
return
|
||
|
||
model_id = payload.get("modelId")
|
||
if isinstance(model_id, str):
|
||
model_id = model_id.strip() or None
|
||
active = payload.get("activeActions")
|
||
if active is not None and not isinstance(active, list):
|
||
self._json_response(400, {"ok": False, "error": "activeActions 须为数组"})
|
||
return
|
||
|
||
data = HUB.report_from_client(client_id, model_id, active)
|
||
self._json_response(200, {"ok": True, "data": data})
|
||
|
||
def _cors_headers(self):
|
||
self.send_header("Access-Control-Allow-Origin", "*")
|
||
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
||
|
||
def _sse_write(self, payload: dict):
|
||
line = f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
|
||
self.wfile.write(line.encode("utf-8"))
|
||
self.wfile.flush()
|
||
|
||
def _agv_events_sse(self):
|
||
self.send_response(200)
|
||
self.send_header("Content-Type", "text/event-stream; charset=utf-8")
|
||
self.send_header("Cache-Control", "no-cache")
|
||
self.send_header("Connection", "keep-alive")
|
||
self._cors_headers()
|
||
self.end_headers()
|
||
|
||
sub = HUB.subscribe()
|
||
try:
|
||
state = HUB.get_state()
|
||
self._sse_write({"type": "connected", "revision": state.get("revision", 0), "state": state})
|
||
while True:
|
||
try:
|
||
msg = sub.get(timeout=15)
|
||
self._sse_write(msg)
|
||
except queue.Empty:
|
||
self._sse_write({"type": "ping"})
|
||
except (BrokenPipeError, ConnectionResetError, OSError):
|
||
pass
|
||
finally:
|
||
HUB.unsubscribe(sub)
|
||
|
||
def _json_response(self, code: int, data: dict):
|
||
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
|
||
self.send_response(code)
|
||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||
self.send_header("Content-Length", str(len(body)))
|
||
self._cors_headers()
|
||
self.end_headers()
|
||
self.wfile.write(body)
|
||
|
||
def log_message(self, fmt, *args):
|
||
sys.stderr.write("%s - %s\n" % (self.address_string(), fmt % args))
|
||
|
||
|
||
def main():
|
||
os.chdir(ROOT)
|
||
with http.server.ThreadingHTTPServer(("", PORT), Handler) as httpd:
|
||
print(f"Serving {ROOT}")
|
||
print(f"配置页: http://127.0.0.1:{PORT}/viewer/index.html")
|
||
print(f"播放页: http://127.0.0.1:{PORT}/viewer/play.html")
|
||
print(f"远程API: GET /api/catalog | POST /api/agv (playAction / stopAction / clearActions)")
|
||
try:
|
||
httpd.serve_forever()
|
||
except KeyboardInterrupt:
|
||
print("\n已停止服务")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|