Pārlūkot izejas kodu

联动树莓派

AnlaAnla 3 nedēļas atpakaļ
vecāks
revīzija
699c6f8845

+ 2 - 2
Test/mqtt_test.py

@@ -81,9 +81,9 @@ def on_message(client, userdata, msg):
         # 拍照
         if camera_command_data.get("cmd") == "capture":
             img_name = camera_command_data.get("id", "0")
+            print(f"--- start capture: {img_name}[{time.time()}] ---")
             capture(f"{img_name}.jpg")
-            time.sleep(0.5)
-            print(f"--- save img {img_name} ---")
+            print(f"--- save img {img_name}[{time.time()}] ---")
 
         # 通知拍照完成
         send_camera_response(success=1)

+ 26 - 2
Test/test01.py

@@ -25,10 +25,34 @@ except Exception as e:
 
 time.sleep(0.1)  # 等待相机初始化
 
+
+t1 = time.time()
+frame = picam2.capture_array()  # 捕获图像
+# 打印图像信息
+# print(frame)
+# print(frame.shape)  # 这里使用 shape 来查看图像的尺寸
+
+# 保存图片查看结果
+cv2.imwrite("test.jpg", frame)
+print(time.time()-t1)
+
+t1 = time.time()
+frame = picam2.capture_array()  # 捕获图像
+# 打印图像信息
+# print(frame)
+# print(frame.shape)  # 这里使用 shape 来查看图像的尺寸
+
+# 保存图片查看结果
+cv2.imwrite("test.jpg", frame)
+print(time.time()-t1)
+
+
+t1 = time.time()
 frame = picam2.capture_array()  # 捕获图像
 # 打印图像信息
-print(frame)
-print(frame.shape)  # 这里使用 shape 来查看图像的尺寸
+# print(frame)
+# print(frame.shape)  # 这里使用 shape 来查看图像的尺寸
 
 # 保存图片查看结果
 cv2.imwrite("test.jpg", frame)
+print(time.time()-t1)

+ 48 - 1
app/api/camera.py

@@ -1,11 +1,15 @@
 from datetime import datetime
 
-from fastapi import APIRouter, HTTPException, Query, Response
+from fastapi import APIRouter, HTTPException, Query, Request, Response
 
+from app.core.config import settings
 from app.services import (
     CameraCaptureError,
     CameraUnavailableError,
+    MqttCaptureBusyError,
+    MqttCaptureError,
     get_camera_service,
+    get_mqtt_capture_service,
 )
 
 router = APIRouter(
@@ -45,3 +49,46 @@ def capture_image(
             "Cache-Control": "no-store",
         },
     )
+
+
+@router.api_route(
+    "/capture-pair",
+    methods=["GET", "POST"],
+    summary="通过 MQTT 联动拍摄正反两张图片",
+)
+def capture_pair_via_mqtt(request: Request) -> dict[str, object]:
+    """
+    机械臂联动拍照接口。
+
+    调用流程:
+    1. 本接口收到请求后,临时连接 MQTT。
+    2. 发送 start 指令给机械臂。
+    3. 收到 `id=1` / `id=2` 的拍照指令后,分别覆盖保存到静态目录。
+    4. 等待 `status=4` 作为流程结束信号。
+    5. 断开 MQTT,返回两张图片的静态访问地址。
+
+    返回 JSON 而不是直接返回图片,是因为这条流程一次会产出两张图,
+    更适合用 URL 的方式交给前端或其他服务继续处理。
+    """
+    mqtt_capture_service = get_mqtt_capture_service()
+
+    try:
+        result = mqtt_capture_service.capture_pair()
+    except MqttCaptureBusyError as exc:
+        raise HTTPException(status_code=409, detail=str(exc)) from exc
+    except MqttCaptureError as exc:
+        raise HTTPException(status_code=503, detail=str(exc)) from exc
+
+    front_url = str(
+        request.url_for("static", path=settings.static.front_relative_path.as_posix())
+    )
+    back_url = str(
+        request.url_for("static", path=settings.static.back_relative_path.as_posix())
+    )
+
+    return {
+        "request_id": result["request_id"],
+        "status": result["status"],
+        "front_url": front_url,
+        "back_url": back_url,
+    }

+ 77 - 103
app/core/config.py

@@ -1,124 +1,98 @@
-import os
-from dataclasses import dataclass
-from functools import lru_cache
-from dotenv import load_dotenv
+from dataclasses import dataclass, field
+from pathlib import Path
 
