홈 synapse TOM AND GERRI NEXUS 체험해보기 GitHub ↗
🗄️
플릿 관제 서버 FastAPI Single-site API-first

NEXUS

한 사이트의 플릿 매니저 · WebRTC 시그널링 게이트웨이

단일 사이트의 로봇 플릿을 한곳에서 보는 관제 서버이자, 운영자↔로봇 P2P 연결을 이어주는 WebRTC 시그널링 릴레이. 상태의 원본은 로봇(엣지)이 소유하고, NEXUS는 그 사본(뷰·캐시)만 들고 있는 Edge-Authoritative 설계다.

python -m nexus app.py relay.py registry.py fleet.py persistence.py gateway.py config.py v0.0.1

실제 로봇 없이 nexus_ui 스타일 화면에서 직접 조종해 볼 수 있습니다.

Overview

한 사이트를 책임지는 뷰·캐시 서버

NEXUS 한 대 = 한 사이트의 플릿 매니저.상태의 진실은 로봇에 있고, 서버는 그 사본을 모아 보여줄 뿐이다.

NEXUS는 진실의 원본(SSOT)이 아니다. 각 로봇(TOM)이 자기 상태의 권위를 가지며, NEXUS는 WebSocket으로 받은 상태를 메모리에 캐시해 대시보드에 비춰주는 뷰다.

핵심 축은 둘이다. 첫째, 연결된 로봇들의 status를 모아 캐시하고 REST로 노출한다(/api/robots). 둘째, 운영자와 로봇이 직접 만나도록 WebRTC 시그널링을 중계한다(/ws/{room}). 핸드셰이크가 끝나면 미디어·DataChannel은 P2P로 직결되고 NEXUS는 데이터 경로에서 빠진다 — 작은 시그널링 JSON만 오간다.

