Skip to content

MQTT_SCHEMA

lazybrain80 edited this page May 23, 2026 · 2 revisions

MQTT 메시지 스키마 카탈로그

모든 토픽의 payload 스키마·발행자·구독자·QoS(Quality of Service, 서비스 품질 등급)를 정의하는 단일 진실 공급원. TypeScript/Python 양쪽 코드 생성의 소스(packages/topics/)와 일관성을 유지해야 함.


1. 공통 envelope

모든 자체 도메인 메시지는 CloudEvents 1.0 호환 envelope으로 감싼다. (외부 시스템인 Frigate frigate/events는 envelope 없이 자체 포맷을 사용.)

TypeScript 인터페이스

interface CloudEvent<T> {
  specversion: "1.0";
  type: string;                  // "vision.zone.enter" 형태 dot-notation
  source: string;                // "/services/bridge", "/services/orchestrator", ...
  id: string;                    // ULID, 멱등성 dedup 용
  time: string;                  // RFC3339 (ISO8601), UTC
  datacontenttype: "application/json";
  subject?: string;              // "session/01HXYZ", "robot/spot-01" 등
  traceparent?: string;          // W3C TraceContext (OpenTelemetry 전파)
  data: T;                       // 메시지별 payload
}

JSON 예시

{
  "specversion": "1.0",
  "type": "vision.zone.enter",
  "source": "/services/bridge",
  "id": "01HW9XJ1A2B3C4D5E6F7G8H9JK",
  "time": "2026-05-04T10:00:00.123Z",
  "datacontenttype": "application/json",
  "subject": "session/01HW9X...",
  "traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
  "data": { "session_id": "01HW9X...", "zone_id": "cafe", "confidence": 0.97 }
}

규칙

  • id는 발행자가 ULID(Universally Unique Lexicographically Sortable Identifier, 범용 고유 정렬 가능 식별자)로 생성. 동일 id 재수신 시 구독자는 멱등 처리.
  • time은 발생 시각, now()가 아닌 이벤트 원천 시각(예: 프레임 캡처 시각).
  • traceparent는 가능한 경우 항상 포함 — 트레이싱이 한 줄로 이어진다.
  • subject는 grep·필터링 보조 — 강제 아님.

예시 표기 관례 (중요)

§3 토픽 카탈로그의 JSON 예시는 가독성을 위해 data 부분만 표기한다. 실제 MQTT(Message Queuing Telemetry Transport, 메시지 큐 텔레메트리 전송 프로토콜) publish 시에는 위 envelope으로 감싸야 한다.

대표 예시 — vision/zone/enter의 실제 publish payload:

{
  "specversion": "1.0",
  "type": "vision.zone.enter",
  "source": "/services/bridge",
  "id": "01HW9XJ1A2B3C4D5E6F7G8H9JK",
  "time": "2026-05-04T10:00:00.123Z",
  "datacontenttype": "application/json",
  "subject": "session/01HW9X...",
  "traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
  "data": {
    "session_id": "01HW9X...",
    "zone_id": "cafe",
    "camera_id": "cafe_cam_01",
    "confidence": 0.97,
    "position": { "x": 2.4, "y": 1.1 },
    "timestamp": "2026-05-04T10:00:00.123Z"
  }
}

라이브러리에서는 envelope 생성·파싱을 한 곳에 캡슐화 권장:

# packages/topics/envelope.py (Python)
def wrap(type_: str, data: dict, source: str, subject: str | None = None) -> dict:
    return {
        "specversion": "1.0",
        "type": type_,
        "source": source,
        "id": ulid.new().str,
        "time": datetime.now(timezone.utc).isoformat(),
        "datacontenttype": "application/json",
        **({"subject": subject} if subject else {}),
        **({"traceparent": current_traceparent()} if current_traceparent() else {}),
        "data": data,
    }

예외: frigate/+(외부 시스템), frigate/{cam}/{label}·{zone}(단순 카운트/ON·OFF) — Frigate가 자체 포맷으로 발행하므로 envelope 미적용. bridge가 변환 시 envelope 부착.