-load_dotenv()
+PROJECT_ROOT = Path(__file__).resolve().parents[2]
 
 
-def _get_bool(name: str, default: bool) -> bool:
-    value = os.getenv(name)
-    if value is None:
-        return default
-
-    return value.strip().lower() in {"1", "true", "yes", "on"}
+@dataclass(frozen=True)
+class AppSettings:
+    """FastAPI 基础配置。"""
 
+    title: str = "Raspberry Pi Camera API"
+    version: str = "0.1.0"
+    description: str = "用于树莓派相机抓拍的 FastAPI 服务。"
+    host: str = "0.0.0.0"
+    port: int = 8002
 
-def _get_int(name: str, default: int) -> int:
-    """从环境变量中读取整数配置。"""
-    value = os.getenv(name)
-    if value is None:
-        return default
 
-    try:
-        return int(value)
-    except ValueError:
-        return default
+@dataclass(frozen=True)
+class CameraSettings:
+    """树莓派相机配置。"""
 
+    camera_index: int = 0
+    width: int = 2048
+    height: int = 2048
+    frame_format: str = "RGB888"
+    jpeg_quality: int = 95
+    warmup_seconds: float = 0.05
+    enable_continuous_autofocus: bool = True
+    enable_auto_white_balance: bool = True
 
-def _get_float(name: str, default: float) -> float:
-    """从环境变量中读取浮点数配置。"""
-    value = os.getenv(name)
-    if value is None:
-        return default
 
-    try:
-        return float(value)
-    except ValueError:
-        return default
+@dataclass(frozen=True)
+class MqttSettings:
+    """MQTT 配置。"""
+
+    broker: str = "192.168.77.132"
+    port: int = 1883
+    keep_alive_interval: int = 60
+    client_id_prefix: str = "arm_camera_api_"
+    request_timeout_seconds: float = 40.0
+    connect_timeout_seconds: float = 5.0
+    default_cycles: int = 1
+    command_topic: str = "arm_card_dealer/command"
+    camera_response_topic: str = "arm_card_dealer/camera/response"
+    status_topic: str = "arm_card_dealer/status"
+    error_topic: str = "arm_card_dealer/error"
+    camera_command_topic: str = "arm_card_dealer/camera/command"
 
 
 @dataclass(frozen=True)
-class AppSettings:
-    """
-    FastAPI 服务本身的运行配置。
-    """
-
-    title: str
-    version: str
-    description: str
-    host: str
-    port: int
-
-    @classmethod
-    def from_env(cls) -> "AppSettings":
-        return cls(
-            title=os.getenv("APP_TITLE", "Raspberry Pi Camera API"),
-            version=os.getenv("APP_VERSION", "0.1.0"),
-            description=os.getenv(
-                "APP_DESCRIPTION",
-                "用于树莓派相机抓拍的 FastAPI 服务。",
-            ),
-            host=os.getenv("APP_HOST", "0.0.0.0"),
-            port=_get_int("APP_PORT", 8002)
-        )
+class StaticSettings:
+    """静态目录配置。"""
 
+    root_dir: Path = field(default_factory=lambda: PROJECT_ROOT / "static")
+    capture_dir_name: str = "captures"
+    front_filename: str = "1.jpg"
+    back_filename: str = "2.jpg"
+    index_filename: str = "index.html"
 
-@dataclass(frozen=True)
-class CameraSettings:
-    """
-    树莓派相机的集中配置。
-    """
-
-    camera_index: int
-    width: int
-    height: int
-    frame_format: str
-    jpeg_quality: int
-    warmup_seconds: float
-    enable_continuous_autofocus: bool
-    enable_auto_white_balance: bool
-
-    @classmethod
-    def from_env(cls) -> "CameraSettings":
-        return cls(
-            camera_index=_get_int("CAMERA_INDEX", 0),
-            width=_get_int("CAMERA_WIDTH", 2048),
-            height=_get_int("CAMERA_HEIGHT", 2048),
-            frame_format=os.getenv("CAMERA_FRAME_FORMAT", "RGB888"),
-            jpeg_quality=_get_int("CAMERA_JPEG_QUALITY", 95),
-            warmup_seconds=_get_float("CAMERA_WARMUP_SECONDS", 0.05),
-            enable_continuous_autofocus=_get_bool(
-                "CAMERA_ENABLE_CONTINUOUS_AUTOFOCUS",
-                True,
-            ),
-            enable_auto_white_balance=_get_bool(
-                "CAMERA_ENABLE_AUTO_WHITE_BALANCE",
-                True,
-            ),
-        )
+    @property
+    def capture_dir(self) -> Path:
+        return self.root_dir / self.capture_dir_name
 
+    @property
+    def front_image_path(self) -> Path:
+        return self.capture_dir / self.front_filename
 
