Files
Migu2.0/SimpleLite/model/agv_glb/serve.py
T
ArtoriasWu 15405ba114 新增 WMS 搬运规则与任务管理
支持搬运规则维护、候选预览、任务生成、预占、下发、取消和完成,并完善仓储管理前端交互。
2026-06-25 11:02:22 +08:00

380 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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()