-
Notifications
You must be signed in to change notification settings - Fork 0
SERVICES
각 서비스가 어떤 OSS를 어떻게 활용하고, 어디까지 직접 만들어야 하는지 정리. 메시지 스키마 상세는 MQTT_SCHEMA.md 참조.
| 서비스 | OSS 그대로 | 설정 작성 | 코드 작성 |
|---|---|---|---|
mosquitto |
● | ○ | — |
frigate |
● | ● | — |
cctv-relay (go2rtc) |
● | ● | — |
home-assistant |
● | ● | — |
bridge |
(라이브러리만) | — | ● |
orchestrator |
(라이브러리만) | ● | ● |
mcp-robot-adapter |
(라이브러리만) | — | ● |
mcp-tablet-gateway |
(라이브러리만) | — | ● |
tablet-app |
(프레임워크만) | — | ● |
- 이미지:
eclipse-mosquitto:2.0 - 라이선스: EPL/EDL
- 그대로 사용. 설정 파일만.
listener 1883
allow_anonymous false
password_file /mosquitto/config/passwd
acl_file /mosquitto/config/acl
persistence true
persistence_location /mosquitto/data/
log_dest stdout
# 메시지 보존 정책 (전시 운영 중 안전)
max_inflight_messages 100
max_queued_messages 1000원칙: 최소 권한. 각 서비스는 자기 책임 토픽만 write, 필요한 토픽만 read. 모든 자체 서비스는 system/heartbeat/<자기이름> 발행 권한을 가진다.
# ──────────────────────────────────────────
# frigate: 자체 토픽만 발행
# ──────────────────────────────────────────
user frigate
topic write frigate/#
# ──────────────────────────────────────────
# bridge: frigate/# 구독, vision/# + session/+ + safety/proximity 발행
# ──────────────────────────────────────────
user bridge
topic read frigate/#
topic write vision/zone/enter
topic write vision/zone/exit
topic write vision/position/update
topic write session/start
topic write safety/proximity
topic read session/end # 임베딩 폐기 트리거 수신 (SCRUB)
topic write system/heartbeat/bridge
# ──────────────────────────────────────────
# orchestrator: 명령은 MCP로 전달 (MQTT 발행 X)
# ──────────────────────────────────────────
user orchestrator
topic read vision/zone/enter
topic read vision/zone/exit
topic read vision/position/update
topic read robot/+/state
topic read robot/+/ack
topic read robot/+/fault
topic read tablet/+/intent
topic read tablet/+/ack
topic read context/+/+/update
topic read context/environmental/update
topic read safety/+
topic read session/start
topic read session/end
topic write tablet/+/show_options
topic write tablet/+/show_message
topic write context/user/+/update
topic write session/end # timeout 트리거
topic write system/heartbeat/orchestrator
# ──────────────────────────────────────────
# mcp-robot-adapter: 로봇 전용
# ──────────────────────────────────────────
user robot-adapter
topic write robot/+/state
topic write robot/+/ack
topic write robot/+/fault
topic read robot/+/command # HA 폴백 경로 수신
topic read safety/estop
topic read safety/proximity
topic write safety/estop # 자체 감지 시 발행
topic read vision/position/update # proximity guard
topic write system/heartbeat/robot-adapter
# ──────────────────────────────────────────
# mcp-tablet-gateway: 태블릿 전용
# ──────────────────────────────────────────
user tablet-gateway
topic write tablet/+/intent
topic write tablet/+/ack
topic read tablet/+/show_options
topic read tablet/+/show_message
topic read session/end # SCRUB 트리거 수신 (토큰 무효화·WSS 종료)
topic write system/heartbeat/tablet-gateway
# ──────────────────────────────────────────
# home-assistant: 라이브 모니터링·폴백 자동화·운영자 명령
# ──────────────────────────────────────────
user homeassistant
topic read vision/#
topic read robot/+/state
topic read robot/+/ack
topic read robot/+/fault
topic read tablet/+/intent
topic read tablet/+/ack
topic read context/+/+/update
topic read safety/#
topic read session/+
topic read system/heartbeat/+
topic write context/environmental/update
topic write safety/estop # 운영자 e-stop 콘솔
topic write robot/+/command # LLM 폴백 경로
topic write tablet/+/show_options # 폴백 시 옵션 노출
topic write tablet/+/show_message
topic write session/end # 운영자 종료 트리거
topic write system/heartbeat/homeassistant
비밀번호 파일 권한:
passwd파일은chmod 600, owner는 mosquitto 컨테이너 uid(1883). 호스트 디렉터리(./mosquitto/config)도 동일하게 제한.
- 비밀번호는
mosquitto_passwd -c passwd <user>로 생성,.env.secret에 보관 X (파일 자체가 비밀). - TLS(Transport Layer Security, 전송 계층 보안)는 단일 사이트 LAN 내부에선 필수 아님. 외부 노출 시 8883 + cert.
- 이미지:
ghcr.io/blakeblackshear/frigate:0.14.1 - 라이선스: MIT
-
그대로 사용 +
config.yml+ zone polygon + 카메라 추가. - Cross-camera ReID(Re-identification, 보행자 재식별)는 부족 →
bridge서비스에서 보강.
원칙: config.yml은 git에 커밋 가능해야 하므로 자격증명을 평문으로 두지 않는다. Frigate 0.13+ 는 {ENV_VAR} 형태로 환경변수 치환을 지원하며, 컨테이너 환경변수(env_file)로 주입한다.
mqtt:
host: mosquitto
port: 1883
user: frigate
password: '{FRIGATE_MQTT_PASSWORD}' # 환경변수 치환 (Frigate 0.13+)
topic_prefix: frigate
detectors:
coral:
type: edgetpu
device: usb
# 또는 NVIDIA: type: tensorrt
cameras:
cafe_cam_01:
ffmpeg:
inputs:
- path: rtsp://{FRIGATE_RTSP_USER}:{FRIGATE_RTSP_PASSWORD}@10.0.1.11:554/stream
roles: [detect, record]
detect:
width: 1280
height: 720
fps: 5
objects:
track: [person]
zones:
cafe_zone:
coordinates: 0,720,0,0,1280,0,1280,720
objects: [person]
record:
enabled: false # 영상 저장 안 함 (개인정보)
snapshots:
enabled: false
park_cam_01: { ... }
vehicle_cam_01: { ... }
home_cam_01: { ... }compose에서 주입:
# deploy/docker-compose.yml (frigate 서비스)
frigate:
image: ghcr.io/blakeblackshear/frigate:0.14.1
env_file: [./.env, ./.env.secret] # FRIGATE_MQTT_PASSWORD, FRIGATE_RTSP_USER, FRIGATE_RTSP_PASSWORD
....env.secret 정의 (gitignore):
FRIGATE_MQTT_PASSWORD=...
FRIGATE_RTSP_USER=admin
FRIGATE_RTSP_PASSWORD=...카메라마다 자격증명이 다르면
FRIGATE_RTSP_PASSWORD_CAFE_01등으로 분리. Frigate는 임의 환경변수명을 모두 치환한다.
브라우저 라이브 영상(WebRTC)은 Frigate가 아니라 **독립 서비스
cctv-relay**가 담당한다 → §2.5. Frigate는 detect/이벤트 전용으로 유지한다.
-
frigate/events— 이벤트 JSON -
frigate/{camera}/{label}— 카운트 -
frigate/{camera}/{zone}— 점유 ON/OFF
- 모델 정확도 향상은 labelmap·threshold 튜닝으로 시작, 커스텀 모델은 후순위.
- GPU/TPU 선택: Jetson은 TensorRT(NVIDIA TensorRT, NVIDIA 딥러닝 추론 최적화 엔진), USB 환경은 Coral, x86 GPU는 NVIDIA TensorRT detector.
전시장 데모 한정. 브라우저는 RTSP를 직접 재생하지 못하므로(raw socket 불가) RTSP를 브라우저 호환 전송(WebRTC)으로 변환·중계하는 독립 서비스다. Frigate(detect/이벤트)와 분리해 라이브 영상만 책임진다. 설계 근거·미디어 평면 분리는 SYSTEM_DESIGN.md §8.4.
go2rtc는 카메라 스트림을 여러 프로토콜로 실시간 변환·중계하는 단일 바이너리 스트리밍 서버다(Go로 작성, MIT). 하나의 RTSP/RTMP/HTTP 소스를 받아 WebRTC·MSE·HLS·RTSP 등 클라이언트가 요구하는 전송 방식으로 재패키징(restream) 해 내보낸다 — 본 프로젝트에서 필요한 "브라우저가 못 받는 RTSP → 브라우저가 받는 WebRTC" 변환이 정확히 핵심 기능이다.
| 특징 | 본 프로젝트에서의 의미 |
|---|---|
| WebRTC + WHEP 내장 | 브라우저가 표준 WHEP(SDP offer/answer)로 바로 연결 → 별도 시그널링 서버 불필요 |
| zero-copy 패스스루 | 소스가 H.264면 트랜스코딩 없이 그대로 전달(저지연·저부하). H.265 등 비호환 코덱만 ffmpeg: producer로 변환 |
| config 구동 |
streams: YAML 맵에 소스 한 줄 추가로 스트림 등록, 첫 consumer 연결 시 lazy 연결 — 자체 코드 불필요 |
| 단일 바이너리(~50MB) | 의존성 없는 작은 컨테이너, Frigate와 분리해 독립 배포 용이 |
| 저장 안 함 | 기본적으로 녹화·스냅샷 없이 라이브 통과만 → 개인정보 정책(§11.3)과 무충돌 |
참고: go2rtc는 Frigate에 내장된 것과 동일한 엔진이다(Frigate가 라이브 보기용으로 go2rtc를 번들). 본 프로젝트는 detect 파이프라인과의 장애 격리를 위해 이를 번들 대신 독립 서비스로 떼어 운용한다(§2.5 Frigate와의 관계).
- 이미지:
ghcr.io/alexxit/go2rtc:1.9.x(버전 핀) - 라이선스: MIT
- 프로젝트: https://github.com/AlexxIT/go2rtc
- 대안: MediaMTX(구 rtsp-simple-server) — WHEP 지원하나 본 프로젝트는 go2rtc 표준 채택.
-
그대로 사용 +
config/cctv-relay.yaml작성. RTSP→WebRTC 변환·ICE·WHEP는 go2rtc가 전담하므로 자체 코드 없음. - 선택: 단일 소스(
config/cameras.yaml)에서 이 설정을 생성하는 빌드 스크립트(아래 "단일 소스" 참조).
config/cctv-relay.yaml (컨테이너에 마운트). streams: 맵에 원본 RTSP 주소를 한 줄 추가하면 그 스트림이 자동 등록·연결된다. 카메라 n대는 항목 n개로 확장된다.
# config/cctv-relay.yaml (go2rtc 설정)
streams:
cafe_cam_01: rtsp://${RTSP_USER}:${RTSP_PASSWORD}@10.0.1.11:554/stream # 원본 소스만 적으면 끝
park_cam_01: rtsp://${RTSP_USER}:${RTSP_PASSWORD}@10.0.1.12:554/stream
vehicle_cam_01: rtsp://${RTSP_USER}:${RTSP_PASSWORD}@10.0.1.13:554/stream
home_cam_01: rtsp://${RTSP_USER}:${RTSP_PASSWORD}@10.0.1.14:554/stream
# H.265 카메라는 브라우저 WebRTC 호환(H.264)으로 트랜스코드:
# lobby_cam_01:
# - rtsp://${RTSP_USER}:${RTSP_PASSWORD}@10.0.1.15:554/stream
# - 'ffmpeg:lobby_cam_01#video=h264#audio=opus'
webrtc:
candidates:
- 10.0.1.10:8555 # 전시장 LAN에서 브라우저가 닿는 호스트 IP:포트 (host candidate)
api:
listen: ":1984" # WHEP/REST (BFF 시그널링용, 외부 비공개)-
자동 연결: go2rtc는
streams:항목을 첫 consumer(WHEP 연결) 시 lazy로 자동 연결한다. 항목 추가 후 서비스 reload(또는 재시작)만 하면 새 카메라가 즉시 사용 가능 — 코드 변경 불필요. 항상 연결(프리워밍)이 필요하면 해당 스트림에ffmpeg:...producer를 두어 상시 활성으로 만든다. -
자격증명:
${RTSP_USER}/${RTSP_PASSWORD}는 go2rtc의 env 치환. 평문 금지, 컨테이너 env(.env.secret)로 주입.
단일 소스(선택): RTSP 주소를 Frigate와 중복 관리하지 않으려면, SYSTEM_DESIGN.md §8.3.3의 zone 단일-소스 패턴과 동일하게 config/cameras.yaml(카메라당 rtsp/zone/codec) 하나에서 scripts/gen-cctv-relay.py가 config/cctv-relay.yaml을, scripts/gen-frigate-config.py가 Frigate cameras:를 각각 생성한다.
- 브라우저는 same-origin으로 tablet-fe BFF(
/api/cctv/webrtc/<cam>)에 WHEP offer(SDP)를 보낸다 → BFF가cctv-relay의 go2rtc API(/api/webrtc?src=<cam>,:1984)로 프록시하고 answer를 반환. - 협상 후 미디어(SRTP)는 브라우저 ↔ cctv-relay ICE로 직접(P2P) — Node BFF는 시그널링만 중계, 미디어는 거치지 않는다. LAN 한정이라 STUN/TURN 불필요.
- RTSP 자격증명·relay 주소는 서버에만 보유 → 브라우저 번들 노출 금지(CCTV SSE 브리지와 동일 원칙).
-
기본(권장): 독립 pull —
cctv-relay가 카메라에서 직접 RTSP를 가져온다. Frigate detect 파이프라인과 완전 분리 → 한쪽 장애가 다른 쪽에 전파되지 않음. 비용: 카메라당 연결 2개(detect + relay), 입력 대역폭 중복(§DEPLOYMENT 0.4). - 대안(단일 pull): Frigate detect 입력을
rtsp://cctv-relay:8554/<cam>로 두어 카메라 연결을 1개로 줄일 수 있으나, Frigate가 relay 가용성에 종속되므로 비전 파이프라인 안정성과 트레이드오프. 카메라 동시 연결 제한이 빡빡할 때만 채택.
-
8555/tcp+udp— WebRTC 미디어(브라우저↔cctv-relay). -
1984/tcp— go2rtc API/WHEP(BFF 시그널링용, 외부 비공개). -
8554/tcp— (선택) RTSP restream, 단일-pull 토폴로지에서만 노출.
# deploy/docker-compose.yml (cctv-relay 서비스)
cctv-relay:
image: ghcr.io/alexxit/go2rtc:1.9.x
command: ["-config", "/config/cctv-relay.yaml"]
volumes: ["./config/cctv-relay.yaml:/config/cctv-relay.yaml:ro"]
env_file: [./.env.secret] # RTSP_USER, RTSP_PASSWORD
ports: ["8555:8555/tcp", "8555:8555/udp"] # 1984은 LAN 내부(BFF)만 → 외부 미노출
restart: unless-stopped- 영상 비저장: go2rtc는 기본적으로 녹화하지 않음 → 라이브 통과만(개인정보 정책 §11.3와 무충돌).
-
코덱 확인 선결: WebRTC는 H.264만 실질 지원(HEVC 불가). 신규 카메라는
ffprobe rtsp://...로 확인 후, H.265면 위 transcode 항목 추가. -
폴백: WebRTC 협상 실패 시 MSE(fMP4 over WS, go2rtc
/api/ws)로 폴백.
- 이미지:
ghcr.io/home-assistant/home-assistant:2026.1 - 라이선스: Apache 2.0
-
그대로 사용 + 다음을 우리가 작성:
- MQTT(Message Queuing Telemetry Transport, 메시지 큐 텔레메트리 전송 프로토콜) integration 활성화
- 엔티티 정의 (template sensor, MQTT discovery)
- 자동화 (rule-based 폴백 시나리오)
- Lovelace 대시보드 YAML
- Zone playbook용 input_select / input_boolean
ha-config/
├── configuration.yaml
├── automations.yaml
├── scripts.yaml
├── secrets.yaml # gitignore
├── packages/
│ ├── sessions.yaml # 세션 엔티티
│ ├── robots.yaml # 로봇 엔티티
│ ├── zones.yaml # 공간 점유 엔티티
│ └── fallback.yaml # LLM 폴백 자동화
└── lovelace/
└── operator.yaml # 운영자 대시보드
mqtt:
broker: mosquitto
username: !secret mqtt_user
password: !secret mqtt_password
discovery: true
discovery_prefix: homeassistant
recorder:
purge_keep_days: 3
exclude:
domains: [automation, updater]
http:
use_x_forwarded_for: true
trusted_proxies: [127.0.0.1]
homeassistant:
packages: !include_dir_named packagesLLM(Large Language Model, 대규모 언어 모델)이 응답 못 하는 동안에도 시연이 끊기지 않도록 모든 zone에 정적 시나리오를 사전 정의한다. 트리거는 공통 — "orchestrator heartbeat 30초 이상 미수신 + zone enter 또는 tablet intent 발생".
| Zone | 트리거 | 폴백 액션 (로봇 + 태블릿) |
|---|---|---|
cafe |
vision/zone/enter |
• SPOT look_at (사용자) • 태블릿: 옵션 ["커피 추천 (오프라인)", "조용히 둘러보기"]
|
park |
vision/zone/enter |
• SPOT gesture: wave • 태블릿: 메시지 "공원 산책로를 따라 이동해보세요" + 옵션 ["로봇과 산책", "혼자 산책"]
|
vehicle |
vision/zone/enter |
• 로봇 명령 없음 (차량 내부 협소) • 태블릿: 메시지 "운전석에 앉아보세요" + 옵션 ["오디오 데모", "디스플레이 데모"]
|
home |
vision/zone/enter |
• SPOT sit (실내 안전 자세) • 태블릿: 옵션 ["거실 분위기", "주방 분위기", "침실 분위기"]
|
| any |
tablet/+/intent (옵션 선택) |
• 태블릿: 메시지 "선택 감사합니다. 운영자에게 안내드릴게요." (정적 응답) |
| any | safety/estop active=true |
• 모든 활성 태블릿: 메시지 "잠시 시연을 멈추었습니다" 표시 |
packages/fallback.yaml (모든 zone 공통 헬퍼):
# orchestrator 활성 여부 sensor
template:
- binary_sensor:
- name: orchestrator_alive
state: >
{{ (now() - states.sensor.orchestrator_heartbeat.last_updated).total_seconds() < 30 }}
availability: "{{ states('sensor.orchestrator_heartbeat') not in ['unknown', 'unavailable'] }}"
# 공통 trigger·condition 변수로 묶음
input_text:
fallback_last_session:
name: "마지막 폴백 발동 세션"zone별 자동화 (4개):
automation:
- alias: "Fallback - cafe enter"
trigger:
- platform: mqtt
topic: vision/zone/enter
condition:
- condition: state
entity_id: binary_sensor.orchestrator_alive
state: 'off'
- condition: template
value_template: "{{ trigger.payload_json.data.zone_id == 'cafe' }}"
action:
- service: mqtt.publish
data:
topic: "robot/spot-01/command"
payload_template: >
{
"command_id": "{{ now().timestamp() | int | string }}",
"robot_id": "spot-01",
"capability": "look_at",
"params": {"target_session_id": "{{ trigger.payload_json.data.session_id }}"},
"priority": "operator",
"source": {"service": "home-assistant", "trace_id": "fallback-{{ now().timestamp() | int }}"},
"expires_at": null
}
qos: 1
- service: mqtt.publish
data:
topic: "tablet/{{ states('input_text.session_to_tablet_' ~ trigger.payload_json.data.session_id) }}/show_options"
payload: >
{
"tablet_id": "...",
"message_id": "fallback-cafe-{{ now().timestamp() | int }}",
"options": [
{"id": "fallback_drink", "label": "커피 추천 (오프라인)"},
{"id": "fallback_quiet", "label": "조용히 둘러보기"}
],
"ttl_s": 60,
"timestamp": "{{ now().isoformat() }}"
}
qos: 1
- alias: "Fallback - park enter"
# 동일 패턴, capability=gesture, params.name=wave, 옵션 변경
...
- alias: "Fallback - vehicle enter"
# 로봇 명령 없음, 태블릿 메시지만
...
- alias: "Fallback - home enter"
# capability=sit, 옵션 3개
...
- alias: "Fallback - tablet intent (LLM down)"
trigger:
- platform: mqtt
topic: tablet/+/intent
condition:
- condition: state
entity_id: binary_sensor.orchestrator_alive
state: 'off'
action:
- service: mqtt.publish
data:
topic: "tablet/{{ trigger.topic.split('/')[1] }}/show_message"
payload: >
{
"message_id": "fallback-ack-{{ now().timestamp() | int }}",
"content": {"text": "선택 감사합니다. 운영자에게 안내드릴게요.", "media": [], "speaker": "system"},
"duration_s": 5,
"timestamp": "{{ now().isoformat() }}"
}
- alias: "Fallback - estop broadcast"
trigger:
- platform: mqtt
topic: safety/estop
condition:
- condition: template
value_template: "{{ trigger.payload_json.data.active == true }}"
action:
# 모든 활성 태블릿에 broadcast (세션 목록은 HA entity로 보유)
- repeat:
for_each: "{{ state_attr('sensor.active_sessions', 'tablet_ids') | default([]) }}"
sequence:
- service: mqtt.publish
data:
topic: "tablet/{{ repeat.item }}/show_message"
payload: >
{
"content": {"text": "잠시 시연을 멈추었습니다", "speaker": "system"},
"duration_s": null
}- orchestrator 컨테이너를
docker compose stop으로 강제 종료. - 30초 후 zone enter 트리거 → 위 4개 자동화 각각 발동되는지 라이브 테일로 확인:
docker exec mosquitto mosquitto_sub -t '#' -v
- 각 zone 시나리오의 메시지·로봇 동작 모두 검증 후 orchestrator 재기동.
- LLM이 복귀하면
binary_sensor.orchestrator_alive가on이 되어 자동화 condition 차단 — 추가 폴백 발동 안 함. - 진행 중이던 폴백 메시지는 자체 TTL(60s)로 자연 소멸. orchestrator의 신규 메시지가 덮어쓰면 즉시 교체.
- Glance 카드: 로봇 배터리·연결·현재 동작
- Picture-elements: 전시장 floor plan 위에 활성 세션 위치 오버레이
- Conditional 카드: e-stop(emergency stop, 긴급 정지) 활성 시 큰 빨간 배너
- History graph: 최근 1시간 zone 점유 변화
- 베이스:
python:3.12-slim -
paho-mqtt: MQTT 클라이언트 -
pydantic: 스키마 검증 -
numpy,scipy: 임베딩 매칭 - (선택)
boxmot: cross-camera ReID 알고리즘 — AGPL 주의
전부 자체 개발. 외부 OSS는 라이브러리 수준만 활용.
-
frigate/events구독 → 우리vision/zone/{enter|exit}토픽으로 변환 - 세션 ↔ track ID 매핑: 태블릿 페어링 시 캡처한 외형 임베딩과 Frigate 추적 결과를 매칭
- Cross-camera ReID: 카메라 간 동일 인물 매칭 (Frigate 미지원 영역)
- Position update를 5Hz로 발행
Frigate frigate/events는 객체 단위로 발행되며, 동일 person이 zone 경계에서 깜빡이거나 카메라 전환 시 노이즈가 발생한다. 단순 변환 시 다음 문제가 생김:
- 같은 person이 cafe→park 이동 중 잠깐 둘 다 ON → 중복 enter.
- person이 zone 경계에서 머무르며 ON/OFF 반복 → 깜빡거리는 enter/exit.
- cross-camera 전환 시 Frigate track ID 변경 → exit 후 즉시 enter.
따라서 session_id 단위로 "현재 zone" 상태를 bridge가 보유하고 hysteresis(이력)을 두어 판정한다.
# bridge/zone_tracker.py
@dataclass
class SessionZoneState:
session_id: str
current_zone: ZoneId | None
candidate_zone: ZoneId | None # 전이 중 후보
candidate_since: datetime | None # 후보 진입 시각
last_seen: datetime| 입력 | 조건 | 출력 |
|---|---|---|
frigate/events의 entered_zones에 새 zone Z 등장 (현재 다른 zone) |
Z != current_zone |
candidate_zone=Z, candidate_since=now |
candidate_zone이 ENTER_HOLD_MS(800ms) 동안 유지 |
timer 만료 |
vision/zone/exit (current_zone) → vision/zone/enter (Z) → current_zone=Z |
| candidate_zone이 hold 중 다시 사라짐 | flicker | candidate 폐기 (이전 enter는 무시) |
frigate/events에 person 사라짐 (current_zones 빔) |
last_seen 갱신만 | (즉시 exit 안 함) |
last_seen이 EXIT_GRACE_MS(3000ms) 이상 stale |
timer 만료 |
vision/zone/exit (current_zone) → current_zone=None |
| cross-camera 전환으로 Frigate track ID 변경 | ReID로 같은 session_id 매칭 | 동일 session으로 처리 — exit/enter 발행 안 함 |
zone_tracker:
enter_hold_ms: 800 # 짧으면 깜빡거림, 길면 NFR(1.5s) 위반
exit_grace_ms: 3000 # 짧으면 잠깐 가린 인물도 exit, 길면 zone 사이 빈 공간 길어짐
stale_timeout_ms: 30000 # 이 시간 후 session 자체를 dropped로 처리(다른 메커니즘)async def on_frigate_event(event):
person = event["after"]
session_id = registry.match(person) # ReID
if not session_id:
return # 페어링 안 된 인물 — 무시
zones_now = set(person["current_zones"]) # 예: {"cafe_zone"}
domain_zone = first_match(zones_now, ZONE_MAP) # 'cafe' 등
state = states.get(session_id)
state.last_seen = now()
if domain_zone and domain_zone != state.current_zone:
if state.candidate_zone != domain_zone:
state.candidate_zone = domain_zone
state.candidate_since = now()
elif now() - state.candidate_since >= ENTER_HOLD_MS:
await commit_transition(state, domain_zone)
elif not domain_zone:
# zone 없음 — exit 후보 (timer는 별도 tick에서 처리)
state.candidate_zone = None
# 별도 tick (100ms 주기)
async def tick():
for state in states.values():
if not state.current_zone:
continue
if now() - state.last_seen >= EXIT_GRACE_MS:
await publish_exit(state.session_id, state.current_zone)
state.current_zone = None-
vision/zone/enter발행 직후 동일 (session_id,zone_id) 중복 enter 1초 내 발행 금지. - envelope
id는 항상 새 ULID(Universally Unique Lexicographically Sortable Identifier, 범용 고유 정렬 가능 식별자) — 구독자가 같은 메시지를 두 번 처리하지 않도록.
services/bridge/
├── Dockerfile
├── requirements.txt
├── pyproject.toml
├── src/
│ └── bridge/
│ ├── __init__.py
│ ├── main.py # 엔트리포인트
│ ├── mqtt_client.py # paho-mqtt 래퍼
│ ├── frigate_adapter.py # frigate/events 파서
│ ├── reid.py # 임베딩 매칭, cross-cam
│ ├── session_registry.py # session_id ↔ track_id
│ ├── topics.py # 토픽 상수
│ └── schemas.py # pydantic 모델
└── tests/
├── test_frigate_adapter.py
├── test_reid.py
└── fixtures/
└── frigate_events/ # 샘플 JSON
# src/bridge/main.py
from bridge.mqtt_client import Mqtt
from bridge.frigate_adapter import handle_frigate_event
from bridge.session_registry import registry
mqtt = Mqtt(host="mosquitto", user="bridge", password=os.environ["MQTT_PWD"])
@mqtt.on("frigate/events")
def on_frigate(msg):
domain_events = handle_frigate_event(msg, registry)
for ev in domain_events:
mqtt.publish(ev.topic, ev.payload, qos=1)
@mqtt.on("session/end")
def on_session_end(msg):
registry.unbind(session_id=msg["session_id"]) # SCRUB: 임베딩 참조 해제
mqtt.run_forever()-
유닛:
frigate_adapter에 픽스처 JSON 입력 → 도메인 이벤트 출력 검증. - 통합: 임베디드 mosquitto + Frigate 이벤트 시뮬레이터로 E2E.
- 성능: 10명 동시 추적 시 latency p95 < 100ms 검증.
- 베이스:
python:3.12-slim -
anthropic: Claude SDK(Software Development Kit, 소프트웨어 개발 키트) -
langgraph: 그래프 오케스트레이션 -
mcpPython SDK: MCP(Model Context Protocol, 모델 컨텍스트 프로토콜) 서버 연결 -
paho-mqtt,pydantic,httpx(HA REST(Representational State Transfer, 표현 상태 전이)),prometheus-client
전부 자체 개발. LangGraph는 골격, 노드와 zone playbook은 우리가 작성.
- MQTT 트리거 구독 (
vision/zone/enter,tablet/+/intent,context/+/+/update,context/environmental/update) - HA REST/WebSocket으로 컨텍스트 어셈블
- Claude tool use 호출 (MCP 서버 노출)
- 가드레일 (스키마·whitelist·hard clamp)
- 도구 호출 결과를 MQTT publish 또는 MCP dispatch
- 폴백 모드 (Claude 실패 시 rule-based)
services/orchestrator/
├── Dockerfile
├── pyproject.toml
├── src/
│ └── orchestrator/
│ ├── main.py
│ ├── graph.py # LangGraph 정의
│ ├── nodes/
│ │ ├── assemble.py
│ │ ├── call_claude.py
│ │ ├── guardrail.py
│ │ ├── dispatch.py
│ │ └── fallback.py
│ ├── ha_client.py # HA REST/WS
│ ├── mcp_clients.py # robot/tablet/ha MCP 연결
│ ├── prompts/
│ │ ├── system.md
│ │ └── playbooks/
│ │ ├── cafe.md
│ │ ├── park.md
│ │ ├── vehicle.md
│ │ └── home.md
│ ├── tools.py # Claude tool 스키마
│ ├── metrics.py # Prometheus
│ └── topics.py
└── tests/
├── test_graph.py
├── test_guardrail.py
└── test_playbooks.py
TOOLS = [
{
"name": "robot_command",
"description": "로봇에게 capability 명령을 발행. 안전 인터록 통과 시에만 실행됨.",
"input_schema": {
"type": "object",
"properties": {
"robot_id": {"type": "string"}, # 런타임에 ROBOT_REGISTRY로 검증 (config/robots.yaml)
"capability": {"type": "string",
"enum": ["goto", "follow", "stop", "look_at", "gesture"]},
"params": {"type": "object"},
},
"required": ["robot_id", "capability"],
},
},
{
"name": "show_options",
"description": "체험자 태블릿에 선택지를 표시.",
"input_schema": {...},
},
# ...
]def validate_tool_calls(state: FlowState) -> dict:
accepted, rejected = [], []
for call in state["tool_calls"]:
# 1. JSON schema
if not validate_schema(call):
rejected.append((call, "schema"))
continue
# 2. capability whitelist
if call["input"]["capability"] not in ALLOWED_CAPS:
rejected.append((call, "whitelist"))
continue
# 3. zone polygon clamp
if "params" in call["input"] and "target_pos" in call["input"]["params"]:
if not in_zone_polygon(call["input"]["params"]["target_pos"]):
rejected.append((call, "zone_fence"))
continue
# 4. velocity clamp
if "velocity" in call["input"].get("params", {}):
call["input"]["params"]["velocity"] = min(
call["input"]["params"]["velocity"], MAX_VELOCITY
)
accepted.append(call)
REJECT_COUNTER.inc(len(rejected))
return {"tool_calls": accepted, "rejected_calls": rejected}시스템 프롬프트 + zone playbook은 stable → Anthropic prompt cache 활용.
- 노드 단위: 각 LangGraph 노드 입출력 검증.
- 가드레일 fuzz: 의도적으로 잘못된 도구 호출 1000건 → 100% 거부 확인.
- 시뮬레이션: 가짜 MQTT 이벤트 stream → 그래프 종단 동작 검증.
- 베이스:
python:3.12-slim -
bosdyn-client,bosdyn-api: SPOT SDK (Boston Dynamics 별도 EULA) - (미정 로봇 결정 후) ROS2
rclpy또는 해당 SDK -
mcpPython SDK: MCP server 노출 -
paho-mqtt,pydantic
전부 자체 개발. SPOT SDK는 라이브러리 호출 수준.
- 이종 로봇 추상화: Capability Model 인터페이스를 SPOT/미정 로봇 어댑터가 구현
-
MCP 도구 노출: orchestrator가 호출할
robot_command도구 - 안전 인터록: Hardware e-stop · zone polygon · proximity guard · watchdog
- 명령 큐: priority(safety > operator > llm) FIFO
-
텔레메트리: 1Hz
robot/{id}/state발행 -
Fault 감지:
robot/{id}/fault발행
services/mcp-robot-adapter/
├── Dockerfile
├── pyproject.toml
├── src/
│ └── adapter/
│ ├── main.py # MCP server + MQTT bridge
│ ├── capability_model.py # 추상 인터페이스
│ ├── adapters/
│ │ ├── spot.py # bosdyn-client 래핑
│ │ └── generic_ros2.py # 미정 로봇용
│ ├── safety/
│ │ ├── interlock.py # 통합 게이트
│ │ ├── estop.py
│ │ ├── zone_fence.py
│ │ ├── proximity.py
│ │ └── watchdog.py
│ ├── command_queue.py
│ ├── telemetry.py
│ └── topics.py
└── tests/
├── test_safety.py
├── test_capability_model.py
└── test_command_queue.py
# capability_model.py
from abc import ABC, abstractmethod
from typing import Literal
Capability = Literal["goto", "follow", "stop", "sit", "stand", "gesture", "look_at"]
class RobotAdapter(ABC):
@abstractmethod
async def execute(self, capability: Capability, params: dict) -> dict: ...
@abstractmethod
async def get_state(self) -> dict: ...
@abstractmethod
async def emergency_stop(self) -> None: ...# adapters/spot.py
from bosdyn.client import create_standard_sdk
from bosdyn.client.robot_command import RobotCommandClient, RobotCommandBuilder
class SpotAdapter(RobotAdapter):
def __init__(self, hostname, username, password):
sdk = create_standard_sdk("zerone-spot")
self.robot = sdk.create_robot(hostname)
self.robot.authenticate(username, password)
self.cmd_client = self.robot.ensure_client(RobotCommandClient.default_service_name)
async def execute(self, capability, params):
if capability == "stop":
cmd = RobotCommandBuilder.stop_command()
elif capability == "goto":
cmd = RobotCommandBuilder.synchro_se2_trajectory_point_command(
goal_x=params["x"], goal_y=params["y"], goal_heading=params.get("yaw", 0),
frame_name=ODOM_FRAME_NAME,
)
elif capability == "look_at":
cmd = self._build_look_at(params["target"])
else:
raise ValueError(f"Unknown capability: {capability}")
future = self.cmd_client.robot_command_async(cmd)
return {"command_id": future.command_id}# safety/interlock.py
class SafetyGate:
def __init__(self, estop, fence, proximity, watchdog):
self.checks = [estop, fence, proximity, watchdog]
async def allow(self, robot_id, capability, params) -> tuple[bool, str | None]:
for check in self.checks:
ok, reason = await check.evaluate(robot_id, capability, params)
if not ok:
return False, reason
return True, None- 유닛: 각 안전 체크가 정확한 조건에서 거부하는지.
- 계약 테스트: SPOT 시뮬레이터(BD에서 제공) 또는 mock 어댑터로 capability 호출 검증.
- 카오스: e-stop 이벤트 발생 시 큐에 남은 모든 명령 거부 확인.
- 베이스:
node:20-alpine -
ws: WebSocket 서버 -
mqtt: MQTT 클라이언트 -
@modelcontextprotocol/sdk: MCP server (TypeScript) -
zod: 스키마 검증
전부 자체 개발.
- 태블릿 WSS(WebSocket Secure, 보안 웹소켓) 연결 관리 (재연결·세션 페어링)
- MQTT
tablet/{id}/show_options·show_message구독 → WSS push - WSS 입력 → MQTT
tablet/{id}/intent발행 - MCP 도구로
show_options·show_message를 외부에 노출 (orchestrator 직접 호출용) - 메시지 ID 추적 + ACK
services/mcp-tablet-gateway/
├── Dockerfile
├── package.json
├── tsconfig.json
├── src/
│ ├── main.ts
│ ├── wss-server.ts
│ ├── mqtt-bridge.ts
│ ├── mcp-server.ts
│ ├── session-pairing.ts # QR 토큰 발급
│ ├── schemas.ts
│ └── topics.ts
└── tests/
├── wss.test.ts
└── mqtt-bridge.test.ts
import { WebSocketServer } from "ws";
import mqtt from "mqtt";
const wss = new WebSocketServer({ port: 8080 });
const mq = mqtt.connect("mqtt://mosquitto");
const tablets = new Map<TabletId, WebSocket>();
wss.on("connection", (ws, req) => {
const tabletId = authenticate(req);
tablets.set(tabletId, ws);
mq.subscribe(`tablet/${tabletId}/+`);
ws.on("message", (raw) => {
const intent = parseIntent(raw); // zod 검증
mq.publish(`tablet/${tabletId}/intent`, JSON.stringify(intent), { qos: 1 });
});
ws.on("close", () => tablets.delete(tabletId));
});
mq.on("message", (topic, payload) => {
const [, tabletId, type] = topic.split("/");
const ws = tablets.get(tabletId);
if (!ws) return;
ws.send(JSON.stringify({ type, ...JSON.parse(payload.toString()) }));
});전제: 전시장 입구에 페어링 카메라 1대(또는 입구 zone 카메라 겸용)가 항상 켜져 있고, Frigate가 person 추적 중. 태블릿은 부팅 후 운영자가 체험자에게 전달.
Tablet tablet-gateway Frigate bridge orchestrator
│ │ │ │ │
│ POST /pair │ │ │ │
├───────────────────►│ │ │ │
│ │ generate │ │ │
│ │ - session_id (ULID) │ │
│ │ - pairing_token (32B random) │ │
│ │ - QR(payload=session_id) │ │
│ 200 { session_id, token, qr_png } │ │ │
│◄───────────────────┤ │ │ │
│ │ │ │ │
│ display QR + "잠시만 카메라를 봐주세요" │ │ │
│ │ │ │ │
│ ─── 운영자가 태블릿을 페어링 카메라 앞으로 ─── │ │
│ │ │ │ │
│ │ │ person 감지 │ │
│ │ │ + QR 디코딩 │ │
│ │ │ (zone=pairing)│ │
│ │ │ │ │
│ │ ├─MQTT frigate/events──► │
│ │ │ │ │
│ │ │ │ 매칭: │
│ │ │ │ - person bbox에서│
│ │ │ │ OSNet 임베딩 추출│
│ │ │ │ - QR payload에서 │
│ │ │ │ session_id 추출 │
│ │ │ │ - registry.bind() │
│ │ │ │ │
│ │ │ │ MQTT session/start│
│ │ │ ├──────────────────►│
│ │ │ │ │ assemble + welcome
│ │ │ │ │ Claude tool call:
│ │ │ │ │ show_message("환영합니다")
│ WSS upgrade Auth: Bearer <token> │ │ │
├───────────────────►│ verify token │ │ │
│ │ session_id 매핑 │ │ │
│ WSS open │ │ │ │
│◄───────────────────┤ │ │ │
│ │ │ │ │
│ {type:"show_message", text:"환영..."} │ │ │
│◄───────────────────┤◄─────────────────────────────────────────────────┤
│ │ │ │ │
-
mcp-tablet-gatewayPOST /pair:-
session_idULID 생성,pairing_token32-byte random. - QR 이미지 생성: payload = session_id (URL-safe base64).
- 응답:
{session_id, token, qr_png_base64}. 토큰 TTL 4시간. - 토큰은 in-memory Map에
{token: session_id}저장.
-
- 태블릿: QR 풀스크린 + 음성 안내 "카메라를 봐주세요". 5초 카운트다운.
-
Frigate: 페어링 카메라(예:
pairing_cam_01)에서 person 감지 + QR 디코드 (zone polygonpairing_zone). 디코드 결과를 attribute로 이벤트 발행. -
bridge:-
frigate/events구독. -
current_zones에pairing_zone포함 + QR attribute에서 session_id 추출 시:- person bbox 영역에서 OSNet(Omniscale Feature Network, 옴니스케일 특징 네트워크) 임베딩 추출 (BoxMOT 또는 자체 모델).
-
registry.bind(session_id, embedding). -
session/startMQTT publish ({session_id, paired_tablet_id, visual_embedding_ref, started_at}).
-
-
orchestrator:session/start수신 → 환영 시나리오 실행 (Claudeshow_message). - 태블릿: 토큰으로 WSS 연결 → orchestrator 메시지 수신 시작.
| 상황 | 동작 |
|---|---|
| 태블릿이 QR 표시 후 60초 내 카메라 인식 못 함 | 태블릿 자동으로 페어링 화면 재시작, 토큰 폐기 |
| Frigate가 person은 보지만 QR 디코드 실패 | bridge가 무시 (session_id 없으면 bind 안 함) |
| 같은 session_id로 다른 임베딩 재바인딩 시도 | 기존 임베딩 덮어쓰기 (재페어링 의도) + warn 로그 |
| WSS 연결 시 토큰 만료 | 401 응답 → 태블릿 재페어링 |
- 페어링 카메라 위치: 입구·태블릿 수령대 기준 1.5m 거리, 머리·상반신 잘 보이게.
- 체험자에게 "QR이 사라질 때까지 카메라를 봐주세요" 안내.
- 동시 페어링 회피: 한 번에 한 명씩 카메라 앞으로.
- 유닛: zod 스키마, 페어링 토큰 생성/만료.
- 통합: 가짜 mosquitto + WSS 클라이언트로 메시지 round-trip.
- 프레임워크: React Native (권장·기본). Flutter도 가능하나 본 시스템은 RN 생태계 기준으로 가이드 — 변경 시 별도 PoC 필요.
-
ws: WSS 연결 (mcp-tablet-gateway와 통신). -
react-native-vision-camera: QR 스캔. -
zustand: 상태 관리.
전부 자체 개발.
- 부팅 → 페어링 대기: QR 표시 (또는 운영자 스캔 모드).
-
세션 진행: orchestrator가 push한
show_options/show_message를 동적으로 렌더. -
인터랙션: 사용자가 옵션 선택·자유 텍스트 입력 →
tablet/{id}/intentpublish. - 세션 종료: 운영자 트리거 또는 timeout.
- UI: NativeBase / React Native Paper
- 애니메이션: Reanimated 3
- 미디어: react-native-video (LLM이 영상 응답 시)
apps/tablet/
├── App.tsx
├── src/
│ ├── screens/
│ │ ├── PairingScreen.tsx
│ │ ├── SessionScreen.tsx
│ │ └── EndScreen.tsx
│ ├── components/
│ │ ├── OptionsCard.tsx
│ │ └── MessageBubble.tsx
│ ├── stores/
│ │ └── session.ts
│ ├── transport/
│ │ ├── wss.ts
│ │ └── auth.ts
│ └── schemas.ts
└── ios|android/
- 인터랙션 단순하면 Home Assistant Lovelace 풀스크린 + 커스텀 카드로도 가능 (개발 비용 ↓, 표현 자유도 ↓).
zerone/
├── deploy/ # 운영 docker-compose, .env, configs
│ ├── docker-compose.yml
│ ├── frigate/config.yml
│ ├── ha-config/
│ ├── mosquitto/
│ └── prometheus/
├── services/
│ ├── bridge/
│ ├── orchestrator/
│ ├── mcp-robot-adapter/
│ └── mcp-tablet-gateway/
├── apps/
│ └── tablet/
├── packages/ # 공유 라이브러리
│ └── topics/ # 토픽 상수·스키마 (TS/Python 듀얼)
├── .github/workflows/
└── docs/ # SYSTEM_DESIGN.md, MQTT_SCHEMA.md, ...
packages/topics에 TypeScript와 Python에서 모두 import 가능한 형태로 정의 (Pydantic ↔ Zod 동기화). 변경 시 양쪽 자동 반영하도록 codegen.
모든 서비스가 공유:
MQTT_HOST=mosquitto
MQTT_PORT=1883
LOG_LEVEL=info
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
SERVICE_NAME=<each>
모든 자체 서비스는 GET /healthz 노출 — Docker HEALTHCHECK와 Prometheus probe에서 사용.
- 구조화 JSON (
structlogPython,pinoNode). -
trace_id필드 필수 — orchestrator가 발행, 모든 다운스트림이 전파.
MQTT envelope의 traceparent는 W3C Trace Context 형식. 모든 자체 서비스는 다음 패턴으로 추출·전파한다.
의존성:
opentelemetry-api ~= 1.27
opentelemetry-sdk ~= 1.27
opentelemetry-exporter-otlp-proto-http ~= 1.27
opentelemetry-instrumentation-paho-mqtt ~= 0.48b0
부트스트랩:
# packages/topics/tracing.py (모든 Python 서비스 공통)
from opentelemetry import trace
from opentelemetry.propagate import extract, inject
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
def init_tracing(service_name: str):
provider = TracerProvider(resource=Resource.create({"service.name": service_name}))
provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter())) # OTEL_EXPORTER_OTLP_ENDPOINT
trace.set_tracer_provider(provider)발행자 패턴:
tracer = trace.get_tracer(__name__)
def publish_with_trace(topic: str, data: dict, span_name: str):
with tracer.start_as_current_span(span_name) as span:
carrier = {}
inject(carrier) # carrier["traceparent"] = "00-..."
envelope = wrap(
type_=topic.replace("/", "."),
data=data,
source=f"/services/{SERVICE_NAME}",
traceparent=carrier.get("traceparent"),
)
mqtt.publish(topic, json.dumps(envelope), qos=1)구독자 패턴:
def on_message(topic, payload):
envelope = json.loads(payload)
ctx = extract({"traceparent": envelope.get("traceparent", "")})
with tracer.start_as_current_span("handle_" + envelope["type"], context=ctx) as span:
span.set_attribute("event.id", envelope["id"])
span.set_attribute("event.type", envelope["type"])
process(envelope["data"])의존성:
"@opentelemetry/api": "^1.9",
"@opentelemetry/sdk-node": "^0.55",
"@opentelemetry/exporter-trace-otlp-http": "^0.55"import { trace, context, propagation } from "@opentelemetry/api";
const tracer = trace.getTracer("mcp-tablet-gateway");
function publishWithTrace(topic: string, data: unknown, spanName: string) {
return tracer.startActiveSpan(spanName, (span) => {
const carrier: Record<string, string> = {};
propagation.inject(context.active(), carrier);
const envelope = wrap({ type: topic.replaceAll("/", "."), data, traceparent: carrier.traceparent });
mqtt.publish(topic, JSON.stringify(envelope), { qos: 1 });
span.end();
});
}-
Anthropic API: orchestrator가 호출 시 현재 span의 traceparent를 HTTP
traceparent헤더로 자동 주입 (OTLPInstrumentation사용 시). -
HA REST:
httpxinstrumentor 활성화 — span 자동 추가. - MCP 호출 (orchestrator → mcp-robot-adapter): MCP transport가 HTTP/SSE면 동일하게 traceparent 헤더 주입.
- 환경변수:
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318(또는 직접 Tempo). - 운영에선 Tempo 또는 Grafana Cloud Traces로 export. 미러 환경에선 Jaeger UI(
localhost:16686)로 시각화.
[Frigate event] → bridge.handle_frigate_event
└─ bridge.publish: vision/zone/enter (traceparent 신규 생성)
└─ orchestrator.handle_vision_zone_enter (extract + 동일 trace 계속)
├─ orchestrator.assemble_context
│ ├─ http GET /api/states/... (HA REST)
│ └─ http GET /api/states/...
├─ orchestrator.call_claude (Anthropic API)
├─ orchestrator.guardrail
└─ orchestrator.dispatch
└─ mcp-robot-adapter.robot_command (MCP 호출, 같은 trace)
└─ adapter.execute_spot (SDK 호출)
한 줄로 이어지는 trace를 Grafana에서 펼쳐 latency 병목·오류 추적.
-
mcp-robot-adapter,mcp-tablet-gateway는 MCP spec(JSON-RPC 2.0 over stdio/SSE) 준수. - 도구 정의 시
inputSchemaJSON Schema 정확히 명시 → Claude가 정확히 호출.
- Python:
ruff+mypy --strict - TypeScript:
eslint+tsc --strict - 모든 PR에 CI에서 lint·type·test 통과 필수.
기존 코드가 없으므로 N/A. 처음부터 위 구조로 시작 권장.
신규 합류자에게 보여줄 입문 경로:
- SYSTEM_DESIGN.md — 전체 그림
- OSS_INTEGRATION.md — 어떻게 묶이는가
- 이 문서 (SERVICES.md) — 어디부터 코딩하는가
- MQTT_SCHEMA.md — 인터페이스 계약
- DEPLOYMENT.md — 어떻게 굴리는가