-@dataclass(frozen=True)
-class Settings:
-    """项目总配置入口,统一汇总服务配置与相机配置。"""
+    @property
+    def back_image_path(self) -> Path:
+        return self.capture_dir / self.back_filename
+
+    @property
+    def front_relative_path(self) -> Path:
+        return Path(self.capture_dir_name) / self.front_filename
+
+    @property
+    def back_relative_path(self) -> Path:
+        return Path(self.capture_dir_name) / self.back_filename
 
-    app: AppSettings
-    camera: CameraSettings
+    @property
+    def index_path(self) -> Path:
+        return self.root_dir / self.index_filename
 
+    def ensure_directories(self) -> None:
+        self.capture_dir.mkdir(parents=True, exist_ok=True)
+
+
+@dataclass(frozen=True)
+class Settings:
+    """项目总配置。"""
 
-@lru_cache(maxsize=1)
-def get_settings() -> Settings:
-    """
-    返回单例配置对象。
+    app: AppSettings = field(default_factory=AppSettings)
+    camera: CameraSettings = field(default_factory=CameraSettings)
+    mqtt: MqttSettings = field(default_factory=MqttSettings)
+    static: StaticSettings = field(default_factory=StaticSettings)
 
-    使用缓存的原因是:
-    1. 避免每次请求都重复读取环境变量。
-    2. 让整个项目里的配置来源保持一致。
-    """
-    return Settings(
-        app=AppSettings.from_env(),
-        camera=CameraSettings.from_env(),
-    )
 
-settings = get_settings()
+settings = Settings()

+ 14 - 12
app/main.py

@@ -1,15 +1,20 @@
 import logging
 from contextlib import asynccontextmanager
 
-from fastapi import FastAPI
+from fastapi import FastAPI, Request
+from fastapi.responses import FileResponse
+from fastapi.staticfiles import StaticFiles
 
 from app.api import api_router
+from app.api.camera import capture_pair_via_mqtt
 from app.services import CameraUnavailableError, get_camera_service
 from app.core.config import settings
 
 logging.basicConfig(level=logging.INFO)
 logger = logging.getLogger(__name__)
 
+settings.static.ensure_directories()
+
 
 @asynccontextmanager
 async def lifespan(_: FastAPI):
@@ -43,20 +48,17 @@ app = FastAPI(
 )
 
 app.include_router(api_router, prefix="/api")
+app.mount("/static", StaticFiles(directory=str(settings.static.root_dir)), name="static")
 
 
-@app.get("/", summary="服务入口")
-def read_root() -> dict[str, str]:
-    """
-    服务入口说明。
+@app.get("/", include_in_schema=False)
+def read_root() -> FileResponse:
+    return FileResponse(settings.static.index_path)
 
-    这里返回最基础的服务信息,方便你确认服务已启动,
-    也方便后续再逐步加入更多模块说明。
-    """
-    return {
-        "message": "Raspberry Pi Camera API is running.",
-        "docs": "/docs",
-    }
+
+@app.api_route("/capture-pair", methods=["GET", "POST"], include_in_schema=False)
+def capture_pair_alias(request: Request):
+    return capture_pair_via_mqtt(request)
 
 
 @app.get("/health", summary="健康检查")

+ 10 - 0
app/services/__init__.py

@@ -4,10 +4,20 @@ from app.services.camera_service import (
     CameraUnavailableError,
     get_camera_service,
 )
+from app.services.mqtt_capture_service import (
+    MqttCaptureBusyError,
+    MqttCaptureError,
+    MqttCaptureService,
+    get_mqtt_capture_service,
+)
 
 __all__ = [
     "CameraCaptureError",
     "CameraService",
     "CameraUnavailableError",
     "get_camera_service",
+    "MqttCaptureBusyError",
+    "MqttCaptureError",
+    "MqttCaptureService",
+    "get_mqtt_capture_service",
 ]

+ 71 - 134
app/services/camera_service.py

@@ -2,7 +2,8 @@ import logging
 import threading
 import time
 from io import BytesIO
-from typing import Any, Optional
+from pathlib import Path
+from typing import Any, Optional, Union
 
 import cv2
 from PIL import Image
@@ -13,202 +14,138 @@ logger = logging.getLogger(__name__)
 
 
 class CameraUnavailableError(RuntimeError):
-    """相机不可用,例如依赖缺失或硬件初始化失败。"""
+    """相机不可用。"""
 
 
 class CameraCaptureError(RuntimeError):
-    """相机抓拍失败,例如采集帧失败或图像编码失败。"""
+    """相机抓拍失败。"""
 
 
 class CameraService:
