홈 synapseTOMAND GERRINEXUS체험해보기 GitHub ↗
🧠
synapse
The single message standard every component imports — definitions only, no execution.

이기종 멀티로봇 플랫폼의 가장 안쪽 표준 레이어로, Topics·표준 메시지 6종·Enum 카탈로그·Pydantic v2 자료구조·미션 모델·흐름 추적 계약·standardize()를 한 곳에 모은다. 철학은 단 하나, "정의만 한다"는 것이다. 실행 로직은 바깥(tom_and_gerri·nexus)이 가지며, 여기를 바꾸면 전체가 흔들리므로 가장 신중히 관리한다.

공통 표준 레이어 pip 패키지 · v0.12.0 Python 3.10+ pydantic v2
__init__.pytopics.pymessage.py enums.pytypes.pymodels.py standardize.pybuilders.pyteleop_mapping.py time_sync.pymissions.pyunits.pyframes.pytrace.py
Message Catalog

표준 메시지 6종

외부로 흐르는 모든 통신은 정확히 6개의 토픽으로 끝난다. 토픽은 절대 raw 문자열로 쓰지 않고 항상 Topic.* 상수를 쓴다.

필드타입설명
msg_info.timestampfloattime.time() 기준 송신 시각
msg_info.uuidstruuid4 — JS 안전을 위해 문자열로 직렬화
msg_info.senderstr송신 로봇의 robot_id
msg_info.receiverstr | None수신 대상. None = 브로드캐스트
topicstr항상 Topic.* 값 — raw 문자열 금지
valuedict토픽별 페이로드

Figure 1. 공통 메시지 봉투. msg_info(timestamp·uuid·sender·receiver) + topic + value 세 부분으로 고정된다. uuid는 JS 호환을 위해 uuid4의 문자열로 직렬화한다.

robot.status
Topic.Robot.STATUS
로봇 전체 상태. RobotInfo(id·sub_id·type·shape·capability·version) + RobotState(base_state·pose·battery…) + MissionInfo 세 묶음. 주기적 "전체 상태" 스냅샷이자 단일 진실 원천(SSOT).
⚠️ raw_status(SDK 원본) ≠ base_state(시스템 종합)를 합치지 않는다. (robot_id, sub_id)로 upsert — 1:N 로봇 대응.
mission.event
Topic.Mission.EVENT
미션 진행 중 "방금 일어난 일". MissionEvent 16종으로 표현. Event를 놓쳐도 Status가 SSOT이므로 복구된다.
Status = 주기적 전체 상태 · Event = 실시간 알림.
mission.trigger
Topic.Mission.TRIGGER
외부에서 미션을 트리거. name + args 구조. 미션 시작, wait_input·wait_teleop 해제 등에 사용.
빌더: build_trigger(sender, name, args=None)
command.capability
Topic.Command.CAPABILITY
Capability 호출. value.name = capability 이름, value.params = 인자. base가 getattr(sub, name)(**params)로 디스패치한다.
✓ 단일 토픽 — command.{capability} 동적 토픽은 만들지 않는다(무한 폭발 방지).
command.teleop
Topic.Command.TELEOP
원격조작 입력을 단일 토픽으로 통일. axes · buttons · poses 세 채널을 함께 담는다. 조이패드와 VR을 하나의 표준으로 흡수.
build_joy() / build_vr()는 이 위에 얹힌 편의 빌더일 뿐, 별도 토픽이 아니다.
help.action
Topic.Help.ACTION
인간 개입 액션. HelpAction 기반(retry · skip · abort · takeover). "실패는 정상, 인간 개입 우선" 원칙의 메시지 표현.
빌더: build_help(sender, action, context=None)
내부 토픽: Topic.Network.SEND/RECV는 tom_and_gerri 내부 버스용으로 synapse에 함께 정의되지만 외부 표준 6종에는 포함되지 않는다. 규칙은 동일하다 — 토픽은 언제나 Topic.*를 통한다.
관측 채널 trace.hop — 6종을 보는 부수 채널: Topic.Trace.HOP은 시스템을 움직이는 위 6종과 성격이 다르다 — 그 6종이 어디까지 갔는지를 관측하는 채널이고 기본 꺼짐이다. 다만 와이어를 타므로 EXTERNAL_TOPICS 카탈로그에 포함되고 스키마·픽스처 검증 대상이다(흐름 추적 계약). 그래서 카탈로그 테스트는 숫자를 박지 않고 구성으로 검사한다 — 지키려는 성질은 “6종이다”가 아니라 “동적 토픽이 없다”이기 때문이다(시스템 6종 + 관측 1종).
Standard Schema