2. QoS · retain 가이드

카테고리 QoS retain 이유
vision/zone/+ 1 false 누락 시 인터랙션 미발동 → 최소 1회 보장
vision/position/update 0 false 5Hz 고빈도, 누락 허용
frigate/+ (외부) Frigate 기본 변경하지 않음
session/+ 1 false 세션 라이프사이클은 누락 금지
robot/+/state 0 true 신규 구독자가 즉시 최신 상태 획득
robot/+/command 1 false 안전 명령은 보장 + 멱등
robot/+/ack 1 false 명령 결과 추적
robot/+/fault 1 true 알림 누락 금지 + 신규 구독자도 인지
tablet/+/intent 1 false 사용자 입력 누락 금지
tablet/+/show_options 1 false UI 상태
tablet/+/show_message 1 false 동일
tablet/+/ack 1 false 표시 확인
context/user/+/update 1 true 마지막 사용자 컨텍스트가 진실 (4-segment)
context/environmental/update 1 true 마지막 환경 컨텍스트가 진실 (3-segment)
safety/estop 2 true 절대 누락 금지 + 신규 구독자도 인지
safety/proximity 1 false 알림성
system/heartbeat/+ 0 true 라이브니스

Mosquitto는 QoS 2를 지원하지만 사용은 safety/estop에 한정 — 비용이 크다.


3. 토픽 카탈로그

3.1 외부 시스템 (Frigate)

frigate/events

{
  "type": "new",
  "before": null,
  "after": {
    "id": "1701234567.123456-abc",
    "camera": "cafe_cam_01",
    "frame_time": 1701234567.5,
    "label": "person",
    "score": 0.92,
    "box": [120, 80, 480, 720],
    "current_zones": ["cafe_zone"],
    "entered_zones": ["cafe_zone"]
  }
}

frigate/{camera}/{label} 카운트, frigate/{camera}/{zone} 점유

  • 단순 정수/ON|OFF 문자열. HA가 자동 entity로 흡수.

3.2 vision.* — 비전 도메인 (bridge가 변환·발행)

vision/zone/enter

  • type: vision.zone.enter
  • 발행: bridge | 구독: orchestrator, home-assistant
  • QoS: 1 / retain: false
interface VisionZoneEnter {
  session_id: string;          // ULID — bridge가 ReID로 매칭
  zone_id: ZoneId;             // 'cafe' | 'park' | 'vehicle' | 'home'
  camera_id: string;           // 'cafe_cam_01'
  confidence: number;          // 0~1
  position: { x: number; y: number } | null;  // 평면 좌표 (homography 변환)
  timestamp: string;           // 프레임 시각
}
{
  "session_id": "01HW9X...",
  "zone_id": "cafe",
  "camera_id": "cafe_cam_01",
  "confidence": 0.97,
  "position": { "x": 2.4, "y": 1.1 },
  "timestamp": "2026-05-04T10:00:00.123Z"
}

vision/zone/exit

  • type: vision.zone.exit
  • 위와 동일 + duration_s.
interface VisionZoneExit extends VisionZoneEnter {
  duration_s: number;          // 진입~이탈 경과
}

vision/position/update

  • type: vision.position.update
  • QoS: 0 / retain: false / 빈도: 카메라당 ~5Hz
interface VisionPositionUpdate {
  session_id: string;
  zone_id: ZoneId;
  position: { x: number; y: number };
  velocity?: { vx: number; vy: number };
  timestamp: string;
}

3.3 session.* — 세션 라이프사이클

session/start

  • type: session.start
  • 발행: bridge (페어링 완료 시 OSNet 임베딩 바인딩 후) | 구독: orchestrator, home-assistant
  • QoS: 1
interface SessionStart {
  session_id: string;          // 신규 ULID
  paired_tablet_id: string;
  visual_embedding_ref: string;  // bridge가 ReID에 사용
  initial_zone: ZoneId | null;
  started_at: string;
}

session/end

  • type: session.end
  • 발행: 운영자(HA) 또는 timeout(orchestrator)