-    """
-    树莓派相机服务。
-
-    设计上把硬件访问封装到这个类里,而不是直接写在接口函数中,
-    有几个明显好处:
-    1. 路由层只关心 HTTP 请求和响应,职责更清晰。
-    2. 以后如果要增加录像、参数调节、图片落盘等功能,可以继续在这里扩展。
-    3. 相机对象是重量级资源,做成服务之后更容易复用和统一关闭。
-    """
-
-    def __init__(self, settings: CameraSettings) -> None:
-        self._settings = settings
-        self._lock = threading.RLock()
+    """树莓派相机服务。"""
+
+    def __init__(self, config: CameraSettings) -> None:
+        self._config = config
         self._camera: Optional[Any] = None
+        self._lock = threading.RLock()
 
     @property
     def is_initialized(self) -> bool:
-        """用于健康检查,判断相机对象是否已经初始化完成。"""
         return self._camera is not None
 
     def initialize(self) -> None:
-        """
-        显式初始化相机。
-
-        这个方法适合在 FastAPI 启动阶段预热相机,
-        也适合你以后做手动重连、后台巡检时复用。
-        """
         with self._lock:
-            self._ensure_camera()
+            self._get_camera()
 
     def capture_jpeg(self) -> bytes:
-        """
-        抓拍一张图片,并直接返回 JPEG 二进制内容。
-        """
         with self._lock:
-            camera = self._ensure_camera()
-
             try:
-                frame = camera.capture_array()
-                frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
+                frame = self._get_camera().capture_array()
             except Exception as exc:
-                raise CameraCaptureError("相机抓拍失败,请检查相机连接和占用状态。") from exc
+                raise CameraCaptureError("相机抓拍失败,请检查相机连接状态。") from exc
 
-            # 当前设备输出 shape=(H, W, 3),PIL 直接接收前3通道颜色正确
             if getattr(frame, "ndim", 0) != 3 or frame.shape[2] < 3:
                 raise CameraCaptureError(
-                    f"相机返回了无法识别的帧结构: shape={getattr(frame, 'shape', None)}"
+                    f"相机返回了异常图像数据: shape={getattr(frame, 'shape', None)}"
                 )
 
             try:
-                image = Image.fromarray(frame[:, :, :3])
-
-                # image.save("temp.jpg")
+                rgb_frame = cv2.cvtColor(frame[:, :, :3], cv2.COLOR_BGR2RGB)
+                image = Image.fromarray(rgb_frame)
                 buffer = BytesIO()
-                image.save(
-                    buffer,
-                    format="JPEG",
-                    quality=self._settings.jpeg_quality,
-                )
+                image.save(buffer, format="JPEG", quality=self._config.jpeg_quality)
                 return buffer.getvalue()
             except Exception as exc:
-                raise CameraCaptureError("图片编码为 JPEG 失败。") from exc
+                raise CameraCaptureError("图片编码失败。") from exc
 
-    def close(self) -> None:
-        """
-        关闭相机资源。
+    def capture_to_file(self, output_path: Union[str, Path]) -> Path:
+        output_path = Path(output_path)
+        output_path.parent.mkdir(parents=True, exist_ok=True)
+        output_path.write_bytes(self.capture_jpeg())
+        return output_path
 
-        FastAPI 服务退出时应调用这个方法,避免相机句柄残留,
-        否则后续重新启动服务时可能出现资源被占用的问题。
-        """
+    def close(self) -> None:
         with self._lock:
-            if self._camera is None:
+            camera = self._camera
+            self._camera = None
+
+            if camera is None:
                 return
 
             try:
-                self._camera.stop()
+                camera.stop()
             except Exception:
-                logger.exception("停止相机时发生异常。")
-            finally:
-                try:
-                    self._camera.close()
-                except Exception:
-                    logger.exception("关闭相机句柄时发生异常。")
-                finally:
-                    self._camera = None
-
-    def _ensure_camera(self) -> Any:
-        """
-        延迟初始化相机。
-        """
+                logger.warning("停止相机时发生异常。", exc_info=True)
+
+            try:
+                camera.close()
+            except Exception:
+                logger.warning("关闭相机时发生异常。", exc_info=True)
+
+    def _get_camera(self) -> Any:
         if self._camera is not None:
             return self._camera
 
-        camera = None
-
         try:
             from libcamera import controls
             from picamera2 import Picamera2
         except ImportError as exc:
             raise CameraUnavailableError(
-                "当前环境缺少树莓派相机依赖,请确认已安装 picamera2 和 libcamera。"
+                "当前环境缺少 picamera2 或 libcamera。"
             ) from exc
 
+        camera = None
         try:
-            camera = Picamera2(camera_num=self._settings.camera_index)
-            still_configuration = self._build_still_configuration(camera)
-            camera.configure(still_configuration)
+            camera = Picamera2(camera_num=self._config.camera_index)
+            try:
+                camera.configure(
+                    camera.create_still_configuration(
+                        main={
+                            "size": (self._config.width, self._config.height),
+                            "format": self._config.frame_format,
+                        }
+                    )
+                )
+            except Exception:
+                logger.warning("相机不支持 %s,已回退默认格式。", self._config.frame_format)
+                camera.configure(
+                    camera.create_still_configuration(
+                        main={"size": (self._config.width, self._config.height)}
+                    )
+                )
+
             camera.start()
 
-            self._apply_optional_controls(camera, controls)
-            time.sleep(self._settings.warmup_seconds)
-        except Exception as exc:
-            if camera is not None:
+            if self._config.enable_continuous_autofocus:
                 try:
-                    camera.stop()
+                    camera.set_controls({"AfMode": controls.AfModeEnum.Continuous})
                 except Exception:
-                    logger.exception("初始化失败后停止相机时发生异常。")
+                    logger.warning("当前摄像头不支持自动对焦。", exc_info=True)
+
+            if self._config.enable_auto_white_balance:
                 try:
-                    camera.close()
+                    camera.set_controls({"AwbMode": controls.AwbModeEnum.Auto})
                 except Exception:
-                    logger.exception("初始化失败后关闭相机句柄时发生异常。")
+                    logger.warning("当前摄像头不支持自动白平衡。", exc_info=True)
 
-            raise CameraUnavailableError(
-                "树莓派相机初始化失败,请检查相机排线、驱动和权限配置。"
-            ) from exc
+            time.sleep(self._config.warmup_seconds)
+        except Exception as exc:
+            if camera is not None:
+                try:
+                    camera.close()
+                except Exception:
+                    logger.warning("相机初始化失败后关闭资源时发生异常。", exc_info=True)
+            raise CameraUnavailableError("树莓派相机初始化失败。") from exc
 
         self._camera = camera
         return camera
 
-    def _build_still_configuration(self, camera: Any) -> Any:
-        """
-        创建静态拍照配置。
-
-        默认优先尝试使用 `RGB888`,这样返回的数组通常就是 3 通道 RGB,
-        后续编码 JPEG 会更直接。
-        如果目标设备不支持指定格式,则自动回退到默认配置。
-        """
-        main_config = {
-            "size": (self._settings.width, self._settings.height),
-        }
-
-        if self._settings.frame_format:
-            main_config["format"] = self._settings.frame_format
-
-        try:
-            return camera.create_still_configuration(main=main_config)
-        except Exception:
-            if "format" not in main_config:
-                raise
-
-            logger.warning(
-                "相机不支持指定的帧格式 %s,已回退到默认格式。",
-                self._settings.frame_format,
-            )
-            main_config.pop("format")
-            return camera.create_still_configuration(main=main_config)
-
-    def _apply_optional_controls(self, camera: Any, controls: Any) -> None:
-        """
-        应用可选相机参数。
-
-        对焦和白平衡不一定所有摄像头都支持,所以这里采用“尽力而为”的策略:
-        支持就启用,不支持则记录日志,但不阻断整个服务启动。
-        """
-        if self._settings.enable_continuous_autofocus:
-            try:
-                camera.set_controls({"AfMode": controls.AfModeEnum.Continuous})
-            except Exception:
-                logger.warning("当前摄像头不支持连续自动对焦,已跳过。", exc_info=True)
-
-        if self._settings.enable_auto_white_balance:
-            try:
-                camera.set_controls({"AwbMode": controls.AwbModeEnum.Auto})
-            except Exception:
-                logger.warning("当前摄像头不支持自动白平衡,已跳过。", exc_info=True)
-
 
 _camera_service: Optional[CameraService] = None
 _camera_service_lock = threading.Lock()
 
 
 def get_camera_service() -> CameraService:
-    """
-    返回全局单例相机服务。
-
-    相机通常只需要维护一个共享实例,否则多个请求反复创建/关闭相机,
-    会明显增加拍照延迟,也更容易触发硬件占用问题。
-    """
     global _camera_service
 
     if _camera_service is not None:

+ 235 - 0
app/services/mqtt_capture_service.py