robot.status 상세 스키마

모든 로봇이 같은 형태로 보고한다. 핵심 설계 원칙은 차원 분리로, SDK 원본·시스템 종합·미션 진행을 절대 한 칸에 섞지 않는다.

필드타입 · 설명
robot_info — 정적 메타
robot_idstr
sub_idstr — 1:N 대응 키
robot_typestr (mobile·manipulator…)
robot_shapedict
capability{common[], custom[]}
versiondict | None — {repo_name: semver} 배포 버전 보고
angle_unit"deg"(기본·권장) | "rad" — 이 로봇이 보내는 각도의 단위 선언
extensiondict — 로봇별 정적 자기기술(프리셋·파트·작업평면·폼 명세)
robot_state — 동적 상태
base_stateBaseState — 시스템 종합(파생)
posePose | Pose3D
velocityTwist
battery_stateBattery — percentage · is_charging
joint_stateJoint
networkNetworkState
raw_statusdict — SDK 원본(분리!)
extensiondict — 로봇별 자유 확장
mission_info — 미션 진행
mission_stateMissionState
mission_namestr
current_taskstr
available_missionsstr[]
raw_status ≠ base_state ≠ mission_state
세 차원을 절대 병합하지 않는다. 같은 이름(IDLE·TELEOP)이 서로 다른 차원에 등장해도 의미가 다르다 — raw_status는 SDK 원본, base_state는 시스템 종합, mission_state는 미션 진행이다.
battery_state.percentage / is_charging
Battery 필드명은 percentage·is_charging(그 외 voltage·current·temperature). level/charging이 아니다.
정적 메타도 status 안에
capability·shape 같은 정적 메타데이터도 status에 함께 싣는다. 새 엔티티로 분산하지 않는다.
upsert key = (robot_id, sub_id)
1:N 로봇(한 본체에 여러 서브)을 위해 두 키로 upsert. sub_id가 모듈별 상태를 분리한다.
extension / raw_status = dict
표준에 없는 값은 자유 dict로 확장. 표준을 깨지 않고 로봇별 확장을 허용한다. 확장 슬롯은 둘이며 차원이 다르다 — robot_info.extension은 정적 자기기술(연결 시·변경 시에만 바뀜), robot_state.extension은 동적 상태다.
정적 메타를 최상위에 얹지 않는다
모델이 extra="allow"라 최상위에 실어도 통과는 하지만 세 가지가 깨진다 — 키 소멸 불가(registry는 키 단위 merge·툼스톤은 블록 단위), 각도 정규화 우회(to_standard_angles는 robot_state만 접으므로 rad 선언 로봇이 최상위에 각도를 실으면 조용한 57.3배), static/dynamic 혼동. 실측으로 실기 한 대가 비표준 키 15개를 최상위에 싣고 있었다.
런타임에 바뀌는 설정값 = robot_state.extension.settings
옆잡기 토글·status 주기·티칭 파일 목록처럼 런타임에 바뀌는 값을 static 블록인 robot_info에 쓰면, 생략 판정이 블록 전체의 digest라 키 하나만 바뀌어도 robot_info가 통째로 재전송된다 — 실측 ≈130배 증폭. robot_state는 어차피 매 틱 나가므로 거기 두면 증폭이 사라진다. ⚠️ 빈 값은 None이 아니라 []·false·0으로 말한다.