interface SessionEnd {
  session_id: string;
  reason: "user_exit" | "timeout" | "operator_terminate" | "safety";
  duration_s: number;
  zones_visited: ZoneId[];
  ended_at: string;
}

session/end는 SCRUB 트리거를 겸함 — 별도 SCRUB 토픽을 두지 않는다. 모든 구독자는 수신 즉시 해당 session_id의 모든 in-memory 데이터(임베딩·캐시·페어링 토큰)를 폐기해야 함. 자세한 책임 분담은 SYSTEM_DESIGN.md §11.3 참조.


3.4 robot.* — 로봇 도메인

robot/{robot_id}/state

  • type: robot.state
  • 발행: mcp-robot-adapter (1Hz) | 구독: 모두
  • QoS: 0 / retain: true
type RobotId = string;   // 런타임에 config/robots.yaml 레지스트리로 검증 (SYSTEM_DESIGN.md §7.2)
type Connection = "online" | "degraded" | "offline";

interface RobotState {
  robot_id: RobotId;
  pose: {
    x: number;                 // meters, 전시장 평면 좌표
    y: number;
    yaw: number;               // radians
    zone_id: ZoneId | null;    // pose가 어느 zone polygon 내부인지
  };
  battery_pct: number;         // 0~100
  current_action: {
    type: string;              // 'goto' | 'follow' | ...
    started_at: string;
    eta_s: number | null;
    command_id: string;        // 트리거한 command의 ID
  } | null;
  faults: Fault[];             // 활성 fault 스냅샷
  connection: Connection;
  timestamp: string;           // 텔레메트리 캡처 시각
}

interface Fault {
  code: string;
  severity: "info" | "warning" | "critical";
  message: string;
  raised_at: string;
}

robot/{robot_id}/command

  • type: robot.command
  • 발행: home-assistant(LLM(Large Language Model, 대규모 언어 모델) 폴백 경로 + 운영자 콘솔 수동 명령) | 구독: mcp-robot-adapter
  • QoS: 1

명령 경로 정책 (중요):

  • 주 경로 (LLM): orchestrator → MCP(Model Context Protocol, 모델 컨텍스트 프로토콜) tool call → mcp-robot-adapter 내부 entry → 큐 → SDK(Software Development Kit, 소프트웨어 개발 키트). MQTT를 거치지 않음.
  • 폴백 경로 (HA): home-assistant 자동화 → MQTT publish robot/+/commandmcp-robot-adapter MQTT subscriber → 같은 큐 → SDK.
  • 두 entry point는 동일한 priority queue + safety interlock으로 수렴 — 결과·텔레메트리는 동일 토픽으로 발행.
  • orchestrator는 이 토픽을 발행하지 않는다 (ACL(Access Control List, 접근 제어 목록)에서 write 권한도 부여하지 않음).
type Capability = "goto" | "follow" | "stop" | "sit" | "stand" | "gesture" | "look_at";
type Priority = "safety" | "operator" | "llm";

interface RobotCommand {
  command_id: string;          // ULID
  robot_id: RobotId;
  capability: Capability;
  params: CapabilityParams;    // capability별 다름 (아래)
  priority: Priority;
  source: {
    service: "home-assistant" | "operator-console";   // MQTT 경로는 폴백 전용
    trace_id: string;
  };
  expires_at: string | null;   // 이 시점 이후 큐에 남았으면 폐기
}

// capability별 params
interface GotoParams   { x: number; y: number; yaw?: number; velocity_max?: number; }
interface FollowParams { target_session_id: string; max_distance_m: number; }
interface StopParams   {} // empty
interface SitParams    {}
interface StandParams  {}
interface GestureParams { name: "wave" | "nod" | "shake"; }
interface LookAtParams { target_session_id?: string; target_pos?: { x: number; y: number }; }

type CapabilityParams =
  | GotoParams | FollowParams | StopParams | SitParams
  | StandParams | GestureParams | LookAtParams;
