Adaptive Network Daemon
tom_and_gerri 레포의 _and_/ 모듈이다.로봇 내부의 in-process 버스(pypubsub)와 외부 전송 백엔드를 잇는 얇은 어댑터 데몬으로, 제어 로직은 없고 전달만 한다.
AND는 “로봇이 바깥과 어떻게 통신하는가”를 한 곳에 가둔다.GERRI(제어 SW)는 전송 수단을 전혀 모른 채 표준 토픽으로만 말하고, AND가 그것을 실제 네트워크로 옮긴다.
Adaptive Network Daemon — synapse 내부 버스와 외부 transport 백엔드를 잇는 레이어다.
AND는 두 조각으로 나뉜다. 브리지(daemon.py의 AdaptiveNetworkDaemon)는 synapse 토픽 Topic.Network.SEND/Topic.Network.RECV와 백엔드를 잇고, 백엔드(backend.py의 NetworkBackend 인터페이스 + 구현)는 실제 전송 수단을 담당한다 — WebRTC(rtc2kng), nexus 직결 WS, rubberneck, 테스트용 loopback 등.
내부 버스는 pypubsub(in-process·동기 디스패치)이다. 문서는 오랫동안 브리지를 "제어 로직 없음 — 전달만"이라 적어놨지만, pub.sendMessage가 동기라 전달이 곧 실행이었다. 수신 쪽은 백엔드의 WS/asyncio 루프 스레드가 capability를 끝까지 떠안았고(IK 격자탐색 실측 604~800ms·SAM·VLM 수 초) — 현장에서 "누르면 바로 끊겨"로 나타났다. 송신 쪽은 backend.send가 발행 스레드에서 돌아 "누르면 잠시 멈췄다 진행해"였다.
그래서 RX/TX를 독립 워커 스레드로 끊었다(전 로봇 기본 ON). 데몬당 데몬 스레드 2개 — and-rx-<tag>(RX 큐 → Network.RECV, 단일 워커라 FIFO 순서 보존)와 and-tx-<tag>(TX 큐 → backend.send)를 connect()가 띄우고 disconnect()가 접는다. 제리에 무엇을 구현하든 송수신은 영향받지 않는다 — 종전에 ai_chat 하나를 스레드로 뺐던 것을 브리지 한 곳에서 전역으로 해결한 셈이다.
_handle_for_self 계약은 불변이다.AND_RX_QUEUE·AND_TX_QUEUE·AND_*_FULL_WAIT.PITFALL SUB-4)은 capability가 “수신 루프 스레드에서 동기 실행”을 전제하는데, 그 스레드는 and-rx-<tag>로 관제망(피어)마다 하나다 — 오퍼레이터가 2명이면 RX 스레드도 2개고 같은 sub의 capability가 동시에 들어온다. 직렬화하는 장치가 아무 데도 없었다(실측: 락 없이 6스레드가 같은 capability를 동시에 돌렸다). 지금은 GERRI base의 _capability_lock(RLock)이 디스패치를 묶는다 — ⛔ STOP은 그 락을 타지 않는다(GERRI 참조)._log_raw(이미 전 송수신이 지나는 단일 초크포인트)가 TX/RX hop을 노드 robot:{id}/and로 기록한다 — 새 훅을 만들지 않았다. 기본 꺼짐이고 꺼진 상태의 와이어는 이 기능이 없던 때와 바이트 하나도 다르지 않다(발행기 · 집계).disconnect()는 남은 송신을 배출하고(마지막 status 유실 방지) 수신은 폐기한 뒤 접는다. 교차오염 방어 deepcopy는 enqueue 시점으로 못박았다 — 워커에서 뜨면 큐 대기시간만큼 창이 넓어져 방어가 무의미해진다. 회귀망은 상수 비교가 아니라 거동으로 짰다 — 느린 핸들러·느린 send를 실제로 물리고 반환 시간을 잰다(13개).Base(게이트웨이)가 Topic.Network.SEND로 내보낸 메시지는 backend.send()로 흘러 나가고, 백엔드가 외부에서 받은 메시지는 Topic.Network.RECV로 다시 올라온다.
Topic.Network.SENDTopic.Network.RECV → BaseFigure 1. AND 브리지 양방향 배선. 송신: Base가 Network.SEND 발행 → TX 큐 → 워커가 backend.send() 호출. 수신: backend가 외부 메시지 수신 → RX 큐 → 워커가 Network.RECV로 재발행. 내용은 변환·검증 없는 pass-through이고, 달라진 것은 어느 스레드에서 실행되는가뿐이다(STOP만 인라인).
| 방향 | 흐름 |
|---|---|
| 송신 | Base가 Topic.Network.SEND로 발행 → 브리지가 구독하고 있다가 TX 큐에 넣고 즉시 반환 → and-tx 워커가 backend.send(message) 호출 |
| 수신 | backend가 외부 메시지 수신 → 주입된 콜백이 RX 큐에 넣고 즉시 반환 → and-rx 워커가 Topic.Network.RECV로 재발행 → Base가 받아 처리 |
| STOP | 큐 우회 · 인라인 발행 + 밀린 비-STOP 명령 purge |
connect()/disconnect()는 전송 계층의 수립·정리를 백엔드에 위임한다(그리고 워커 2개를 띄우고 접는다).이것이 AND 존재 이유다.GERRI는 오직 Topic.Network.SEND/RECV하고만 대화하므로, 전송 백엔드를 WebRTC에서 다른 것으로 교체해도 GERRI 제어 로직은 한 줄도 바뀌지 않는다.
NetworkBackend 인터페이스(connect/send/disconnect + _deliver)를 따른다 — 이미 여러 실 백엔드가 공존한다(제어 WS·미디어·로컬 웹 UI, WebRTC는 옵션). config-driven networks.py의 kind는 nexus·rubberneck·rmi.Figure 2. 전송 독립. 백엔드만 갈아끼우면 된다(브리지·제어 코드는 불변 — 클린 아키텍처). 제어(컨트롤플레인)는 NexusWsBackend(nexus 직결 WS)가 기본이고, 미디어(영상)는 rubberneck(ketirtc)/WebRTC가 맡는다. robot-first WebRTC(rtc2kng)는 WebRTC 옵션 백엔드로 두고, 로컬 웹 UI(RMI)도 하나의 관제망이 된다. 단위 테스트는 실제 네트워크 없는 LoopbackBackend로 대체한다.
인터페이스 계약은 단순하다. 구현체는 connect()·send(message)를 채우고, 외부에서 메시지를 받으면 부모가 제공하는 self._deliver(message)를 호출해 브리지로 올린다. disconnect()는 추상 강제가 아닌 선택(no-op 기본) — 연결·전송은 필수, 정리는 선택이라는 의도적 비대칭이다.
백엔드는 '갈아끼우는' 것에 그치지 않는다. 신규 _and_/networks.py는 한 로봇이 여러 관제망에 동시에 접속하게 한다 — 데몬을 망마다 하나씩 세우면, 브리지가 pypubsub로 Network.SEND를 구독하는 성질 그대로 송신은 전 망으로 팬아웃, 수신은 어느 망에서 오든 Network.RECV로 병합된다.
AdaptiveNetworkDaemonkind: "nexus"kind: "rubberneck"kind: "rmi"Figure 3. 동시 다중 접속. config의 "networks" 리스트가 데몬 N개를 만들고(kind = nexus·rubberneck·rmi), 각 데몬이 같은 내부 버스에 붙어 자동으로 팬아웃·병합한다. (rtc2kng는 이 config 경로에 없다 — 별도 WebRTC 옵션.)
"networks": [
{"kind": "nexus", "server": "43.202.61.88:8000"},
{"kind": "rubberneck", "url": "https://rubberneck.kr"},
{"kind": "rmi", "port": 7712}
]
_and_/rtc2kng/는 WebRTC 옵션 백엔드다 — 제어 기본값은 nexus 직결 WS(NexusWsBackend)이고, 이쪽은 P2P 저지연 경로가 필요할 때 고른다.로봇이 부팅 시 시그널링에 합류하고, operator의 ready를 받으면 로봇이 offer를 주도한다.단일 asyncio 루프(데몬 스레드)에서 1:N operator 연결을 관리한다.
"synapse")로 표준 메시지(JSON) 교환. ice_servers를 주면 RTCConfiguration으로 넘겨 TURN 릴레이를 쓰고, 없으면 aiortc 기본 STUN.NetworkBackend 구현. robot-first, 여러 operator와 동시 1:N. asyncio 루프 스레드에서 시그널링·peer 구동. 연결을 만들 때마다 nexus /api/ice를 재조회한다 — coturn 시간제한 크리덴셜은 만료가 있어 캐시할 수 없다(실패 시 기본 STUN 폴백).get_frame()의 BGR ndarray를 av.VideoFrame(bgr24)으로 감싸 aiortc 트랙에 addTrack한다. 경로가 둘이고 SYNAPSE_VIDEO_ENCODE로 고른다 — raw(기본·실기 검증) / h264(한 번만 인코딩하고 av.Packet을 나눠 준다).CameraSource 1개 — 한 장치를 딱 한 번 열고(캡처 1회), rtc2kng·rubberneck 등 여러 전송 소비자가 같은 인스턴스를 받아 구독한다(같은 장치 이중 open=device busy 방지, 소유권은 여는 쪽=러너). 제어 버스에 고빈도 프레임을 싣지 않는다 — 영상은 미디어 경로(WebRTC 트랙)로 흐르고, Network.SEND/RECV 제어 채널에는 status·명령 같은 저빈도 JSON만 오간다.
카메라·마이크가 관제에 닿는 길은 둘이며, 데이터·제어(NexusWsBackend의 WebSocket JSON)와는 별개 레이어다. 온보딩에서 "rubberneck엔 뜨는데 콕핏엔 안 뜬다"로 헤매는 지점이라 설계에 명문화했다.
{robot_id}-cam peer로 합류하고, operator가 1:1 콕핏에 들어오면 WebRTC로 직접 받는다. 러너 config의 camera(또는 --camera)가 있으면 켜진다. ICE는 nexus /api/ice(STUN/TURN·coturn).rubberneck.kr로 올리고 그쪽 뷰어/operator가 받는다. networks의 rubberneck 항목(video_info/audio_info·api_key)으로 켜진다 — 서버를 거치므로 P2P보다 지연이 있지만 망 제약에 강하다.Figure 4. robot-first 핸드셰이크. 시그널링이 끝나면 미디어·제어는 P2P로 직결되고 서버는 데이터 흐름에서 빠진다. aiortc는 non-trickle이라 로봇 ICE 후보를 offer SDP에 한 번에 담는다.
audio_device=None(미사용·기본)과 ""(기본 장치)를 구분하고, 이름을 줬는데 못 찾으면 실패한다 — 기본 장치로 떨어지면 지정한 게 아닌 소리가 나간다.MediaRelay를 걷어냈다 — SIGSEGV의 근본 원인이었다: aiortc는 인코딩을 스레드풀에서 하는데(run_in_executor(…, encoder.encode, …)) MediaRelay는 구독자들에게 같은 av.VideoFrame 객체를 넘긴다 — proxy는 트랙을 복제하지 프레임을 복제하지 않는다. ⇒ 오퍼레이터 2명 = 인코더 2개 = 두 스레드가 같은 AVFrame을 동시에 만지고, 프로세스가 예외 없이 죽었다. 격리 증명(브라우저·NEXUS 없이 결정적 재현 — 실 D415 + 실제 VP8 인코더 2개): 같은 프레임 공유 → Segmentation fault(exit 139) · 인코더마다 자기 프레임 → 완주 8,437프레임 × 2. ⚠️ 그리고 종전 주석의 전제가 틀렸다 — “relay를 쓰면 인코딩 비용이 오퍼레이터 수와 무관해진다”고 적었지만 raw 경로에서 인코딩은 오퍼레이터마다 1회다(RTCRtpSender가 각자 인코더를 갖는다). relay가 줄여 준 것은 프레임 생산이지 인코딩이 아니었다 — 측정 없이 쓴 문장이 설계를 끌고 갔다.h264: aiortc rtcrtpsender는 두 갈래인데 우리는 왼쪽만 썼다 — 트랙이 VideoFrame 대신 av.Packet을 내면 인코딩을 건너뛰고 패킷타이즈만 한다(encoder.pack()). 그 길을 열어 공유 인코더 1개 · 트랙 N개로 만들었다. 실측(D415 + 오퍼레이터 5명 동시, x86 RTX5090): 인코딩이 오퍼레이터 수와 무관해지고 h264_nvenc 하드웨어 인코딩 · CPU 14.5% · 예외 0 · 큐 포화 0 · 폴백 0. 프레임당 2.17ms × N(VP8 소프트웨어) → 0.31ms × 1이고, ndarray→AVFrame 복사도 N회 → 1회다(4K면 복사만 3.64ms/구독자). ★ h264 경로에는 위 SIGSEGV 위험이 원천적으로 없다 — pack()은 스레드풀이 아니라 이벤트 루프에서 직렬로 불리고 맨 처음 bytes(packet)으로 복사한다. ⚠️ 기본값은 아직 raw다 — SYNAPSE_VIDEO_ENCODE=h264로만 켜지고, 재접속·대역열화·장시간 실기 검증이 끝난 뒤에 기본값을 뒤집고 raw를 지운다.nvv4l2h264enc: x86 방식이 Orin에서 안 된다. pyav h264_nvenc는 Tegra에서 열리지 않는다 — Tegra에는 데스크톱 NVENC 진입점 libnvidia-encode.so 자체가 없고(NVIDIA 공식 답변: Jetpack에서 ffmpeg 하드웨어 디코딩만 지원), Orin의 NVENC는 V4L2 노드로만 노출되어 정식 접근로가 GStreamer다. is_jetson()(device-tree 판별) → appsrc → nvvidconv(NVMM) → nvv4l2h264enc → h264parse → appsink 파이프라인이 pyav CodecContext와 같은 계약(.encode(frame) → [Packet])을 흉내 내므로 공유 인코더는 둘을 구분하지 않는다. 후보 순서는 Jetson이면 GStreamer 먼저, 그 외엔 nvenc 먼저, 마지막은 항상 libx264다. ⚠️ Jetson인데 GStreamer를 못 열면 경고로 남긴다 — 조용히 넘어가면 “Orin인데 왜 CPU를 먹지”를 아무도 못 찾는다. 파이프라인 필수 조건은 테스트로 못 박았다(num-B-Frames=0 — WebRTC는 B프레임 H.264를 거부한다 등)._read() 뒤에 무조건 sleep(interval)했는데, 그건 _read가 공짜라는 가정이다 — 블로킹 백엔드에선 거짓이라(RealSense wait_for_frames()) 한 주기를 기다린 뒤 또 한 주기를 잤다. 실측(D415 640×480@30): 처리율 이득은 작았지만(28.4 → 29.8Hz) 프레임 나이 p95가 31.7 → 21.6ms(−32%)다 — 종전엔 20프레임 중 1개를 한 주기 통째로 낡은 채 넘겼다. "프레임률이 절반 된다"는 앞선 추측은 실측으로 반증됐다. 프레임에 seq·age 신원을 붙여 소비자가 낡음을 판정할 수 있게 했다.AND는 전달만 하는 데 그치지 않고, 각 통신 링크의 왕복 지연(RTT)을 실측해 robot.status.network에 자동으로 채운다. 로봇별 코드 없이 synapse의 NetworkState 계약이 충족된다.
TimeSyncSlave 4-타임스탬프)으로 서버 RTT + 시간 offset 측정. keepalive 겸용.1.1.1.1:53 · 8.8.8.8:53 · 8.8.8.8:443)이고, 첫 성공 앵커의 RTT를 쓰되 전부 실패해야 끊김으로 본다 — 단일 앵커는 특정 IP만 막는 방화벽에서 오탐이었다(실측: 내부망이 1.1.1.1만 차단).operator_id별로 따로 들고 있다.Figure 5. 3-링크 지연 실측. NetworkState의 internet·server·operator 각 Link{is_connected, latency_ms, packet_loss}를 AND가 채운다 — 관제 UI는 로봇마다 신호 막대로 이 값을 표시한다.
extension.operators: robot_state.network.operator는 슬롯 하나라 오퍼레이터가 2명이면 두 콕핏이 같은 숫자를 본다 — 원격 PC가 로컬의 0.3ms를 자기 지연으로 보고 있었다(사용자 보고). 게다가 ping 루프가 첫 열린 채널에서 break해 둘째부터는 ping을 아예 못 받았다 — 주석이 근거로 적어둔 “1:1이 기본” 전제가 깨진 지 오래였다. 지금은 operator_id별 레지스트리를 extension.operators로 싣는다 — 표준은 건드리지 않는다(확장 슬롯 규약). 비어 있으면 키를 아예 만들지 않고({}를 매 틱 보낼 이유가 없다), 끊긴 오퍼레이터는 지운다 — 이 목록은 현재 접속자를 뜻하므로 is_connected=false로 남기면 “몇 명이 보고 있나”를 못 센다. 표준 슬롯에는 최악 링크를 싣는다 — 한 숫자만 실을 수 있다면 평균이 아니라 최악이어야 한다. 제일 늦은 오퍼레이터가 체감 품질을 정하고 평균은 그 사람을 숨긴다.AND/외부 토픽에 손댈 수 있는 것은 오직 Base(게이트웨이) 하나뿐이다.SubController는 외부 통신을 전혀 몰라야 한다.
Topic.Network.SEND/RECV 등 외부 토픽을 직접 사용하는 것이 금지된다.Sub는 자기 status를 내부 토픽으로만 발행하고, 외부와의 모든 송수신은 Base가 단일 창구로 중계한다.이렇게 해야 전송 교체가 Sub에 전혀 영향을 주지 않고, AND의 “전달만 하는 데몬” 경계가 유지된다.즉 의존 방향은 Sub → (내부 토픽) → Base → (Network.SEND/RECV) → AND → backend → 네트워크 한 방향으로 흐른다. Sub가 이 규칙을 어기고 외부 토픽을 직접 건드리면 게이트웨이 정책이 깨지고, 백엔드 교체 시 Sub 코드까지 수정해야 하는 결합이 생긴다.