Figure 2. robot.status 스키마와 차원 분리 원칙. RobotState의 모든 필드는 optional이며, 누락분은 standardize()가 워닝과 함께 흘려보낸다.

Enum Catalog

표준 Enum — 핵심 6종 + Fleet 3종

모든 상태·이벤트·액션은 문자열 직접 사용 없이 이 Enum 들 중 하나로 표현한다 — 핵심 6종(base/mission/이벤트/개입/capability)에 플릿 상태 3종이 더해진다.

BaseState (5)
base가 sub들을 종합한 파생 상태. 우선순위 ERROR > NEED_HELP > TELEOP > BUSY > IDLE. 헬퍼 derive_base_state().
IDLEBUSYTELEOPNEED_HELPERROR
MissionState (8)
미션 진행 상태. 종료 상태 = {SUCCEEDED, FAILED, ABORTED}. WAIT_INPUT은 계획된 대기, NEED_HELP는 예외 개입.
IDLERUNNINGWAIT_INPUTTELEOPNEED_HELPSUCCEEDEDFAILEDABORTED
MissionEvent (16)
mission.event의 페이로드. 미션 레벨 4 · 태스크 3 · 인간 개입 4 · 개입 세션 3 · 무인 폴백 1 · UI 1로 구성.
미션 (4)
MISSION_STARTEDMISSION_SUCCEEDEDMISSION_FAILEDMISSION_ABORTED
태스크 (3)
TASK_STARTEDTASK_COMPLETEDTASK_FAILED
인간 개입 (4)
NEED_HELPHELP_RECEIVEDTELEOP_STARTEDTELEOP_ENDED
개입 세션 · A4 takeover (3)
TELEOP_TASK_STARTEDTELEOP_TASK_RESOLVEDTELEOP_MISSION_DONE
무인 폴백 (1)
HELP_TIMEOUT
UI (1)
WAIT_INPUT
HelpAction (4)
help.action에 실리는 인간 개입 액션.
retryskipaborttakeover
TeleopExitAction (5)
개입(TELEOP) 세션에서 태스크별 해소 선택 — A4 takeover 순회에서 태스크마다 사용.
resume_doneskipresume_retrymission_doneabort
Capability (18)
표준 capability 카탈로그. enum value == 메서드 이름. 자세한 분류는 아래 Capability 카탈로그 참조.
navigate_to_posenavigate_to_waypointvelocity_ctrldockundock joint_ctrlcartesian_ctrlgripper_ctrlhome_positionjoint_preset pan_tiltpan_tilt_zoomrelocateget_poseget_batteryget_joints software_updatestop
Fleet 상태 Enum (3종)
플릿 미션의 런/태스크 상태·사유. enums.py에 함께 정의되지만 소유·발행은 NEXUS의 FleetMissionRunner(run_view)다. 사유 코드는 제3 UI(Unity 등)까지 기계판독 가능하게 표준화한 것.
FleetRunState — 런 전체 (4)
runningsucceededfailedaborted
FleetTaskState — 개별 task (6)
pendingrunningretry_waitsucceededfailedskipped
FleetTaskReason — skip/fail 사유 코드 (7)
abortedtrigger_undeliveredretry_waitupstream_stopdep_failedfleet_timeouttask_failed

텔레옵 채널 매핑

teleop_mapping.py의 IntEnum 들은 6종 핵심 Enum이 아니라 command.teleop 페이로드의 채널 인덱스 도우미다. 카운트는 axes 8 · buttons 16 · poses 3이다.

JoyAxis (8)
axes[] 인덱스 매핑
LEFT_STICK_X=0LEFT_STICK_Y=1RIGHT_STICK_X=2RIGHT_STICK_Y=3LEFT_TRIGGER=4RIGHT_TRIGGER=5LEFT_GRIP=6RIGHT_GRIP=7
JoyButton (16)
buttons[] 인덱스 매핑 (0..15)
ABXYL1R1L2R2BACKSTARTLEFT_STICKRIGHT_STICKDPAD_UPDPAD_DOWNDPAD_LEFTDPAD_RIGHT
PoseChannel (3)
poses[] 인덱스 매핑 (VR 6DoF)
LEFT_HAND=0RIGHT_HAND=1HEAD=2