// 예시 — HA 폴백 경로(운영자 자동화)가 발행하는 명령
{
  "command_id": "01HW9X...",
  "robot_id": "spot-01",
  "capability": "look_at",
  "params": { "target_session_id": "01HW8M..." },
  "priority": "operator",
  "source": { "service": "home-assistant", "trace_id": "fallback-1714816800" },
  "expires_at": "2026-05-04T10:00:05Z"
}

robot/{robot_id}/ack

  • type: robot.ack
  • 발행: mcp-robot-adapter | 구독: orchestrator, home-assistant
  • QoS: 1
type AckStatus = "accepted" | "rejected" | "started" | "completed" | "failed";

interface RobotAck {
  command_id: string;
  robot_id: RobotId;
  status: AckStatus;
  reason: string | null;       // rejected/failed 시 원인
  result: Record<string, unknown> | null;  // completed 시 결과
  timestamp: string;
}

같은 command_id로 시간 순 여러 ack 발행 가능: acceptedstartedcompleted.

robot/{robot_id}/fault

  • type: robot.fault
  • 발행: mcp-robot-adapter | 구독: home-assistant(알림), orchestrator(블랙리스트)
  • QoS: 1 / retain: true (활성 상태)
interface RobotFaultEvent {
  robot_id: RobotId;
  fault: Fault;
  active: boolean;             // false = cleared
}

Cleared 시 동일 토픽에 active: false payload를 retain false로 발행하여 상태 갱신.


3.5 tablet.* — 태블릿 도메인

tablet/{tablet_id}/intent

  • type: tablet.intent
  • 발행: mcp-tablet-gateway | 구독: orchestrator
  • QoS: 1
type IntentType = "option_select" | "free_text" | "mood_input" | "preference_update";

interface TabletIntent {
  tablet_id: string;
  session_id: string;
  intent_type: IntentType;
  payload: IntentPayload;
  timestamp: string;
}

interface OptionSelectPayload   { option_id: string; from_message_id: string; }
interface FreeTextPayload       { text: string; locale?: string; }
interface MoodInputPayload      { mood: "calm" | "excited" | "tired" | "neutral"; }
interface PreferenceUpdatePayload {
  patch: { music_genre?: string; drink?: string; };
}

type IntentPayload =
  | OptionSelectPayload | FreeTextPayload
  | MoodInputPayload | PreferenceUpdatePayload;
{
  "tablet_id": "t01",
  "session_id": "01HW9X...",
  "intent_type": "option_select",
  "payload": { "option_id": "drink_recommend", "from_message_id": "01HW9Y..." },
  "timestamp": "2026-05-04T10:01:00Z"
}

tablet/{tablet_id}/show_options

  • type: tablet.show_options
  • 발행: orchestrator | 구독: mcp-tablet-gateway
  • QoS: 1
interface TabletShowOptions {
  tablet_id: string;
  message_id: string;          // ULID, 후속 intent의 from_message_id
  options: Option[];
  ttl_s: number | null;        // 만료 시 자동 숨김
  timestamp: string;
}

interface Option {
  id: string;                  // 'drink_recommend' 등
  label: string;               // 사용자 표시
  icon: string | null;         // material/feather icon name
  description: string | null;
}

tablet/{tablet_id}/show_message

  • type: tablet.show_message
  • 발행/구독/QoS 동일
interface TabletShowMessage {
  tablet_id: string;
  message_id: string;
  content: {
    text: string;
    media: Media[];
    speaker: string | null;      // robot_id 값 또는 "system" (런타임 레지스트리 검증)
  };
  duration_s: number | null;   // 자동 사라짐
  timestamp: string;
}

interface Media {
  type: "image" | "video" | "audio";
  url: string;
}

tablet/{tablet_id}/ack

  • type: tablet.ack
  • 발행: mcp-tablet-gateway | 구독: orchestrator
interface TabletAck {
  tablet_id: string;
  message_id: string;
  status: "displayed" | "dismissed" | "failed";
  reason: string | null;
  timestamp: string;
}

3.6 context.* — 사용자/공간/환경 컨텍스트 변경