@@ -0,0 +1,235 @@
+import json
+import logging
+import threading
+import time
+from pathlib import Path
+from typing import Optional
+
+import paho.mqtt.client as mqtt
+
+from app.core.config import settings
+from app.services.camera_service import (
+    CameraCaptureError,
+    CameraUnavailableError,
+    get_camera_service,
+)
+
+logger = logging.getLogger(__name__)
+
+
+class MqttCaptureError(RuntimeError):
+    """MQTT 联动拍照失败。"""
+
+
+class MqttCaptureBusyError(MqttCaptureError):
+    """当前已有任务在执行。"""
+
+
+_capture_lock = threading.Lock()
+
+
+class MqttCaptureService:
+    """按请求临时连接 MQTT,拍完两张图后立即断开。"""
+
+    def __init__(self) -> None:
+        self._mqtt = settings.mqtt
+        self._static = settings.static
+        self._camera = get_camera_service()
+
+    def capture_pair(self) -> dict[str, object]:
+        if not _capture_lock.acquire(blocking=False):
+            raise MqttCaptureBusyError("当前已有一个 MQTT 拍照任务正在执行,请稍后再试。")
+
+        self._static.ensure_directories()
+        request_id = f"req-{int(time.time() * 1000)}"
+        front_path = self._static.front_image_path
+        back_path = self._static.back_image_path
+        state = {
+            "connected": threading.Event(),
+            "done": threading.Event(),
+            "error": "",
+            "status": None,
+            "front_saved": False,
+            "back_saved": False,
+        }
+
+        client = mqtt.Client(
+            client_id=f"{self._mqtt.client_id_prefix}{int(time.time() * 1000)}",
+            clean_session=True,
+        )
+        client.enable_logger(logger)
+
+        def finish_with_error(message: str) -> None:
+            if not state["error"]:
+                state["error"] = message
+            state["done"].set()
+
+        def on_connect(client: mqtt.Client, userdata, flags, rc: int) -> None:
+            if rc != 0:
+                finish_with_error(f"MQTT 连接失败,返回码: {rc}")
+                state["connected"].set()
+                return
+
+            for topic in (
+                self._mqtt.status_topic,
+                self._mqtt.error_topic,
+                self._mqtt.camera_command_topic,
+            ):
+                result, _ = client.subscribe(topic, qos=1)
+                if result != mqtt.MQTT_ERR_SUCCESS:
+                    finish_with_error(f"订阅 MQTT topic 失败: {topic}")
+                    break
+
+            state["connected"].set()
+
+        def on_disconnect(client: mqtt.Client, userdata, rc: int) -> None:
+            if rc != 0 and not state["done"].is_set():
+                finish_with_error(f"MQTT 连接意外断开,返回码: {rc}")
+
+        def on_message(client: mqtt.Client, userdata, message: mqtt.MQTTMessage) -> None:
+            payload_text = message.payload.decode("utf-8", errors="replace")
+            logger.info("收到 MQTT 消息 topic=%s payload=%s", message.topic, payload_text)
+
+            try:
+                payload = json.loads(payload_text)
+            except json.JSONDecodeError:
+                finish_with_error(f"收到无法解析的 MQTT 消息: {message.topic}")
+                return
+
+            if message.topic == self._mqtt.status_topic:
+                try:
+                    state["status"] = int(payload.get("status"))
+                except (TypeError, ValueError):
+                    finish_with_error(f"收到非法状态消息: {payload}")
+                    return
+
+                if payload.get("error_code") not in (None, 0):
+                    finish_with_error(f"机械臂返回错误状态: {payload}")
+                    return
+
+                if state["status"] == 4:
+                    state["done"].set()
+                return
+
+            if message.topic == self._mqtt.error_topic:
+                finish_with_error(f"机械臂返回错误消息: {payload}")
+                return
+
+            if message.topic != self._mqtt.camera_command_topic or payload.get("cmd") != "capture":
+                return
+
+            image_id = str(payload.get("id", "")).strip()
+            image_path = self._get_image_path(image_id, front_path, back_path)
+            if image_path is None:
+                error_message = f"收到未知的拍照编号: {image_id}"
+                self._send_camera_response(client, 0, error_message)
+                finish_with_error(error_message)
+                return
+
+            try:
+                self._camera.capture_to_file(image_path)
+            except (CameraUnavailableError, CameraCaptureError, OSError) as exc:
+                error_message = f"保存图片 {image_id}.jpg 失败: {exc}"
+                self._send_camera_response(client, 0, error_message)
+                finish_with_error(error_message)
+                return
+
+            if image_id == "1":
+                state["front_saved"] = True
+            if image_id == "2":
+                state["back_saved"] = True
+
+            self._send_camera_response(client, 1, "")
+
+        client.on_connect = on_connect
+        client.on_disconnect = on_disconnect
+        client.on_message = on_message
+
+        try:
+            try:
+                client.connect(
+                    self._mqtt.broker,
+                    self._mqtt.port,
+                    self._mqtt.keep_alive_interval,
+                )
+            except Exception as exc:
+                raise MqttCaptureError("连接 MQTT Broker 失败,请检查 broker 地址和网络。") from exc
+
+            client.loop_start()
+
+            if not state["connected"].wait(timeout=self._mqtt.connect_timeout_seconds):
+                raise MqttCaptureError("连接 MQTT Broker 超时。")
+
+            if state["error"]:
+                raise MqttCaptureError(str(state["error"]))
+
+            payload = {
+                "cmd": "start",
+                "cycles": self._mqtt.default_cycles,
+                "request_id": request_id,
+                "timestamp": int(time.time() * 1000),
+            }
+            publish_info = client.publish(self._mqtt.command_topic, json.dumps(payload), qos=1)
+            if publish_info.rc != mqtt.MQTT_ERR_SUCCESS:
+                raise MqttCaptureError("发送 start 指令失败。")
+            publish_info.wait_for_publish()
+            logger.info("已发送机械臂 start 指令: %s", payload)
+
+            if not state["done"].wait(timeout=self._mqtt.request_timeout_seconds):
+                raise MqttCaptureError("等待机械臂拍照流程结束超时。")
+
+            if state["error"]:
+                raise MqttCaptureError(str(state["error"]))
+
+            if state["status"] != 4:
+                raise MqttCaptureError(f"流程未正常结束,最终 status={state['status']}")
+
+            if not state["front_saved"] or not front_path.exists():
+                raise MqttCaptureError("没有拿到 1.jpg")
+
+            if not state["back_saved"] or not back_path.exists():
+                raise MqttCaptureError("没有拿到 2.jpg")
+
+            return {
+                "request_id": request_id,
+                "status": state["status"],
+                "front_path": front_path,
+                "back_path": back_path,
+            }
+        finally:
+            try:
+                client.disconnect()
+            except Exception:
+                logger.warning("断开 MQTT 连接时发生异常。", exc_info=True)
+
+            try:
+                client.loop_stop()
+            except Exception:
+                logger.warning("停止 MQTT 循环时发生异常。", exc_info=True)
+
+            _capture_lock.release()
+
+    def _get_image_path(self, image_id: str, front_path: Path, back_path: Path) -> Optional[Path]:
+        if image_id == "1":
+            return front_path
+        if image_id == "2":
+            return back_path
+        return None
+
+    def _send_camera_response(
+        self,
+        client: mqtt.Client,
+        success: int,
+        error_message: str,
+    ) -> None:
+        payload = {
+            "success": success,
+            "error_message": error_message,
+            "timestamp": int(time.time() * 1000),
+        }
+        client.publish(self._mqtt.camera_response_topic, json.dumps(payload), qos=1)
+        logger.info("已发送拍照回执: %s", payload)
+
+
+def get_mqtt_capture_service() -> MqttCaptureService:
+    return MqttCaptureService()