Figure 3. 텔레옵 채널 인덱스. synapse는 인덱스 표준만 정의하고 해석·핸들러는 tom_and_gerri가 맡는다 — 어떤 입력 장치든 같은 command.teleop로 흘려보낼 수 있다.

Capability Catalog

표준 Capability 18종

모든 로봇이 같은 이름·같은 시그니처를 쓰며, enum value가 곧 메서드 이름이고 base는 getattr(sub, name)(**params)로 디스패치한다.

모바일 (5)
navigate_to_posenavigate_to_waypointvelocity_ctrldockundock
매니퓰레이터 (5)
joint_ctrlcartesian_ctrlgripper_ctrlhome_positionjoint_preset
카메라 (2)
pan_tiltpan_tilt_zoom
pose 보정 (1)
relocate
조회 (3)
get_poseget_batteryget_joints
유지보수 (1)
software_update
필수 (1) · 자유 확장
stop ← 필수 custom_capability (자유)

Figure 4. Capability 18종 + 자유 확장. REQUIRED_CAPABILITIES = {stop} — 모든 로봇이 stop()을 구현해야 한다. 로봇별 비표준 동작은 free-form custom_capability로 추가한다.

디스패치 규칙: command.capability의 value.name에 capability 이름이 담기고, base가 getattr(sub, name)(**params)로 호출한다. command.{capability} 같은 동적 토픽은 만들지 않는다 — 토픽 폭발을 막는다.
relocate — pose 보정: 로봇 pose 추정치를 자기 map 프레임에서 (x, y, theta)로 보정한다(m·deg). 서버 소유의 맵 정렬 T·set_pose와는 엄격히 구분되며 — pose만 바꾼다. pose를 리포트하는 로봇만 광고한다.
software_update — 유지보수: NEXUS 릴레이로 로봇이 스스로 git pull 후 재시작한다(edge-authoritative). BUSY·TELEOP·NEED_HELP·ERROR면 거부한다 — 자가 재시작 위험 가드. N-디바이스 게이트웨이는 프로세스 1개만 갱신하고, 대행 디바이스 중 하나라도 BUSY·ERROR면 거부한다.
Core Building Blocks

핵심 자료구조 · 빌더

개발자가 실제로 import해서 쓰는 골격 — Result, StatusBuilder, standardize(), Topic.

Result — capability 반환 표준: Result는 capability가 반환하는 표준 @dataclass(Pydantic 아님)로, 라이브러리가 정의하고 사용자는 반환만 한다. 필드는 success(bool) · data(dict) · message(str) · error(str|None)이며, Result.ok(message="", **data) 또는 Result.fail(error, message="")로 생성한다.
StatusBuilder — status 메시지 빌더: StatusBuilder(robot_id).build(robot_state, mission_info, robot_info)가 status 메시지 dict를 만든다. 각 입력에 standardize()를 자동 적용하고 robot_id를 보장하며, 누락 필드는 워닝으로 흘려보낸다.
standardize() — Postel's Law(받을 땐 관대, 보낼 땐 표준): normalize(data)는 dict·Pydantic·dataclass·None 등 무엇이든 dict로 바꾼다(never raise). standardize(data, model_class)는 normalize 후 model_validate를 시도하고, 실패하면 문제 필드만 떼어 재검증해 (dict, warnings)를 반환한다 — 절대 예외를 던지지 않는다.
Topic — raw 문자열 금지: 토픽은 항상 Topic.* 상수로 접근한다(예: Topic.Command.CAPABILITY = "command.capability").
Topic.* 상수문자열 값
Topic.Robot.STATUSrobot.status
Topic.Mission.EVENTmission.event
Topic.Mission.TRIGGERmission.trigger
Topic.Command.CAPABILITYcommand.capability
Topic.Command.TELEOPcommand.teleop
Topic.Help.ACTIONhelp.action