context/user/{session_id}/update

  • type: context.user.update
  • 발행: orchestrator, home-assistant | 구독: orchestrator(자기 자신 포함, dedup)
  • QoS: 1 / retain: true (마지막 컨텍스트가 진실)
interface ContextUserUpdate {
  session_id: string;
  patch: Partial<UserContext>;  // 변경된 필드만
  source: "tablet" | "vision" | "system" | "operator";
  timestamp: string;
}

// UserContext 본체는 SYSTEM_DESIGN.md §4.1 참조

context/environmental/update

  • type: context.environmental.update
  • 발행: home-assistant (날씨 통합 등) | 구독: orchestrator
  • QoS: 1 / retain: true
interface EnvironmentalUpdate {
  weather: { temp_c: number; condition: string } | null;
  time_of_day: "morning" | "afternoon" | "evening" | "night" | null;
  visitor_count: number | null;     // 전시장 누적 입장자
  timestamp: string;
}

부분 업데이트(예: 날씨만) 시에도 변경 안 된 필드는 null로 보내지 말고 생략. 구독자는 머지 처리.


3.7 safety.* — 안전 이벤트

safety/estop

  • type: safety.estop
  • 발행: HW e-stop(emergency stop, 긴급 정지) 통합, mcp-robot-adapter, home-assistant(운영자) | 구독: 모두
  • QoS: 2 / retain: true (활성 시)
interface SafetyEstop {
  source: "hardware_button" | "operator_console" | "auto_proximity" | "auto_fault";
  active: boolean;             // true=engage, false=reset
  reason: string;
  timestamp: string;
}

active: false 발행 시 retain 메시지 정리되어 다음 구독자는 e-stop 해제 상태로 인지.

safety/proximity

  • type: safety.proximity
  • 발행: bridge (vision 기반 거리 계산) 또는 mcp-robot-adapter (lidar)
  • QoS: 1
interface SafetyProximity {
  robot_id: RobotId;
  session_id: string | null;
  distance_m: number;
  threshold_m: number;
  violated: boolean;
  timestamp: string;
}

3.8 system.* — 운영 헬스

system/heartbeat/{service}

  • type: system.heartbeat
  • 발행: 각 서비스 (10초 주기) | 구독: home-assistant (라이브니스 알림)
  • QoS: 0 / retain: true
interface SystemHeartbeat {
  service: string;             // 'orchestrator' | 'bridge' | ...
  version: string;             // 이미지 태그
  uptime_s: number;
  status: "healthy" | "degraded";
  details: Record<string, unknown>;
  timestamp: string;
}

HA에서 last_updated > 30s 되면 alert.


4. 토픽 ↔ 발행자/구독자 매트릭스

토픽 bridge orch robot-adp tablet-gw HA frigate
frigate/events S (S) P
vision/zone/+ P S S
vision/position/update P S
session/start P S S
session/end S (P)/S S P/S
robot/+/state S P S
robot/+/command S P
robot/+/ack S P S
robot/+/fault S P S
tablet/+/intent S P
tablet/+/show_options P S
tablet/+/show_message P S
tablet/+/ack S P
context/user/+/update (P) P/S (P)
context/environmental/update S P
safety/estop S S P/S S P/S
safety/proximity P S (P) S
system/heartbeat/+ P P P P S

P = 주 발행자, S = 주 구독자, (P/S) = 보조


4.5 Last Will Testament (LWT) — retain 메시지 정리

robot/+/state(retain=true)·safety/estop(retain=true)는 발행자가 비정상 종료할 경우 stale 메시지가 영구 잔존하는 문제가 있다. 모든 retain-true 발행자는 mosquitto 연결 시 LWT를 등록한다.

발행자별 LWT 정책

서비스 토픽 LWT payload retain
mcp-robot-adapter robot/{robot_id}/state { "robot_id":"spot-01", "connection":"offline", "timestamp":"..." } (envelope 포함) true
home-assistant safety/estop { "active": false, "source": "auto_disconnect", "timestamp":"..." } (해제 의미) true
모든 자체 서비스 system/heartbeat/{service} { "service":"...", "status":"degraded", ... } true