이 두 축 위에 엣지 원칙을 지키는 얇은 서비스가 얹힌다 — FleetMission 조율(/api/fleet/*), 공간 메타(표준맵·로봇맵 정렬 부착 /api/maps·/api/alignments), 3D 트윈용 로봇 description(URDF/GLB) 캐시·서빙(/api/description), 컨트롤플레인 명령 릴레이(/api/command), IoT 알람 집계(/api/alarms), 흐름 추적 집계(/api/trace/* — 기본 꺼짐). 어느 것도 좌표 계산이나 미션 실행을 대신하지 않는다 — 여전히 부착·중계·모니터링만 한다.

의존성도 좁다. NEXUS가 import하는 것은 공통 표준 synapse 하나뿐이며(메시지 토픽 Topic 사용), tom_and_gerri나 tag_* 같은 로봇 코드는 전혀 import하지 않는다 — 코드 결합도 0, 오직 네트워크 메시지 표준으로만 대화한다.

Edge-Authoritative. 연결 끊김(OFFLINE)조차 NEXUS가 단정하지 않는다./api/robots는 server_time을 함께 내려주고, 클라이언트가 server_time − last_seen > ttl 로 스스로 판단한다.
역할
단일 사이트 플릿 관제 + WebRTC 시그널링 게이트웨이
백엔드
FastAPI + Uvicorn (ASGI) · pydantic ≥ 2.0 · websockets
상태 저장
in-memory dict 레지스트리가 기본 · 재시작 생존용 SQLite 스냅샷 영속은 옵션(persistence.py, stdlib sqlite3 최소 구현) — NEXUS_DB_PATH로 켬(run.sh 기본 ON). per-robot 이력(history) seam 배선됨 — NEXUS_HISTORY로 opt-in(실 백엔드는 이관 영역)
기동
python -m nexus 단일 명령 · 빌드 없음 — 서버 배포는 ./setup.sh 한 번으로 설치·systemd 등록·기동·헬스체크까지 끝난다
의존
오직 synapse import (tom_and_gerri·tag 미import → 결합도 0)
버전 · 테스트
v0.12.0(스택 세대 정렬 — MINOR는 전 레포 공통) · pytest 스위트 통과(20개 파일)

Figure 1. NEXUS 핵심 사실. 상태 원본은 로봇이 소유하고 NEXUS는 뷰/캐시 — 메모리 레지스트리가 기본이고, 재시작 생존용 SQLite 스냅샷 영속은 run.sh 기본 ON이다.

Stack & Boot

기술 스택과 단일 명령 기동

실시간 제어는 엣지에, 미디어는 P2P에 맡기므로 NEXUS는 IO 중심이다.async·WebSocket·OpenAPI를 기본 제공하는 FastAPI + Uvicorn 한 켤레로 충분하다.

패키지는 여전히 단순하다. __main__.py가 uvicorn.run(create_app())을 띄우고, app.py의 create_app() 팩토리가 라우트와 RelayHub·RobotRegistry를 조립한다. relay.py는 룸 기반 시그널링 릴레이, registry.py는 (robot_id, sub_id) 상태 캐시, fleet.py는 FleetMission DAG 조율기, persistence.py는 SQLite 영속 seam, gateway.py는 콕핏 정적 서빙 + config 주입, config.py는 정적 Pydantic 설정을 담당한다(그 외 ratelimit.py·logging_config.py). npm·webpack 같은 빌드 단계는 없다.

terminal · 단일 프로세스 기동
# synapse(공통 표준)는 editable 설치 — tom_and_gerri/tag 는 설치/그 import 불필요
pip install -e ./synapse
pip install -e ./nexus      # fastapi · uvicorn · websockets · pydantic>=2

# 서버 기동 (기본 http://0.0.0.0:8000)
python -m nexus
python -m nexus --host 0.0.0.0 --port 8000

# 서버(Linux+systemd) 배포는 원커맨드 — 형제 clone·venv·설치·서비스 등록·기동·healthz 확인까지
./setup.sh                        # 재실행 안전 · SETUP_NO_SERVICE=1 이면 서비스 단계 생략
./run.sh                          # 개발 PC 포그라운드 실행 — nexus.env 있으면 자동 로드

Figure 2. python -m nexus 한 줄이 전부다. --host/--port는 선택, 기본값은 0.0.0.0:8000. 서버 배포에서는 setup.sh가 systemd 유닛의 User·경로를 자동 치환해 상시 가동까지 맡고, 배포별 환경변수는 nexus.env(gitignore) 한 파일로 주입된다(run.sh·systemd 양쪽 자동 로드).

배포 환경에서는 같은 프로세스를 컨테이너로 올린다. 빌드 컨텍스트는 형제 레포가 모인 부모 디렉터리(new_system/)로 두는데 — 비공개 패키지 synapse를 로컬 설치로 먼저 충족한 뒤 nexus를 설치해야 from synapse import …가 깨지지 않기 때문이다. 이미지는 python:3.12-slim 위에 두 패키지를 얹고 포트 8000을 EXPOSE한다. compose는 볼륨 ./data:/data로 맵 PNG·SQLite registry를 영속하고, NEXUS_UI_DIR·NEXUS_MAPS_DIR·NEXUS_DB_PATH 환경변수와 restart: unless-stopped를 건다.

terminal · 컨테이너 기동 (cwd = new_system/)
# 빌드 + 백그라운드 기동 (context 는 부모 — synapse 를 함께 담는다)
docker compose -f nexus/docker-compose.yml up --build -d

# 헬스체크
curl -sf http://localhost:8000/healthz

Figure 3. 컨테이너 운영. 빌드 컨텍스트를 부모로 둬 비공개 synapse를 로컬 설치로 먼저 충족한 뒤 nexus를 설치하고, ./data 볼륨으로 맵·registry를 재시작 너머 보존한다.

모듈역할
__init__.py공개 API — RelayHub · RobotRegistry · load_config · SystemConfig를 export.
__main__.pypython -m nexus 진입점 — uvicorn.run(create_app())으로 서버를 띄운다.
app.pyFastAPI create_app() 팩토리 + 모든 라우트 조립.
relay.pyRelayHub(룸 기반 room = robot_id 시그널링 릴레이) + DashboardHub(대시보드 push).
registry.pyRobotRegistry — (robot_id, sub_id) in-memory status 상태 캐시.
fleet.pyFleetMissionRunner — depends_on DAG 검증·조율, run_view 발행.
persistence.py영속 seam — NullStore(기본) / SqliteStore(stdlib sqlite3, 스냅샷 + 이력).
trace.pyTraceGraph — trace.hop 수집·집계(노드/엣지 + 개수 상한·TTL). 기본 꺼짐 — 로봇이 켠 만큼만 산다.
config.pySystemConfig / SiteConfig / FloorConfig (pydantic, 정적) — 배포마다 다른 IceServer(STUN/TURN)만 NEXUS_ICE_SERVERS 환경변수로 주입.
gateway.py콕핏 정적 서빙 + window.__COCKPIT__ 주입.
ratelimit.py · logging_config.py/api/command 릴레이 rate-limit · 로깅 설정.
cockpit_default/tag_{robot}/ 폴더가 없을 때의 폴백 콕핏.

Figure 4. 패키지 레이아웃 — 코어 모듈 + 폴백 콕핏. 영속은 persistence.py의 stdlib sqlite3 최소 구현으로, 별도 무거운 DB 의존성 없이 스냅샷/이력 seam을 제공한다.

HTTP & WebSocket API

엔드포인트 한눈에

app.py가 노출하는 표면 — 상태·description·맵/정렬·플릿·제어·IoT·흐름추적 REST + 콕핏 게이트웨이 + WS 3개(/ws/dashboard·/ws/trace·/ws/{room}).site/building/floor는 URL 네임스페이스일 뿐, 콕핏 폴더는 robot id로 고른다.

메서드경로반환 · 동작
상태 · 설정
GET /api/robots {"robots":[{robot_id, subs:{sub_id:entry}, last_seen}…], "server_time":float}
등록 로봇을 robot_id로 묶어 반환. 클라이언트가 server_time − last_seen으로 OFFLINE 추론.
GET /api/sites SystemConfig.model_dump()
사이트 → 건물 → 층(floor) → 맵 트리. 정적 config를 그대로 직렬화.
GET /healthz 헬스체크 — uptime · 등록 로봇 수.
POST /register_robot/{room} rtc2kng 호환용 ack — no-op이다. NEXUS는 /ws/{room} 합류 시 자동 등록이라 실제 동작은 없고, 등록 POST를 보내는 클라이언트에 200으로 답해 404 노이즈를 없앤다(드롭인 호환). 실제 등록·라우팅은 로봇이 보내는 robot.status로 이뤄진다.
로봇 description (3D 디지털 트윈)
GET /api/description/{robot_type} 로봇 URDF/GLB 본문 서빙(디렉터리형 model.urdf → 플랫 폴백, 없으면 404).
POST /api/description/{robot_type} 로봇 → 서버 URDF + 메시 zip 업로드/캐시(3D 트윈용). 가드: basename·60MB·zip-slip·엔트리 상한·model.urdf 또는 model.glb(모바일 일체형) 필수·원자적 교체.
GET /api/descriptions 캐시된 description 매니페스트(index.json).
공간 — 표준맵 · 정렬
GET POST DEL /api/maps · /api/maps/{id} 표준맵 목록(+image_url) · 맵 에디터 업로드(JSON+base64 PNG) · 업로드 맵 삭제(config 맵은 불가).
GET POST DEL /api/alignments 로봇맵→표준맵 정렬(SE(2) to_standard=[tx(m), ty(m), θ(deg)]) 조회·등록·삭제. status 수신 시 standard_map_id+to_standard를 부착만 한다(좌표 계산은 UI). 등록(POST)은 standard_map_id가 실제 등록된 표준맵(config + 업로드맵)인지 검증 → 아니면 400(dangling 방지). 맵을 보고하지 않는 로봇도 (robot_id, '*') 정렬로 표준맵 위에 배치할 수 있다. 저장 시 to_standard의 자릿수를 정리한다 — 마법사가 계산한 SE(2)의 부동소수 꼬리(-33.33006134969326)가 그대로 저장돼 있었고, 이 값은 로봇 발행 경로를 안 타서 gerri 자릿수 게이트가 못 본다 — 저장하는 여기가 유일한 초크포인트다. θ를 deg 자릿수 정책(WIRE_DECIMALS["deg"])으로 접는 것이 이 필드의 단위 선언이기도 하다 — 만드는 쪽이 nexus_ui(JS)라 synapse 타입이 강제하지 못하고, angle_unit 브릿지도 로봇 발행 경로만 보므로 여기는 사각지대다(2026-08-18: 맵 에디터 창 하나가 θ를 rad로 접어 저장하고 있었다 — 90 입력 → 1.57 저장 → 콘솔은 1.57°로 해석).
플릿 미션 (FleetMissionRunner)
GET POST /api/fleet/templates 저장된 FleetMission 템플릿 목록 · 빌더가 만든 미션을 missions/*.json으로 저장(DAG 검증).
GET POST /api/fleet/missions 진행/완료 런 목록 · 시작({fleet_mission} 인라인 또는 {template}+args).
GET POST /api/fleet/missions/{run_id} · …/abort 런 단건 조회(run_view) · 중단(진행 로봇에 stop 발송).
제어 · IoT
POST /api/command 컨트롤플레인 명령 릴레이 — 게이트키핑(봉투검증 400 → 미연결 404 → rate-limit 429 → 전달 200).
GET POST DEL /api/placements · /api/placements/{robot_id} 설비(IoT: 엘리베이터·자동문 등) 설치 좌표(표준맵 world m) 목록 · 등록/갱신 · 삭제. {robot_id, map_id, x, y, theta} — 설비는 pose를 스스로 보고하지 않으므로 서버가 위치를 소유한다(placements.json 영속). 엘리베이터는 관통하는 맵마다 등록.
GET /api/alarms · /api/ice 활성 IoT 알람 집계(io.is_alarm) · WebRTC ICE(STUN/TURN) 설정 — TURN은 요청 시마다 시간제한 크리덴셜 생성(coturn use-auth-secret), server_time 동봉.
흐름 추적(trace) · 기본 꺼짐
GET /api/trace/graph 흐름 그래프 스냅샷 — 노드=참여자(operator:{id}·nexus·robot:{id}/{layer}), 엣지=(source, target, topic)별 관측 흐름(최근 60초 창의 메시지/초 · p50/p95 지연 · 정규화 탈락 필드 · 마지막 hop). 로봇이 안 켰으면 빈 그래프다 — 에러가 아니다.
GET /api/trace/message/{uuid} 한 메시지(msg_info.uuid)의 end-to-end hop 타임라인. 링버퍼라 최근 것만 남는다 → 없으면 404.
WS /ws/trace 흐름 추적 증분 push — 연결 시 전체 snapshot, 이후 배치가 건드린 노드/엣지만 보낸다(/ws/{room}보다 먼저 정의 — 와일드카드 가로채기 방지).
콕핏 게이트웨이 · WebSocket · 정적
GET /{site}/{building}/{floor}/tag_{robot}/… 콕핏 index.html + window.__COCKPIT__ = {robotId, signalingUrl, mode} 주입 · 정적 자산 서빙(traversal 차단). tag 폴더 없으면 cockpit_default 폴백.
WS /ws/dashboard 대시보드 실시간 push(/ws/{room}보다 먼저 정의 — 와일드카드 가로채기 방지).
WS /ws/{room} room = robot_id. 통합 시그널링 릴레이 + 상태 캐시. 운영자·로봇이 같은 room에서 메시지 교환.
GET /console · /maps · /descriptions 정적 마운트 — nexus_ui 콘솔 · 표준맵 이미지 · URDF/메시(상대경로 해석).

Figure 5. 전체 API 표면. signalingUrl은 요청 Host로부터 ws(s)://{host}/ws/{robot} 형태로 만들어 콕핏에 주입된다.

Signaling Relay & Status Cache

하나의 WS, 다섯 갈래 처리

단일 엔드포인트 /ws/{room}가 메시지 종류에 따라 캐시 / 브로드캐스트 / 1:1 라우팅 / 컨트롤플레인 중계 / 흐름 추적 수집으로 갈린다.시그널링 JSON은 해석 없이 원문 그대로 전달된다.

topic == "robot.status"로봇 → NEXUS
→
캐시만 (릴레이 X). registry.upsert_status(value)로 (robot_id, sub_id) 엔트리를 갱신. 대시보드용 사본일 뿐 다른 피어에 전달하지 않는다.
type == "ready" (+ sender)운영자 입장
→
등록 후 브로드캐스트. hub.register_sender(sender, ws)로 sender id를 등록하고, receiver가 없으므로 같은 room의 나를 제외한 모두에게 전파.
offer / answer / candidate (+ receiver)핸드셰이크
→
1:1 유니캐스트. receiver id로 등록된 그 피어에게만 전달. 원문 JSON을 그대로 forward — 재직렬화·해석 없음.
command.* (receiver ≠ room)컨트롤플레인 중계
→
명령 중계 — HTTP 불필요. 로봇이 이미 연결된 WS로 command.* 봉투(receiver ≠ room)를 보내면 /api/command와 동일 게이트키핑(미연결 경고 · rate-limit 429)으로 대상 피어에 중계. 로봇이 IoT·타 노드를 조작할 때 별도 HTTP가 필요 없다.
topic == "trace.hop"흐름 추적 · 기본 꺼짐
→
집계만 (릴레이 X). TraceGraph.record_batch로 접고 /ws/trace 구독자에게 증분만 push. 분기는 status 분기 뒤에 둔다 — 10~50Hz 핫패스에 비교 한 줄도 더하지 않는다(관측이 대상을 바꾸면 안 된다).

Figure 6. RelayHub.targets() + app WS 핸들러의 분기. receiver가 있으면 그 피어에게만, 없으면 room 전체로. status는 라우팅에서 빠져 캐시로 흡수되고, command.*(receiver ≠ room)는 게이트키핑을 거쳐 대상 피어로 중계되며, trace.hop은 릴레이 없이 집계기로만 간다.

한 줄 규칙. 핸들러는 메시지의 봉투(sender / receiver)만 보고 JSON 원문을 그대로 중계한다 — SDP·ICE 페이로드는 들여다보지 않는다.robot.status만은 라우팅 대상에서 빠져 레지스트리에 캐시만 된다.대시보드로의 실시간 push는 별도 /ws/dashboard가 담당한다(DashboardHub).
Robot-first WebRTC

로봇이 먼저 offer를 만드는 핸드셰이크

운영자의 ready 신호를 받은 로봇이 offer를 만든다.설계상 NEXUS는 시그널링까지만 — 핸드셰이크 후 미디어·데이터는 P2P로 직결된다.

🧑‍💻
운영자 / COCKPIT
웹 브라우저
WebRTC 클라이언트
WS 시그널링
🗄️
NEXUS
/ws/{room}
릴레이 + status 캐시
WS 시그널링
🖥️
로봇 (TOM)
상태 원본 소유자
offer 생성
🧑‍💻 운영자
핸드셰이크 후 WebRTC P2P 직결
미디어 · DataChannel (NEXUS 안 거침)
🖥️ 로봇

Figure 7. 시그널링은 NEXUS를 거치지만, 연결이 서면 데이터 경로에서 NEXUS는 빠진다.

1
운영자 → room
ready 전송
운영자가 {type:"ready", sender:…}로 입장. NEXUS는 sender를 등록하고 room의 로봇에게 브로드캐스트한다.
2
로봇 → 운영자
offer 생성·전송
ready를 받은 로봇이 offer를 만든다(robot-first). receiver를 운영자로 지정 → NEXUS가 1:1로 전달.
3
운영자 → 로봇
answer 응답
운영자가 answer를 돌려준다. 역시 receiver 지정 1:1 라우팅.
4
양방향
ICE candidate 교환
candidate 메시지를 서로 주고받으며 경로를 협상. 모두 receiver 지정 1:1.
✓
운영자 ⇄ 로봇
P2P 연결 — NEXUS 데이터 경로 이탈
미디어·DataChannel이 직결되면 NEXUS는 작은 시그널링 JSON만 중계할 뿐 데이터 흐름에서 빠진다.

Figure 8. Robot-first 핸드셰이크 단계. ready만 브로드캐스트, offer/answer/candidate는 receiver 지정 1:1 라우팅이다.

TURN · coturn

STUN으로 안 뚫리는 현장 — 시간제한 크리덴셜

대칭 NAT·사내 방화벽 뒤에서는 STUN만으로 P2P가 서지 않는다. 이때 TURN 릴레이가 필요한데, TURN 계정을 클라이언트에 고정으로 박아두면 노출된 순간 누구나 릴레이를 쓴다. 그래서 NEXUS는 /api/ice에서 coturn의 TURN REST API(use-auth-secret) 규약으로 요청 시마다 시간제한 크리덴셜을 만들어 내려준다 — username = 「만료 epoch:nexus」, credential = base64(HMAC-SHA1(secret, username)). 서버가 쥔 auth_secret 자체는 응답에 절대 나가지 않는다.

만료가 있으므로 클라이언트는 캐시하지 않고 통화 직전에 다시 조회한다 — 콕핏(브라우저)은 1:1 진입 시점에, 로봇(rtc2kng)은 연결을 만들 때마다 /api/ice를 부른다(조회 실패는 정적 설정 → 공용 STUN 순으로 폴백해 연결 시도 자체는 막지 않는다). 응답에는 시계 스큐 판단용 server_time이 동봉된다. 정적 계정(coturn lt-cred-mech)을 쓰는 배포는 auth_secret 대신 username/credential을 그대로 넣으면 된다.

terminal · TURN 원커맨드 + ICE 설정
# coturn 설치 → 시크릿 생성 → turnserver.conf → 서비스 기동 → nexus.env 작성까지 한 번에(재실행 안전)
sudo bash scripts/setup_turn.sh          # 공인 IP 자동 감지 (또는 setup_turn.sh turn.keti.kr)
sudo systemctl restart nexus            # nexus.env(EnvironmentFile) 자동 로드
curl -s http://localhost:8000/api/ice    # username 이 '만료epoch:nexus' 형태면 정상

# 수동 구성 시 — 배포별 ICE 설정은 환경변수 하나로 주입(JSON 리스트)
NEXUS_ICE_SERVERS='[{"urls":"stun:stun.l.google.com:19302"},
  {"urls":["turn:turn.keti.example:3478?transport=udp","turns:turn.keti.example:5349"],
   "auth_secret":"<coturn static-auth-secret 과 동일>","ttl_s":600}]'

Figure 9. TURN 배포. scripts/setup_turn.sh가 coturn 설치부터 nexus.env 작성까지 맡고, 사람이 직접 여는 것은 방화벽뿐이다(3478/udp·tcp, 릴레이 범위 49152–65535/udp, TLS는 5349/tcp). 파싱 실패 시 기본 STUN으로 계속 떠서 서버 기동을 막지 않는다.

Data Model & Registry

(robot_id, sub_id)로 캐시하고 robot_id로 묶기

레지스트리는 (robot_id, sub_id) 키로 status를 upsert한다.보통 1 로봇 = 1 base = 1 피어지만, 드물게 물리적으로 분리된 디바이스는 별도 sub로 발행한다.

한 로봇이 여러 sub(또는 분리 디바이스)로 status를 따로 발행해도, /api/robots는 이를 robot_id 기준으로 묶어 한 로봇처럼 보여준다. 묶인 그룹의 last_seen은 sub들 중 가장 최신 시각이다. 드물게 물리적으로 갈라진 경우에는 mobile_01.arm 같은 피어 id로 구분한다. robot_id가 없는 status는 라우팅 불가로 폐기되며(경고 로그), 잘못된 메시지가 서버를 죽이지 않게 한다.

필드설명
robots[]등록된 로봇 배열. 각 항목은 robot_id로 묶인 한 로봇.
robots[].robot_id로봇 식별자 (그룹 키).
robots[].subs{ sub_id: entry } 맵. 각 entry는 robot_info · robot_state · mission_info를 담는다.
robots[].last_seenfloat — 묶인 sub들 중 가장 최신 수신 시각.
server_timefloat — 응답 생성 시각. 클라이언트의 OFFLINE 판정 기준.

Figure 10. /api/robots 응답 구조. 각 sub 엔트리는 robot_info · robot_state · mission_info를 담고, 상위에 server_time이 동봉된다.

OFFLINE은 클라이언트가 판정한다. NEXUS는 단정하지 않고, 클라이언트가 server_time − last_seen > ttl 인지로 스스로 판단한다.
화이트리스트가 아니라 패스스루다: 종전엔 robot_info·robot_state·mission_info 3개만 뽑아 저장해서, 로봇이 value 최상위에 실은 나머지 블록이 조용히 잘렸다. 실제로 capability_info(진행 표시)가 이 지점에서 소실됐고 — 로봇도 gerri도 정상 발행 중인데 콘솔에만 안 보여서 원인 추적이 어려웠다. 릴레이는 계약을 해석하지 말고 실어 나른다 — Postel's Law.
통째 교체가 아니라 블록 병합이다: 로봇이 안 바뀐 정적 블록을 생략하기 때문이다(gerri _omit_unchanged — robot_info가 와이어의 61%인데 거의 안 변한다). 교체로 두면 생략된 틱마다 capability·presets·version이 사라졌다 다시 나타나 콘솔이 깜빡였다. 규칙은 한 줄이다 — 온 블록은 그대로 쓰고, 안 온 블록은 직전 것을 유지. 예외는 robot_info 하나로, 라우팅 키가 항상 실려 오므로 이 블록만 키 단위로 병합한다.
이기종 각도 단위 브릿지 — 수신 경계에서 1회: 로봇이 robot_info.angle_unit="rad"를 선언하면 upsert_status가 to_standard_angles()로 deg로 접는다(멱등). 그 뒤로 registry·API·콘솔은 전부 deg만 본다. 이건 gerri를 안 쓰는 로봇(plain class·외부 스택)을 위한 안전망이다 — gerri 로봇은 이미 발행 시점에 접어서 보낸다(synapse 각도 표준). 정규화가 실패해도 status 수신을 죽이지 않는다(워닝 후 원본 저장).

✅ NEXUS가 하는 것

📊status 수집·캐시 — (robot_id, sub_id) upsert, robot_id로 그룹화 노출
🔀WebRTC 시그널링 릴레이 — ready 브로드캐스트, offer/answer/candidate 1:1
🗂️콕핏 게이트웨이 — 정적 서빙 + window.__COCKPIT__ 주입
🧭플릿 미션 조율 — FleetMissionRunner가 DAG를 풀어 미션 트리거·모니터링
🗺️공간 메타 부착 — /api/sites·maps·alignments, status에 표준맵 정렬 부착(계산 X)
🧊3D 트윈 description 캐시 — 로봇 URDF/GLB/메시 업로드·서빙(/api/description)
◈흐름 추적 집계 — trace.hop을 그래프로 접어 /api/trace/*·/ws/trace로 노출(기본 꺼짐)

🚫 NEXUS가 하지 않는 것

💾제어·미디어 중계 X — 카메라·DataChannel은 P2P 직결
📌상태 원본 보유 X — SSOT는 로봇(엣지). NEXUS는 사본
🗃️본격 시계열 DB X — SQLite 스냅샷 + per-robot 이력 seam(opt-in)까지만. 배치·보존정책·Timescale 등 실 백엔드는 이관 영역
📐좌표 계산·미션 실행 X — 정렬은 부착만, Task 실행은 로봇(엣지)
🌍다중 사이트 X — single-site 가정
config는 정적이다. config.py의 SITES dict를 시작 시 한 번 SystemConfig로 검증한다..env·핫리로드 없음 — 값을 바꾸면 서버를 재시작한다.예: {"itp": {"name":"인천테크노파크", "floors":{"1f":…, "2f":…}}}.
Fleet Orchestration · 구현됨

FleetMission — 다중 로봇 조율 (구현 완료)

다중 로봇 조율이 구현됐다(Phase 3).NEXUS가 FleetTask DAG를 조율해 로봇에 미션을 던지고 robot.status.mission_info로 진행을 추적한다 — 실행 주체는 여전히 로봇(엣지)이다.

구현됐습니다. nexus/fleet.py의 FleetMissionRunner가 FleetTask DAG를 실행하고, /api/fleet/* REST API로 시작·조회·중단한다(단위 테스트 tests/test_fleet.py 포함).

NEXUS는 스케줄링을 조율(orchestrate)한다. FleetMissionRunner가 depends_on 기반 DAG를 풀어, 의존 task가 모두 성공하면 해당 로봇에 mission.trigger를 던진다. 완료 추적은 robot.status.mission_info 폴링 — mission_state가 TERMINAL(SUCCEEDED/FAILED/ABORTED)이 되면 task 종료로 본다(Status가 SSOT). 미션 실행 주체는 로봇(TOM)이고, 데이터 모델 FleetMission은 synapse에 정의된다(NEXUS에 두지 않는다).

한 로봇은 한 번에 한 미션만 수행하므로, 같은 로봇을 대상으로 한 task는 의존이 없어도 자동 직렬화된다. 실패 정책 on_failure(stop · continue · retry · help)와 timeout을 지원하고, 진행상황은 run_view로 대시보드에 broadcast된다. 각 task의 skip/실패에는 기계판독 reason 코드(FleetTaskReason — dep_failed·upstream_stop·trigger_undelivered·fleet_timeout 등)와 사람용 detail이 분리돼 붙어, 제3 UI(Unity 등)도 동일 계약으로 분기·로컬라이즈할 수 있다. 요약하면 — NEXUS는 던지고 지켜보고, 로봇이 실행한다. 점유·실패 복구 같은 실행 책임을 엣지에 남기는 것이 Edge-Authoritative 원칙과 일관된다.

컴포넌트책임 · 구현됨
FleetMissionRunnernexus/fleet.py. depends_on 기반 DAG를 풀어 다중 로봇 미션을 조율(orchestrate). 예: 이동 → 제조 → 전달 → 배송 순서를 의존성으로 묶는다. validate_dag가 의존성 존재·사이클을 사전 검증한다.
/api/fleet/*REST — GET/POST templates(빌더 저장, DAG 검증) · GET/POST missions(인라인 fleet_mission 또는 template+args로 시작) · GET missions/{id} · POST missions/{id}/abort.
FleetMission (데이터 모델)synapse.missions에 정의 — NEXUS가 소유하지 않는다.
NEXUS의 역할mission.trigger로 미션을 던지고, robot.status.mission_info 폴링으로 진행을 모니터링한다(콜백 send·get_mission_info 주입 구조).
로봇 (TOM)의 역할실제 Task 실행 · 점유 · 실패 복구 — 실행 책임은 엣지에 남는다.

Figure 11. FleetMission 조율 — 구현됨(Phase 3). NEXUS는 DAG를 풀어 미션을 트리거하고 mission_info로 완료를 추적하며, 실제 실행은 로봇(엣지)이 맡는다.

Flow Observability · 기본 꺼짐

흐름 추적 — hop을 그래프로 접는다

한 명령이 운영자 → NEXUS → AND → base → sub → 메서드를 지나는 동안 어디서 무엇이 되었는지 한 화면에서 보게 한다.계약(TraceHop·TraceBatch)은 synapse가 소유하고, 발행은 GERRI/AND, 집계는 NEXUS다 — nexus/trace.py의 TraceGraph.

노드는 참여자, 엣지는 관측된 흐름이다. hop 1건은 “참여자 하나를 지나갔다”는 사실일 뿐이므로, 같은 msg_info.uuid의 앞뒤 hop을 at 순으로 이어 직전 hop → 이 hop을 엣지로 센다.

NEXUS도 자기가 와이어에서 본 것만 얹는다. 로봇만 hop을 만들면 노드가 언제나 robot:{id}/{layer}뿐이라 nexus도 operator:{id}도 그래프에 영원히 나타나지 않고, 간판인 end-to-end가 로봇 내부로 쪼그라든다. 얹는 무게는 토픽 정책으로 가른다(정책은 synapse가 미리 고정 — 판단이 두 곳에 있으면 드리프트한다).

정책토픽NEXUS가 하는 일
틱(tick)robot.status · command.teleop10~50Hz라 hop을 만들지 않는다 — 창 카운터(dict 조회 + 정수 증가)만 올리고 1초 창마다 엣지 관측 1건으로 접는다(count=창의 건수).
건별(per-message)command.capability · mission.trigger · mission.event · help.action사람이 누를 때만 생기는 저빈도 → 그때만 실제 TraceHop을 만든다. uuid로 end-to-end를 꿴다.
정책 없음trace.hop 자신아무 일도 하지 않는다 — hop이 hop을 낳는 증폭을 차단한다(관측이 대상을 바꾸는 최악의 형태).

Figure 12. 관측 샘플링 정책. 실측 근거 — command.teleop을 건별로 추적하면 hop 트래픽이 원본 업링크의 148%였다. 사용자 지침도 “최대한 가볍게, 토픽 정도만”이었다.

계기를 둔 자리는 이미 있던 초크포인트뿐: NEXUS 쪽 발행 지점은 WS 수신 루프와 /api/command 릴레이 둘이다. 후자를 빼먹으면 operator 노드가 아예 안 나타난다 — 콕핏의 send()는 P2P DataChannel을 먼저 쓰고 미개통이면 그 HTTP로 폴백하기 때문이다. registry.upsert_status는 건드리지 않는다(핫패스). P2P로 직행한 명령은 NEXUS를 지나지 않으므로 여기서 보이지 않고, 로봇쪽 AND가 자기 RX hop으로 기록한다.
관측 기능이 서버를 죽이면 안 된다: 세 저장소(노드·엣지·uuid 타임라인) 모두 개수 상한 + TTL을 동시에 건다 — 노드 256 · 엣지 2048 · uuid 512 · uuid당 hop 32 · 엣지당 지연 표본 128, TTL 300초. OrderedDict 순서가 곧 LRU라 상한 초과 시 앞에서 버리고, TTL 정리도 앞에서 훑다가 살아있는 첫 항목에서 멈춘다. 시계: 빈도·TTL은 서버 수신 시각으로 세고(로봇 시계가 틀어져도 msg/s는 정확해야 한다), 지연은 발행자가 계산한 latency_ms를 우선 쓴다. 음수(시계 역전)는 표본에서 뺀다. 발신 시각이 수신 시각보다 앞서면 수신 시각으로 눕힌다 — 없는 흐름을 그리는 것이 가장 나쁜 관측이다.
엣지 키에 topic을 포함한다: 한 쌍(nexus↔robot)에 50Hz robot.status와 가끔 오는 command.capability를 함께 담으면 p50 지연도 “마지막 1건”도 status가 다 덮어써 의미가 사라진다. 접어 보는 것은 소비자 몫이다 — /api/robots와 같은 원칙으로 서버는 전체를 준다. 같은 노드끼리(in→out, 처리 시간)는 엣지로 만들지 않는다 — self-loop이 그래프를 덮기 때문이고, 그 구간은 /api/trace/message/{uuid}의 원본 타임라인에 그대로 남는다.
숨은 값은 dropped_fields다: 엣지에 정규화가 떨군 필드 경로가 실린다 — “내 필드가 왜 콘솔에 안 보이지?”에 답하는 유일한 데이터다. 종전에 그걸 아는 곳은 발행측 워닝 로그뿐이었고, 그마저 경로당 60초 1회로 스로틀돼 사실상 안 보였다.
← COCKPIT (GERRI) 홈으로 →