자료구조 요약. models.py는 Pydantic v2이며 모든 필드가 optional이다. Pose3D{x,y,z,qx,qy,qz,qw}가 정규형(쿼터니언)이고 from_2d/from_rpy/.yaw/.to_2d 변환을 제공한다. Twist{linear, angular}, Joint{positions(required)·names·velocities·efforts}, NetworkState는 internet/server/operator 각각 Link{is_connected, latency_ms, packet_loss}로 구성된다.

기계판독 wire contract (contract/): 모델·enum·미션을 export_schema.py로 뽑은 JSON Schema(synapse.schema.json, draft 2020-12) + 토픽별 골든 픽스처(byte-stable, drift-guard) + interop.json(WebRTC 공통 코덱 베이스라인 VP8+Opus — SDK는 이를 협상해야 함)을 커밋해 둔다. SDK 코드젠(Phase 6~8)이 git 해시로 이 아티팩트를 핀한다. 버전(0.12.0)은 semver 규칙(D-07)으로 올린다.
스택 세대 정렬 — MINOR는 전 레포가 함께 올린다: 0.12.0부터 synapse·nexus·nexus_ui·tom_and_gerri가 같은 MINOR를 쓴다. 형제 의존은 >=0.0.1이고 전부 editable이라 버전이 무엇을 강제하지 않는다 — 그래서 값은 읽기 쉬움이 전부다. 종전처럼 레포마다 제각각이면(0.6.5·0.6.16·0.8.17) 숫자를 나란히 놓고 알 수 있는 게 없다. PATCH는 레포별로 독립이다.
Units · v0.6 Milestone

각도 표준 — 와이어는 deg 하나

2026-07-27, 표준 각도가 rad → deg로 바뀌었다. 워크스페이스 전 레포가 0.6.0으로 동시에 올라간 것이 그 경계다 — < 0.6은 rad 시대, >= 0.6은 deg 시대다. 태도는 언제나처럼 Postel이다 — 표준은 하나로 엄격하되 들어오는 것은 너그럽게 받는다.

층단위누가 책임지나
로봇 내부rad 또는 deg자유다. ROS·pinocchio·벤더 SDK가 rad 네이티브라 그게 자연스러운 로봇이 있다
발행 경계 ①→ degSubControllerCommon.get_status() — angle_unit 선언을 보고 각도를 접는다
발행 경계 ②→ degBaseControllerCommon._standardize_outgoing() — standardize() 전체 정규화 + 자릿수 정리
와이어deg (항상)표준. 여기부터는 단위가 하나뿐이다
수신 경계deg (멱등)NEXUS registry.upsert_status — gerri를 안 쓰는 로봇(plain class·외부 스택)을 위한 안전망
소비자deg (분기 없음)NEXUS·콕핏·도구. 단위 분기가 존재하지 않는다
rad 요구 소비자rad (일회용 뷰)units.to_radians() — ROS 노드 등. 되발행·저장 금지

Figure 5. 층별 단위 책임. 정규화가 수신이 아니라 발행 시점인 이유는 둘이다 — ① standardize()가 2D {x,y,theta}를 즉시 쿼터니언으로 승급시키는데 그때 단위가 틀리면 손실이 확정된다(π rad를 deg로 읽으면 qz=0.0274, 정답은 1.0). ② 콕핏은 WebRTC DataChannel로 status를 직접 받으므로(NEXUS 미경유) 수신 경계에만 두면 그 경로가 통째로 빠진다.