safety/estop의 LWT는 active: false로 둔다 — broker 연결 끊긴 HA가 e-stop을 active로 잠재울 수 없게(false-positive). HW e-stop은 별도 회로로 보호.

Python 예시 (mcp-robot-adapter)

import paho.mqtt.client as mqtt

client = mqtt.Client(client_id="mcp-robot-adapter")

# LWT 등록 (connect 전)
will_payload = json.dumps({
    "specversion": "1.0",
    "type": "robot.state",
    "source": "/services/mcp-robot-adapter",
    "time": datetime.utcnow().isoformat() + "Z",
    "data": {
        "robot_id": "spot-01",
        "connection": "offline",
        "timestamp": datetime.utcnow().isoformat() + "Z",
    },
})
client.will_set(
    topic="robot/spot-01/state",
    payload=will_payload,
    qos=1,
    retain=True,
)
client.connect("mosquitto", 1883)

Graceful shutdown

정상 종료 시에도 동일 payload를 명시적으로 publish 후 disconnect → broker가 LWT 발동 안 함.

def shutdown_handler(*_):
    client.publish("robot/spot-01/state", will_payload, qos=1, retain=True)
    client.disconnect()

Retain 메시지 수동 정리

운영 중 의도치 않은 retain 메시지가 남았을 때:

docker exec mosquitto mosquitto_pub -h localhost -t "robot/spot-01/state" -r -n

(-r -n: retain + null payload → broker가 retain 메시지 삭제)


5. 멱등성과 순서

  • 멱등성: 동일 id(envelope) 재수신 시 구독자는 재처리 금지. orchestrator는 in-memory LRU(Least Recently Used, 최근 미사용 제거 캐시 정책)(1024 entries) 사용 권장.
  • 순서: MQTT 자체는 토픽-클라이언트 단위 순서만 보장. 여러 토픽 간 순서가 의미 있을 때(예: vision/zone/entertablet/+/intent)는 traceparenttime으로 사후 정렬.
  • clock skew: 모든 컨테이너 NTP 동기화 필수.

6. 토픽 와일드카드 사용

서비스가 구독할 때 다음 권장:

# bridge
mqtt.subscribe("frigate/events", qos=1)
mqtt.subscribe("session/end",    qos=1)  # SCRUB: 임베딩 참조 해제 (bridge는 session/start 발행자)

# orchestrator
mqtt.subscribe("vision/zone/+",               qos=1)
mqtt.subscribe("tablet/+/intent",             qos=1)
mqtt.subscribe("context/+/+/update",          qos=1)  # context/user/{id}/update (4-segment)
mqtt.subscribe("context/environmental/update", qos=1)  # 별도 구독 필요 (3-segment, 위 패턴 미매칭)
mqtt.subscribe("robot/+/ack",                 qos=1)
mqtt.subscribe("safety/+",                    qos=2)

# mcp-robot-adapter
mqtt.subscribe("robot/+/command", qos=1)
mqtt.subscribe("safety/estop",    qos=2)

7. 스키마 진화 정책

  • Backward 호환 원칙: 새 필드는 optional로 추가. 기존 필드 삭제·타입 변경 금지.
  • Breaking change 시 type 버전화: vision.zone.enter.v2 처럼 type 마지막에 .v2. v1·v2 병행 발행 → 모든 구독자 마이그레이션 후 v1 폐기.
  • 스키마 정의는 packages/topics/에 단일 소스로 두고 codegen.

8. 디버깅 팁

  • 모든 토픽 라이브 테일:
    docker exec -it mosquitto mosquitto_sub -h localhost -t '#' -v
  • 특정 trace_id 추적:
    mosquitto_sub -h localhost -t '#' -v | grep '0af7651916cd43dd8448eb211c80319c'
  • Retained 메시지 확인:
    mosquitto_sub -h localhost -t 'robot/+/state' -v --retained-only
  • 부하 테스트: mosquitto_pub을 루프에서 호출, MQTT broker는 단일 노드에서도 수만 msg/s 처리 가능.

Clone this wiki locally