S SmartDocs
Serie: Humanoid python 198 righe · Aggiornato 2026-05-12

hmi.py

Humanoid/May_12/RoboOS/master/task_manager/hmi.py

# -*- coding: utf-8 -*-
"""HMI:Flask Blueprint + WebSocket 推送橋接器。

對應架構教學文件 §5.5、interfaces.md §4。
"""
from __future__ import annotations

import json
import logging
import threading
from typing import Any, Callable, Dict, Optional

from flask import Blueprint, Flask, jsonify, request

from .context import ContextStore
from .diagnostics import TaskDiagnostics
from .events import ProgressEvent
from .monitor import TaskMonitor
from .scheduler import TaskScheduler


logger = logging.getLogger(__name__)


def make_blueprint(
    scheduler: TaskScheduler,
    monitor: TaskMonitor,
    diagnostics: TaskDiagnostics,
    context_store: ContextStore,
) -> Blueprint:
    bp = Blueprint("task_manager_hmi", __name__)

    # --- 控制端點 -------------------------------------------------------
    @bp.post("/control/start")
    def control_start():
        data = request.get_json(silent=True) or {}
        task_text = data.get("task")
        if not task_text:
            return jsonify({"status": "error", "message": "missing 'task'"}), 400
        if isinstance(task_text, list):
            task_text = "\n".join(str(t) for t in task_text)
        result = scheduler.start_task(task_text, refresh=bool(data.get("refresh", False)))
        return jsonify(result), (200 if result.get("status") == "ok" else 400)

    @bp.post("/control/pause")
    def control_pause():
        scheduler.pause()
        return jsonify({"state": scheduler.state.value})

    @bp.post("/control/resume")
    def control_resume():
        scheduler.resume()
        return jsonify({"state": scheduler.state.value})

    @bp.post("/control/abort")
    def control_abort():
        data = request.get_json(silent=True) or {}
        scheduler.abort(reason=data.get("reason", "user_request"))
        return jsonify({"state": scheduler.state.value})

    @bp.post("/control/reset")
    def control_reset():
        scheduler.reset()
        return jsonify({"state": scheduler.state.value})

    @bp.post("/mode")
    def set_mode():
        data = request.get_json(silent=True) or {}
        mode = data.get("mode")
        if not mode:
            return jsonify({"status": "error", "message": "missing 'mode'"}), 400
        result = scheduler.switch_mode(mode)
        return jsonify(result), (200 if result.get("status") == "ok" else 400)

    # --- 查詢端點 -------------------------------------------------------
    @bp.get("/task_status")
    def task_status():
        return jsonify(scheduler.status())

    @bp.get("/faults")
    def faults():
        task_id = request.args.get("task_id") or scheduler.current_task_id
        limit_raw = request.args.get("limit", "20")
        try:
            limit = max(0, int(limit_raw))
        except ValueError:
            limit = 20
        if not task_id:
            return jsonify([])
        records = diagnostics.list_faults(task_id)
        return jsonify(records[-limit:] if limit else records)

    @bp.get("/scene/snapshot")
    def scene_snapshot():
        snap = context_store.read_current_scene()
        return jsonify(snap or {})

    @bp.get("/task/<task_id>/context")
    def task_context(task_id: str):
        ctx = context_store.get(task_id)
        if not ctx:
            return jsonify({"status": "error", "message": "task not found"}), 404
        return jsonify(ctx.to_dict())

    @bp.get("/runtimes")
    def runtimes():
        return jsonify(monitor.snapshot())

    return bp


class WSBridge:
    """把 task_progress / task_faults channel 的訊息透過 socketio.emit 推給前端。

    用法:
        bridge = WSBridge(socketio, collaborator)
        bridge.start()
    """

    def __init__(
        self,
        socketio: Any,
        collaborator: Any,
        progress_channel: str = "task_progress",
        fault_channel: str = "task_faults",
        scene_channel: Optional[str] = None,  # 預留:日後想推 scene_update 用
    ) -> None:
        self.socketio = socketio
        self.collaborator = collaborator
        self.progress_channel = progress_channel
        self.fault_channel = fault_channel
        self.scene_channel = scene_channel
        self._threads: list[threading.Thread] = []
        self._stop = threading.Event()

    def start(self) -> None:
        for channel, event in (
            (self.progress_channel, "task_progress"),
            (self.fault_channel, "task_fault"),
        ):
            t = threading.Thread(
                target=self._loop,
                args=(channel, event),
                daemon=True,
                name=f"ws-bridge-{channel}",
            )
            t.start()
            self._threads.append(t)
        logger.info(
            "[WSBridge] bridging: %s",
            [(t.name) for t in self._threads],
        )

    def stop(self) -> None:
        self._stop.set()

    def push_progress(self, evt: ProgressEvent) -> None:
        """供 monitor.on_progress 直接調用(避免再經 Redis 一圈)。"""
        try:
            self.socketio.emit("task_progress", evt.to_dict(), namespace="/")
        except Exception as e:  # noqa: BLE001
            logger.warning("[WSBridge] emit progress fail: %s", e)

    def _loop(self, channel: str, ws_event_name: str) -> None:
        while not self._stop.is_set():
            try:
                self.collaborator.listen(channel, lambda raw: self._forward(ws_event_name, raw))
                # listen 不會自然返回;若返回則認為訂閱結束
            except Exception as e:  # noqa: BLE001
                logger.warning("[WSBridge] listen(%s) error: %s", channel, e)
            self._stop.wait(2.0)

    def _forward(self, ws_event_name: str, raw: str) -> None:
        try:
            payload = json.loads(raw)
        except json.JSONDecodeError:
            payload = {"raw": raw}
        try:
            self.socketio.emit(ws_event_name, payload, namespace="/")
        except Exception as e:  # noqa: BLE001
            logger.warning("[WSBridge] emit(%s) fail: %s", ws_event_name, e)


def register(
    app: Flask,
    scheduler: TaskScheduler,
    monitor: TaskMonitor,
    diagnostics: TaskDiagnostics,
    context_store: ContextStore,
    url_prefix: str = "",
) -> Blueprint:
    bp = make_blueprint(scheduler, monitor, diagnostics, context_store)
    app.register_blueprint(bp, url_prefix=url_prefix)
    logger.info("[HMI] blueprint registered at prefix=%r", url_prefix)
    return bp


__all__ = ["register", "make_blueprint", "WSBridge"]

Articoli correlati