-
Notifications
You must be signed in to change notification settings - Fork 0
MQTT_SCHEMA
모든 토픽의 payload 스키마·발행자·구독자·QoS(Quality of Service, 서비스 품질 등급)를 정의하는 단일 진실 공급원. TypeScript/Python 양쪽 코드 생성의 소스(
packages/topics/)와 일관성을 유지해야 함.
모든 자체 도메인 메시지는 CloudEvents 1.0 호환 envelope으로 감싼다.
(외부 시스템인 Frigate frigate/events는 envelope 없이 자체 포맷을 사용.)
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
}{
"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 부착.
| 카테고리 | 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에 한정 — 비용이 크다.
- 발행: Frigate
-
구독:
bridge - 포맷: Frigate 자체 (CloudEvents 미적용)
- 참조: https://docs.frigate.video/integrations/mqtt#frigateevents
{
"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"]
}
}- 단순 정수/
ON|OFF문자열. HA가 자동 entity로 흡수.
-
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"
}-
type:
vision.zone.exit - 위와 동일 +
duration_s.
interface VisionZoneExit extends VisionZoneEnter {
duration_s: number; // 진입~이탈 경과
}-
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;
}-
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;
}-
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 참조.
-
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;
}-
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 publishrobot/+/command→mcp-robot-adapterMQTT 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"
}-
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 발행 가능: accepted → started → completed.
-
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: falsepayload를 retain false로 발행하여 상태 갱신.
-
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"
}-
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;
}-
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;
}-
type:
tablet.ack -
발행:
mcp-tablet-gateway| 구독:orchestrator
interface TabletAck {
tablet_id: string;
message_id: string;
status: "displayed" | "dismissed" | "failed";
reason: string | null;
timestamp: string;
}-
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 참조-
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로 보내지 말고 생략. 구독자는 머지 처리.
-
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 해제 상태로 인지.
-
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;
}-
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.
| 토픽 | 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) = 보조
robot/+/state(retain=true)·safety/estop(retain=true)는 발행자가 비정상 종료할 경우 stale 메시지가 영구 잔존하는 문제가 있다. 모든 retain-true 발행자는 mosquitto 연결 시 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은 별도 회로로 보호.
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)정상 종료 시에도 동일 payload를 명시적으로 publish 후 disconnect → broker가 LWT 발동 안 함.
def shutdown_handler(*_):
client.publish("robot/spot-01/state", will_payload, qos=1, retain=True)
client.disconnect()운영 중 의도치 않은 retain 메시지가 남았을 때:
docker exec mosquitto mosquitto_pub -h localhost -t "robot/spot-01/state" -r -n(-r -n: retain + null payload → broker가 retain 메시지 삭제)
-
멱등성: 동일
id(envelope) 재수신 시 구독자는 재처리 금지.orchestrator는 in-memory LRU(Least Recently Used, 최근 미사용 제거 캐시 정책)(1024 entries) 사용 권장. -
순서: MQTT 자체는 토픽-클라이언트 단위 순서만 보장. 여러 토픽 간 순서가 의미 있을 때(예:
vision/zone/enter→tablet/+/intent)는traceparent와time으로 사후 정렬. - clock skew: 모든 컨테이너 NTP 동기화 필수.
서비스가 구독할 때 다음 권장:
# 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)- Backward 호환 원칙: 새 필드는 optional로 추가. 기존 필드 삭제·타입 변경 금지.
-
Breaking change 시 type 버전화:
vision.zone.enter.v2처럼type마지막에.v2. v1·v2 병행 발행 → 모든 구독자 마이그레이션 후 v1 폐기. - 스키마 정의는
packages/topics/에 단일 소스로 두고 codegen.
-
모든 토픽 라이브 테일:
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 처리 가능.