왜 deg인가: 사람이 직접 읽고 쓰는 값이기 때문이다. config·로그·테스트·콕핏 폼이 전부 사람 표면인데 rad는 매번 암산을 요구한다(tolerance 0.08이 4.58°인지). 실제로 pitch:0(rad 문맥)과 주석 "45° 하향"의 모순을 아무도 못 봐서 카메라 광축이 작업 평면과 안 만나는 상태가 방치된 사고가 있었다. ⚠️ 기계 경계는 여전히 rad다(우리 소유가 아니다) — pinocchio·URDF·urdf-loader·벤더 SDK·ROS REP-103, 그리고 ROS map.yaml의 origin. 각 경계에서 1회 변환하고 그 지점은 코드에 math.radians/degrees로 보이게 둔다.
units.py — 단위를 기계가 읽게: 각도는 rad든 deg든 타입이 똑같은 float라 틀려도 예외가 안 난다 — 파싱은 성공하고 계산만 57.3배 어긋난다. 종전 계약에는 단위가 주석에만 있어 스키마에 "unit"이 0회 등장했고 — SDK 코드젠도 검증기도 린터도 단위를 못 봤다. 이제 필드가 DegYaw·DegJoint·DegPerSec*·Unitless로 선언되고, 그 메타가 export_schema.py를 거쳐 synapse.schema.json에 실린다.
⚠️ 런타임 강제가 아니다 (의도적): json_schema_extra로만 넣고 minimum/maximum·Field(ge=…) 같은 진짜 제약은 쓰지 않는다. 표준 키워드로 내보내면 스키마를 읽는 외부 검증기가 진짜로 거절한다(적대감사 실측: theta=200·qz=1.0000001이 jsonschema에서 거절됨). 파이썬은 통과시키는데 계약 검증은 실패하는 자기모순이 된다. 제약을 걸면 Postel과도 충돌한다 — 범위 밖 값이 필드째 떨어져 나가는데, 값이 이상한 것보다 사라지는 게 나쁘다. 범위는 x_ 접두 참고치로만 두고 경고에 쓴다.
robot_info.angle_unit — 선언이지 변환 요청이 아니다: rad로 작업하는 로봇은 "rad" 한 줄만 선언하면 코드는 끝까지 rad로 써도 된다 — 프레임워크가 발행 시점에 접는다. 미선언은 deg로 간주한다(전환 이전 로봇도 안전하게 흡수). ⚠️ 틀리게 선언하면 그 로봇만 57.3배 어긋난다. 1순위는 여전히 로봇이 표준(deg)으로 맞춰 송출하는 것이다.
deg 세계에서 단위 오류는 조용하다: rad 값(0.785)이 deg 필드에 새어도 "유효한 작은 각도"라 파싱·범위검사를 통과하고 동작만 미세하게 어긋난다 — rad 표준이었을 땐 deg 값 45가 범위 밖이라 시끄러웠다. 그래서 standardize()가 rad 리터럴(π 배수)과 크기 밴드(비영값이 전부 |v|<2π)를 경고한다.
이관에서 남는 쪽은 소비자가 아니라 생산자다: 2026-07-27 전환에서 각도를 읽는 쪽(applyStandard·poseYaw·wrap)은 한 번에 옮겨졌지만, 만드는 쪽이 rad로 남아 3주 동안 조용히 깨진 채였다(콘솔에서 6곳 — 2026-08-18 nexus_ui 0.12.1에서 정리). 읽는 쪽은 틀리면 화면이 즉시 이상해져 이관이 강제되는데, 만드는 쪽은 Math.cos·atan2가 rad를 요구하는 기계 경계라 코드가 종전 그대로도 돌아간다 — 증상은 입력 크기에 비례해서만 나타난다(정렬 마법사가 raw (8, 5, −45°) 로봇을 (10,10)에 찍으면 (21.3, 19.3) — 대각선 14.6m 튄다). ⚠️ 위의 standardize() 경고는 Python 발행 경계에만 있다 — 콘솔(JS) 내부 계산은 그 그물을 안 지나므로 단위 경계를 테스트로 고정해야 한다(test_angle_units.js: 왕복 50조합 + "지워도 예외가 안 나는" 경계 4곳을 원문에서 확인).
단위자릿수근거
deg · deg/s30.001° = 17 µrad — 어떤 로봇 엔코더보다 곱다
dimensionless (쿼터니언)6회전 오차 0.00006°. 4자리면 0.0057°로 100배 나빠진다
degC1온도. "deg"로 줄여 쓰지 않는다(각도와 혼동)
미선언 (m · % · V · A · ms)4미터 기준 0.1mm
raw_status · extension—제외. SDK 원본·자유 슬롯은 우리 차원이 아니다