+ 1 - 0
static/.gitkeep

@@ -0,0 +1 @@
+

+ 1 - 0
static/captures/.gitkeep

@@ -0,0 +1 @@
+

+ 229 - 0
static/index.html

@@ -0,0 +1,229 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>树莓派双面拍照</title>
+    <style>
+        :root {
+            --bg: #f3efe6;
+            --panel: #fffdf8;
+            --line: #d7c8b6;
+            --text: #2c241c;
+            --muted: #7a6c5d;
+            --accent: #a44c21;
+            --accent-dark: #7d3818;
+        }
+
+        * {
+            box-sizing: border-box;
+        }
+
+        body {
+            margin: 0;
+            font-family: "Microsoft YaHei", sans-serif;
+            color: var(--text);
+            background:
+                radial-gradient(circle at top left, #f8d7b8 0, transparent 28%),
+                radial-gradient(circle at bottom right, #ead7c5 0, transparent 24%),
+                linear-gradient(135deg, #f5f0e7, #ebe2d5);
+            min-height: 100vh;
+        }
+
+        .page {
+            max-width: 1100px;
+            margin: 0 auto;
+            padding: 40px 20px 60px;
+        }
+
+        .header {
+            margin-bottom: 24px;
+        }
+
+        .header h1 {
+            margin: 0 0 10px;
+            font-size: 34px;
+        }
+
+        .header p {
+            margin: 0;
+            color: var(--muted);
+            line-height: 1.7;
+        }
+
+        .toolbar {
+            display: flex;
+            gap: 12px;
+            align-items: center;
+            flex-wrap: wrap;
+            margin: 28px 0 20px;
+        }
+
+        button {
+            border: 0;
+            border-radius: 12px;
+            padding: 14px 22px;
+            font-size: 16px;
+            cursor: pointer;
+            color: #fff;
+            background: linear-gradient(135deg, var(--accent), var(--accent-dark));
+            box-shadow: 0 10px 24px rgba(164, 76, 33, 0.2);
+        }
+
+        button:disabled {
+            opacity: 0.65;
+            cursor: not-allowed;
+        }
+
+        .status {
+            min-height: 24px;
+            color: var(--muted);
+        }
+
+        .result {
+            display: grid;
+            grid-template-columns: repeat(2, minmax(0, 1fr));
+            gap: 20px;
+            margin-top: 20px;
+        }
+
+        .card {
+            background: var(--panel);
+            border: 1px solid var(--line);
+            border-radius: 18px;
+            padding: 18px;
+            box-shadow: 0 16px 40px rgba(90, 62, 36, 0.08);
+        }
+
+        .card h2 {
+            margin: 0 0 12px;
+            font-size: 20px;
+        }
+
+        .image-box {
+            aspect-ratio: 1 / 1;
+            border-radius: 14px;
+            background: #efe5d8;
+            border: 1px dashed #c7b39b;
+            display: flex;
+            align-items: center;
+            justify-content: center;
+            overflow: hidden;
+        }
+
+        .image-box img {
+            width: 100%;
+            height: 100%;
+            object-fit: contain;
+            display: none;
+            background: #fff;
+        }
+
+        .placeholder {
+            color: var(--muted);
+            text-align: center;
+            padding: 20px;
+            line-height: 1.6;
+        }
+
+        .link {
+            margin-top: 12px;
+            word-break: break-all;
+            font-size: 14px;
+        }
+
+        .link a {
+            color: var(--accent-dark);
+        }
+
+        @media (max-width: 800px) {
+            .result {
+                grid-template-columns: 1fr;
+            }
+
+            .header h1 {
+                font-size: 28px;
+            }
+        }
+    </style>
+</head>
+<body>
+<div class="page">
+    <div class="header">
+        <h1>树莓派正反面拍照</h1>
+        <p>点击按钮后,页面会请求 <code>/capture-pair</code>,等待机械臂流程完成,再展示返回的两张图片。</p>
+    </div>
+
+    <div class="toolbar">
+        <button id="captureBtn">开始拍照</button>
+        <div class="status" id="statusText">等待操作</div>
+    </div>
+
+    <div class="result">
+        <div class="card">
+            <h2>图片 1 / front</h2>
+            <div class="image-box">
+                <img id="frontImage" alt="front">
+                <div class="placeholder" id="frontPlaceholder">还没有图片</div>
+            </div>
+            <div class="link"><a id="frontLink" href="#" target="_blank"></a></div>
+        </div>
+
+        <div class="card">
+            <h2>图片 2 / back</h2>
+            <div class="image-box">
+                <img id="backImage" alt="back">
+                <div class="placeholder" id="backPlaceholder">还没有图片</div>
+            </div>
+            <div class="link"><a id="backLink" href="#" target="_blank"></a></div>
+        </div>
+    </div>
+</div>
+
+<script>
+    const captureBtn = document.getElementById("captureBtn");
+    const statusText = document.getElementById("statusText");
+    const frontImage = document.getElementById("frontImage");
+    const backImage = document.getElementById("backImage");
+    const frontLink = document.getElementById("frontLink");
+    const backLink = document.getElementById("backLink");
+    const frontPlaceholder = document.getElementById("frontPlaceholder");
+    const backPlaceholder = document.getElementById("backPlaceholder");
+
+    function setStatus(text) {
+        statusText.textContent = text;
+    }
+
+    function showImage(img, link, placeholder, url) {
+        const finalUrl = `${url}${url.includes("?") ? "&" : "?"}t=${Date.now()}`;
+        img.src = finalUrl;
+        img.style.display = "block";
+        placeholder.style.display = "none";
+        link.href = url;
+        link.textContent = url;
+    }
+
+    captureBtn.addEventListener("click", async () => {
+        captureBtn.disabled = true;
+        setStatus("正在请求拍照,请等待机械臂流程完成...");
+
+        try {
+            const response = await fetch("/capture-pair", { method: "POST" });
+            const data = await response.json();
+
+            if (!response.ok) {
+                throw new Error(data.detail || "请求失败");
+            }
+
+            showImage(frontImage, frontLink, frontPlaceholder, data.front_url);
+            showImage(backImage, backLink, backPlaceholder, data.back_url);
+            setStatus(`拍照完成,status=${data.status},request_id=${data.request_id}`);
+        } catch (error) {
+            setStatus(`拍照失败:${error.message}`);
+        } finally {
+            captureBtn.disabled = false;
+        }
+    });
+</script>
+</body>
+</html>