이기종 멀티로봇 플랫폼의 가장 안쪽 표준 레이어로, Topics·표준 메시지 6종·Enum 카탈로그·Pydantic v2 자료구조·미션 모델·흐름 추적 계약·standardize()를 한 곳에 모은다.
철학은 단 하나, "정의만 한다"는 것이다.
실행 로직은 바깥(tom_and_gerri·nexus)이 가지며, 여기를 바꾸면 전체가 흔들리므로 가장 신중히 관리한다.
외부로 흐르는 모든 통신은 정확히 6개의 토픽으로 끝난다. 토픽은 절대 raw 문자열로 쓰지 않고 항상 Topic.* 상수를 쓴다.
Figure 1. 공통 메시지 봉투. msg_info(timestamp·uuid·sender·receiver) + topic + value 세 부분으로 고정된다. uuid는 JS 호환을 위해 uuid4의 문자열로 직렬화한다.
(robot_id, sub_id)로 upsert — 1:N 로봇 대응.name + args 구조. 미션 시작, wait_input·wait_teleop 해제 등에 사용.build_trigger(sender, name, args=None)value.name = capability 이름, value.params = 인자. base가 getattr(sub, name)(**params)로 디스패치한다.command.{capability} 동적 토픽은 만들지 않는다(무한 폭발 방지).axes · buttons · poses 세 채널을 함께 담는다. 조이패드와 VR을 하나의 표준으로 흡수.build_joy() / build_vr()는 이 위에 얹힌 편의 빌더일 뿐, 별도 토픽이 아니다.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종).모든 로봇이 같은 형태로 보고한다. 핵심 설계 원칙은 차원 분리로, SDK 원본·시스템 종합·미션 진행을 절대 한 칸에 섞지 않는다.
| 필드 | 타입 · 설명 |
|---|---|
| robot_info — 정적 메타 | |
| robot_id | str |
| sub_id | str — 1:N 대응 키 |
| robot_type | str (mobile·manipulator…) |
| robot_shape | dict |
| capability | {common[], custom[]} |
| version | dict | None — {repo_name: semver} 배포 버전 보고 |
| angle_unit | "deg"(기본·권장) | "rad" — 이 로봇이 보내는 각도의 단위 선언 |
| extension | dict — 로봇별 정적 자기기술(프리셋·파트·작업평면·폼 명세) |
| robot_state — 동적 상태 | |
| base_state | BaseState — 시스템 종합(파생) |
| pose | Pose | Pose3D |
| velocity | Twist |
| battery_state | Battery — percentage · is_charging |
| joint_state | Joint |
| network | NetworkState |
| raw_status | dict — SDK 원본(분리!) |
| extension | dict — 로봇별 자유 확장 |
| mission_info — 미션 진행 | |
| mission_state | MissionState |
| mission_name | str |
| current_task | str |
| available_missions | str[] |
percentage·is_charging(그 외 voltage·current·temperature). level/charging이 아니다.robot_info.extension은 정적 자기기술(연결 시·변경 시에만 바뀜), robot_state.extension은 동적 상태다.extra="allow"라 최상위에 실어도 통과는 하지만 세 가지가 깨진다 — 키 소멸 불가(registry는 키 단위 merge·툼스톤은 블록 단위), 각도 정규화 우회(to_standard_angles는 robot_state만 접으므로 rad 선언 로봇이 최상위에 각도를 실으면 조용한 57.3배), static/dynamic 혼동. 실측으로 실기 한 대가 비표준 키 15개를 최상위에 싣고 있었다.robot_state.extension.settingsrobot_info에 쓰면, 생략 판정이 블록 전체의 digest라 키 하나만 바뀌어도 robot_info가 통째로 재전송된다 — 실측 ≈130배 증폭. robot_state는 어차피 매 틱 나가므로 거기 두면 증폭이 사라진다. ⚠️ 빈 값은 None이 아니라 []·false·0으로 말한다.Figure 2. robot.status 스키마와 차원 분리 원칙. RobotState의 모든 필드는 optional이며, 누락분은 standardize()가 워닝과 함께 흘려보낸다.
모든 상태·이벤트·액션은 문자열 직접 사용 없이 이 Enum 들 중 하나로 표현한다 — 핵심 6종(base/mission/이벤트/개입/capability)에 플릿 상태 3종이 더해진다.
derive_base_state().enums.py에 함께 정의되지만 소유·발행은 NEXUS의 FleetMissionRunner(run_view)다. 사유 코드는 제3 UI(Unity 등)까지 기계판독 가능하게 표준화한 것.teleop_mapping.py의 IntEnum 들은 6종 핵심 Enum이 아니라 command.teleop 페이로드의 채널 인덱스 도우미다. 카운트는 axes 8 · buttons 16 · poses 3이다.
Figure 3. 텔레옵 채널 인덱스. synapse는 인덱스 표준만 정의하고 해석·핸들러는 tom_and_gerri가 맡는다 — 어떤 입력 장치든 같은 command.teleop로 흘려보낼 수 있다.
모든 로봇이 같은 이름·같은 시그니처를 쓰며, enum value가 곧 메서드 이름이고 base는 getattr(sub, name)(**params)로 디스패치한다.
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면 거부한다.개발자가 실제로 import해서 쓰는 골격 — Result, StatusBuilder, standardize(), Topic.
Result는 capability가 반환하는 표준 @dataclass(Pydantic 아님)로, 라이브러리가 정의하고 사용자는 반환만 한다. 필드는 success(bool) · data(dict) · message(str) · error(str|None)이며, Result.ok(message="", **data) 또는 Result.fail(error, message="")로 생성한다.StatusBuilder(robot_id).build(robot_state, mission_info, robot_info)가 status 메시지 dict를 만든다. 각 입력에 standardize()를 자동 적용하고 robot_id를 보장하며, 누락 필드는 워닝으로 흘려보낸다.normalize(data)는 dict·Pydantic·dataclass·None 등 무엇이든 dict로 바꾼다(never raise). standardize(data, model_class)는 normalize 후 model_validate를 시도하고, 실패하면 문제 필드만 떼어 재검증해 (dict, warnings)를 반환한다 — 절대 예외를 던지지 않는다.Topic.* 상수로 접근한다(예: Topic.Command.CAPABILITY = "command.capability").| Topic.* 상수 | 문자열 값 |
|---|---|
| Topic.Robot.STATUS | robot.status |
| Topic.Mission.EVENT | mission.event |
| Topic.Mission.TRIGGER | mission.trigger |
| Topic.Command.CAPABILITY | command.capability |
| Topic.Command.TELEOP | command.teleop |
| Topic.Help.ACTION | help.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}로 구성된다.
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)으로 올린다.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는 레포별로 독립이다.2026-07-27, 표준 각도가 rad → deg로 바뀌었다. 워크스페이스 전 레포가 0.6.0으로 동시에 올라간 것이 그 경계다 — < 0.6은 rad 시대, >= 0.6은 deg 시대다. 태도는 언제나처럼 Postel이다 — 표준은 하나로 엄격하되 들어오는 것은 너그럽게 받는다.
| 층 | 단위 | 누가 책임지나 |
|---|---|---|
| 로봇 내부 | rad 또는 deg | 자유다. ROS·pinocchio·벤더 SDK가 rad 네이티브라 그게 자연스러운 로봇이 있다 |
| 발행 경계 ① | → deg | SubControllerCommon.get_status() — angle_unit 선언을 보고 각도를 접는다 |
| 발행 경계 ② | → deg | BaseControllerCommon._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 미경유) 수신 경계에만 두면 그 경로가 통째로 빠진다.
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)으로 맞춰 송출하는 것이다.standardize()가 rad 리터럴(π 배수)과 크기 밴드(비영값이 전부 |v|<2π)를 경고한다.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곳을 원문에서 확인).Figure 6. 와이어 자릿수 정책(units.WIRE_DECIMALS, 적용은 발행 경계 ②). float64는 17자리를 표현하지만 측정 정밀도는 그 근처도 아니다 — SE2 합성·math.degrees·쿼터니언 변환이 남긴 부동소수 꼬리가(-33.33006134969326) 그대로 와이어에 실려 로그와 콕핏에서 사람에게 읽혔다. 자릿수는 취향이 아니라 물리량이라 단위별로 정한다. 목적은 거짓 정밀도 제거이지 대역폭이 아니다(실측 −1.6%).
한 명령이 운영자 → 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 필드 | 타입 | 의미 |
|---|---|---|
| at | float | 이 hop이 관측된 시각(time.time()) |
| node | str | 참여자 — operator:{id} · nexus · robot:{id}/and · robot:{id}/base · robot:{id}/{sub_id}. 발행측·집계측이 같은 문법을 써야 그래프가 하나로 접힌다. |
| direction | str | "in"(받음) · "out"(보냄) |
| topic | str | 표준 토픽 중 하나(trace.hop 자신은 제외) |
| uuid | str | msg_info.uuid — 이것으로 end-to-end를 꿴다. 틱 hop은 빈 문자열 |
| capability | str | None | command.capability면 어느 메서드로 갔나 |
| normalized | bool | None | 표준 정규화(standardize)를 거쳤나 |
| dropped_fields | list[str] | 정규화가 떨군 필드 경로 — 이 계약의 숨은 값이다(“내 필드가 왜 콘솔에 안 보이지?”에 답한다) |
| latency_ms | float | None | 같은 uuid의 직전 hop 대비 지연. 첫 hop이면 None, 틱이면 창 평균 |
| count | int | 이 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에서 서버 왕복 없이 본다.robot.config.json의 trace 섹션(enabled·batch_seconds·tick_seconds)으로만 켜진다 — CLI 플래그를 늘리지 않는다. 켜지 않으면 trace.hop 메시지는 한 건도 나가지 않고, 기존 발행 경로의 바이트가 달라지지 않는다. robot_state.network(지연 측정)와는 완전히 별개 축이다.missions.py는 미션 자료구조와 간결 헬퍼를, time_sync.py는 NTP식 오프셋 동기화를 정의한다.
Task 5종 · 미션 헬퍼 6종
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", "배달 완료"),
])
시간 동기화 · 미션 위임 구조
TimeSyncMaster(NEXUS) / TimeSyncSlave(TAG)가 4개의 타임스탬프로 offset과 RTT를 NTP식으로 동기화한다.FleetMission(다중 로봇 DAG, depends_on) → RobotMission(단일 로봇 Task 시퀀스) → Task(5종). 헬퍼 cap·wait_input·wait_teleop·notify·func·call로 간결하게 정의한다.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가 덮음).