Figure 6. 와이어 자릿수 정책(units.WIRE_DECIMALS, 적용은 발행 경계 ②). float64는 17자리를 표현하지만 측정 정밀도는 그 근처도 아니다 — SE2 합성·math.degrees·쿼터니언 변환이 남긴 부동소수 꼬리가(-33.33006134969326) 그대로 와이어에 실려 로그와 콕핏에서 사람에게 읽혔다. 자릿수는 취향이 아니라 물리량이라 단위별로 정한다. 목적은 거짓 정밀도 제거이지 대역폭이 아니다(실측 −1.6%).

Flow Observability Contract · 기본 꺼짐

흐름 추적 계약 — 정의만 한다

한 명령이 운영자 → NEXUS → AND → base → sub → 메서드를 지나는 동안 어디서 무엇이 되었는지 지금은 아무도 한 화면에서 못 본다.조각은 이미 전부 와이어에 실려 다닌다(uuid·sender/receiver·timestamp·유한 열거된 토픽) — 없던 것은 그 조각을 같은 이름으로 주워 담을 그릇 하나뿐이었다.

synapse/trace.py는 그릇과 정책만 둔다 — 이 모듈에는 실행 함수가 없다. hop을 만드는 쪽은 GERRI/AND, 접는 쪽은 NEXUS다.

동결 레이어를 여는 근거는 의존 방향이다 — 만드는 쪽(gerri)과 소비하는 쪽(nexus)이 둘 다 Python이고 서로의 구현은 import하지 않는다. synapse에 두지 않으면 같은 모델이 빌더와 파서에 손으로 복제되고 드리프트한다. (대조군: /api/alignments는 만드는 쪽이 nexus_ui(JS)라 synapse를 import할 수 없어서 NEXUS가 소유한다.) HTTP가 아니라 토픽인 이유는 기존 AND 전송 추상화를 그대로 쓰기 때문이다 — 덤으로 NEXUS 없는 콕핏 P2P(1:1)에서도 hop이 흐른다.

TraceHop 필드타입의미
atfloat이 hop이 관측된 시각(time.time())
nodestr참여자 — operator:{id} · nexus · robot:{id}/and · robot:{id}/base · robot:{id}/{sub_id}. 발행측·집계측이 같은 문법을 써야 그래프가 하나로 접힌다.
directionstr"in"(받음) · "out"(보냄)
topicstr표준 토픽 중 하나(trace.hop 자신은 제외)
uuidstrmsg_info.uuid — 이것으로 end-to-end를 꿴다. 틱 hop은 빈 문자열
capabilitystr | Nonecommand.capability면 어느 메서드로 갔나
normalizedbool | None표준 정규화(standardize)를 거쳤나
dropped_fieldslist[str]정규화가 떨군 필드 경로 — 이 계약의 숨은 값이다(“내 필드가 왜 콘솔에 안 보이지?”에 답한다)
latency_msfloat | None같은 uuid의 직전 hop 대비 지연. 첫 hop이면 None, 틱이면 창 평균
countint이 hop이 대표하는 메시지 수 — 건별 1, 틱이면 그 창의 건수

Figure 7. TraceHop — 메시지가 참여자 하나를 지나간 사건 1건. 전송 단위는 TraceBatch(hops: list)로, hop을 status에 얹지 않고 1초 주기 저빈도 배치로 따로 보낸다. 관용(Postel) 원칙으로 direction 등을 Literal로 좁히지 않고 extra="allow"를 둔다 — 오타 하나로 배치가 통째 떨어지는 것보다 이상한 값이 보이는 편이 낫다(관측 데이터로 프로세스를 죽이지 않는다).

관측이 대상을 바꾸면 안 된다 — 정책을 미리 고정한다: 표준 토픽을 빠짐없이 둘로 분류해 둔다 — 건별(command.capability·mission.trigger·mission.event·help.action)은 메시지마다 hop 1건, 틱(robot.status·command.teleop)은 1초 창마다 요약 hop 1건이다. ⚠️ 새 외부 토픽이 생기면 둘 중 하나에 반드시 넣는다 — 안 넣으면 발행측이 “정책 없음”을 “추적 안 함”으로 읽어 조용히 누락된다(테스트가 잡는다). trace.hop 자신은 어디에도 넣지 않는다 — hop이 hop을 낳는 무한 증폭 차단.
페이로드는 어느 정책에도 없다: 계약에 payload 필드를 두지 않은 것이 설계 결정이다 — ① 사용자 지침이 “토픽 정도만”이고 ② 페이로드를 실으면 관측 트래픽이 대상을 넘어서며 ③ 로봇 내부 원본은 로컬 RMI에서 서버 왕복 없이 본다.
기본 꺼짐이 1번 제약이다: robot.config.json의 trace 섹션(enabled·batch_seconds·tick_seconds)으로만 켜진다 — CLI 플래그를 늘리지 않는다. 켜지 않으면 trace.hop 메시지는 한 건도 나가지 않고, 기존 발행 경로의 바이트가 달라지지 않는다. robot_state.network(지연 측정)와는 완전히 별개 축이다.
Mission Model & Sync

미션 모델 · 시간 동기화

missions.py는 미션 자료구조와 간결 헬퍼를, time_sync.py는 NTP식 오프셋 동기화를 정의한다.

Task 5종 · 미션 헬퍼 6종

capability wait_input wait_teleop notify func
개발자가 작성하는 미션 정의
def delivery_5f_to_1f() -> RobotMission:
    return RobotMission(name="delivery", tasks=[
        cap("undock", "undock"),
        cap("go", "navigate_to_pose",
            x=1.0, y=2.0),
        wait_input("confirm", prompt="도착했나요?"),
        cap("dock", "dock"),
        notify("done", "배달 완료"),
    ])

시간 동기화 · 미션 위임 구조

TimeSync — NTP식 4-타임스탬프: TimeSyncMaster(NEXUS) / TimeSyncSlave(TAG)가 4개의 타임스탬프로 offset과 RTT를 NTP식으로 동기화한다.
미션 위임 구조: FleetMission(다중 로봇 DAG, depends_on) → RobotMission(단일 로봇 Task 시퀀스) → Task(5종). 헬퍼 cap·wait_input·wait_teleop·notify·func·call로 간결하게 정의한다.
Fleet 자료구조: FleetMission{tasks: dict[str, FleetTask]}, FleetTask{robot, mission, depends_on, policy}, ExecutionPolicy.on_failure = stop · continue · retry · help(사람 개입 요청 → NEED_HELP/TELEOP, 무응답 timeout 시 FAILED 폴백).
Task.options_key — 선택지를 런타임에: 미션 카탈로그는 기동 시 1회 만들어지므로 목록을 buttons에 박으면 그 시점에 얼어붙는다 — 티칭 파일 목록·웨이포인트처럼 그 사이 늘어나는 것들이 문제였다. options_key="teaching_files"로 선언하면 base가 robot_info의 그 키(배열)를 읽어 채운다.
cap(name, capability, params) — dict 형이 필요했던 이유: 키워드로만 받으면 name이라는 파라미터를 가진 capability를 표현할 수 없다 — 첫 인자가 task 이름이라 cap("저장", "joint_preset", name="ready")는 TypeError다. 표준 카탈로그에 이미 joint_preset(name)·navigate_to_waypoint(name)가 있으므로 실제 구멍이었다 → cap("복귀", "joint_preset", {"name": "ready"}). 두 형식은 섞어 쓸 수 있다(dict가 먼저, kw가 덮음).
← 홈으로 다음: TOM →