From 0eee3af6d52ac9450c904a8cbc3884842d8f40d9 Mon Sep 17 00:00:00 2001 From: PythonToGo Date: Sat, 11 Jul 2026 13:29:02 +0200 Subject: [PATCH 1/4] =?UTF-8?q?[E.17]=20Nav2=20=ED=94=84=EB=A6=AC=EC=85=8B?= =?UTF-8?q?=20+=20=ED=94=84=EB=A6=AC=EC=85=8B=EB=B3=84=20=EB=B8=8C?= =?UTF-8?q?=EB=A6=AC=EC=A7=80=20=EC=8A=A4=EC=BD=94=ED=95=91=20=E2=80=94=20?= =?UTF-8?q?=EB=A7=A5=EC=97=90=EC=84=9C=20/navigate=5Fto=5Fpose?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `rosmac sim nav2-diffbot`: Gazebo diffbot + gpu_lidar + slam_toolbox + Nav2 (벽 아레나), 맥에서 /navigate_to_pose goal 3/3 SUCCEEDED. 97 tests, ruff/mypy clean. 프리셋별 브리지 스코핑 (KI-30 대응): - Preset.bridge_allow (6 allow 카테고리). None이면 무스코프(기존 소형 프리셋 유지). - bridge_scope_config(): 미지정 카테고리 빈목록(deny) 명시로 "전부 허용" 누출 차단, doctor C8 왕복 토픽 /rosmac/doctor/* 상시 주입. - sim start: config·systemd 드롭인을 파일 push(셸 이스케이프 회피) → VM 브리지 스코핑 재기동 + 맥 브리지 리셋. sim stop: 드롭인 제거 → 무스코프 복원(멱등). 자산: nav2-diffbot.yaml(+bridge_allow), nav2_world.sdf(gpu_lidar+벽 아레나), nav2-diffbot.launch.py(gz+bridge+static_tf+slam+nav2 단계 기동), slam_params.yaml, layouts/nav2.json. viz --layout nav2. 유닛 4종 추가. 문서: README(en/ko) 프리셋·능력 매트릭스·구조적 한계, workflow(en/ko) Nav2 절, CHANGELOG. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Usk65wXFwXBq6UgRxGn7Yi --- CHANGELOG.md | 7 +- README.ko.md | 20 +- README.md | 20 +- docs/workflow.ko.md | 20 + docs/workflow.md | 20 + src/rosmac/assets/layouts/nav2.json | 39 ++ src/rosmac/assets/presets/nav2-diffbot.yaml | 38 ++ .../nav2-diffbot/nav2-diffbot.launch.py | 99 +++ .../presets/nav2-diffbot/nav2_world.sdf | 606 ++++++++++++++++++ .../presets/nav2-diffbot/slam_params.yaml | 26 + src/rosmac/cli.py | 4 +- src/rosmac/sim.py | 99 ++- tests/unit/test_sim.py | 47 ++ 13 files changed, 1033 insertions(+), 12 deletions(-) create mode 100644 src/rosmac/assets/layouts/nav2.json create mode 100644 src/rosmac/assets/presets/nav2-diffbot.yaml create mode 100644 src/rosmac/assets/presets/nav2-diffbot/nav2-diffbot.launch.py create mode 100644 src/rosmac/assets/presets/nav2-diffbot/nav2_world.sdf create mode 100644 src/rosmac/assets/presets/nav2-diffbot/slam_params.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index 8db71d3..fca9c24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,7 @@ - 아키텍처: 맥 네이티브 개발(RoboStack) ↔ zenoh 브리지(TCP 7447) ↔ Lima VM Ubuntu 22.04 arm64(MoveIt·Gazebo) ↔ Foxglove(ws 8765), 양측 rmw_cyclonedds_cpp 고정 (D9) -- 함정 DB: 실측 known issues 28건 (`docs/plan/known-issues.md`) +- 함정 DB: 실측 known issues 30건 (`docs/plan/known-issues.md`) - 진단·제보: `doctor --fix`(안전 항목 자동 수리), `report`(진단 번들 tar.gz, `~/.rosmac` 밖 수집 금지) (Phase 5) - **실로봇 연결 (beta)**: `robot:` config 섹션 — 맥 브리지가 로봇 쪽 @@ -35,6 +35,11 @@ - 업그레이드 경로: 버전/sha 핀을 config에 동결하지 않음(커스텀 핀만 보존) + `up`/`init`이 브리지 바이너리 버전을 비교해 자동 갱신 — pip 업그레이드만으로 신 핀 반영 (E.7) +- **Nav2 프리셋 (`sim nav2-diffbot`)**: Gazebo diffbot + gpu_lidar + slam_toolbox + + Nav2, 맥에서 `/navigate_to_pose` goal 3/3 SUCCEEDED. 프리셋별 브리지 스코핑 + (`Preset.bridge_allow`) 도입 — 대형 스택(nav2 서비스 174개)이 맥 디스커버리를 + 포화시키는 문제(KI-30)를 `rosmac sim`이 VM 브리지를 필요한 인터페이스로 스코핑해 + 해결(`sim stop` 시 복원). `viz --layout nav2` (E.17) ### 실측 검증 (Phase 0/2/4 게이트) - 브리지 대역폭 10.3 MB/s (1MB@10Hz 무손실), MoveGroup 액션 왕복 3연속 SUCCEEDED diff --git a/README.ko.md b/README.ko.md index 9b76741..53997df 100644 --- a/README.ko.md +++ b/README.ko.md @@ -24,7 +24,7 @@ rosmac은 문제와 싸우는 대신 문제를 분할한다: 설치 스크립트 이상인 이유: - **`rosmac doctor`** — 알려진 고장 모드 16항 점검, `--fix`로 안전한 항목 자동 수리 - (hung ros2 데몬, 고아 브리지, 깨진 lima 포트 규칙). **실측 함정 29개 DB** 위에서 + (hung ros2 데몬, 고아 브리지, 깨진 lima 포트 규칙). **실측 함정 30개 DB** 위에서 만들어졌다. - **`rosmac deps`** — 워크스페이스 `package.xml` 의존성을 RoboStack conda 패키지로 매핑 (conda를 아는 rosdep 대체). @@ -73,10 +73,15 @@ rosmac shell -c 'ros2 topic echo /chatter --once' # 맥에서 VM 토픽 수신 ```bash rosmac sim panda-moveit # MoveIt(Panda 팔) — 맥에서 /move_action 사용 가능 rosmac sim gazebo-diffbot # Gazebo Fortress headless + 전방 카메라 +rosmac sim nav2-diffbot # Nav2 이동로봇 내비게이션 — 맥에서 /navigate_to_pose rosmac sim list / status / stop / --attach -rosmac viz --layout panda # Foxglove 연결 (+레이아웃 안내) +rosmac viz --layout nav2 # Foxglove 연결 (+레이아웃 안내) ``` +`nav2-diffbot`은 벽 아레나에서 라이다 diffbot에 SLAM+Nav2를 돌린다. `/cmd_vel`로 +주행해 지도를 만든 뒤 맥에서 `/navigate_to_pose` goal을 보낸다. Nav2 같은 대형 +스택은 브리지를 자기 인터페이스로 자동 스코핑한다 — 아래 능력 매트릭스 참조(KI-30). + 맥 네이티브 개발 루프와 예제(pick_demo)는 [docs/workflow.ko.md](docs/workflow.ko.md) 참조. ## 내 프로젝트 가져오기 @@ -119,6 +124,7 @@ exit code 규약: - 브리지 대역폭: 10.3 MB/s (1MB@10Hz 드랍 없음) - MoveGroup 액션 왕복: 플래닝+실행 goal 3연속 SUCCEEDED +- 맥에서 Nav2 `/navigate_to_pose`: goal 3연속 SUCCEEDED (스코프 브리지) - Gazebo Fortress headless RTF: 물리만 1.00 / 카메라(320x240@15Hz) 0.99 - 카메라 스트림: VM 14.4fps → 맥 14.4fps (무손실) @@ -130,13 +136,19 @@ exit code 규약: |---|---|---| | Topics | ✅ | 양방향 pub/sub; 10.3 MB/s @ 10Hz 무손실. 새 토픽의 첫 구독은 라우트 생성에 몇 초 소요 | | Services | ✅ | 핀된 CycloneDDS RMW 필수 — Fast DDS는 디스커버리는 되는데 모든 호출이 타임아웃 (KI-16; RMW를 핀한 이유) | -| Actions | ✅ | MoveGroup 플래닝+실행 goal 3/3 SUCCEEDED | +| Actions | ✅ | MoveGroup 플래닝+실행 goal 3/3 SUCCEEDED; 맥에서 Nav2 `/navigate_to_pose` 3/3 SUCCEEDED (브리지 스코핑 — 아래 참조) | | Parameters | ⚠️ 부분 | 원시 파라미터 서비스(`get/set_parameters` 등)는 `ros2 service call`로 동작; `ros2 param` CLI는 **불가** — 브리지가 원격 노드를 노드 그래프에 미러링하지 않아 `ros2 node list`에 VM 노드가 안 보임 | | rosbag2 | ✅ | 맥에서 VM 토픽 녹화(무손실)·VM 내 녹화·양쪽 어디서 재생해도 반대편 도달. VM bag 회수는 `limactl cp -r rosmac:/path ~/dest` (D16) — [docs/workflow.ko.md](docs/workflow.ko.md) 참조 | | Robot link (LAN) | 🧪 beta | `robot:` 설정 → 맥 브리지가 로봇 쪽 브리지로 TCP 엔드포인트 추가 (D15). 대리 로봇(제2 VM) 실측: 토픽/서비스 10 MB/s @ 10Hz 무손실, 서비스 RTT < 1 ms, 로봇 재시작 자동 재접속. **대리 로봇 검증** — 실기/WiFi 수치는 대기 ([E.15 R5](docs/plan/e15-real-robot.md)). 설치: [docs/robot-setup.ko.md](docs/robot-setup.ko.md). **신뢰 LAN 전용** — 평문 TCP, 인증/TLS 없음 | 구조적 한계 (버그가 아니라 설계): +- **대형 스택(Nav2 등)은 무스코프 브리지를 포화시킨다 (KI-30).** Nav2 풀스택은 + 서비스 ~174개를 노출하는데, 이걸 전부 브리지하면 맥 쪽 DDS 디스커버리가 넘쳐 + 서비스·액션 라우팅이 멈춘다(토픽은 정상). 이런 스택의 프리셋은 `bridge_allow`를 + 선언하고 `rosmac sim`이 VM 브리지를 그 인터페이스로 스코핑한다(`sim stop` 시 복원) + — 덕분에 Nav2 goal이 동작한다. 스코프 sim 실행 중에는 `rosmac doctor` C8 왕복이 + 신규 토픽 라우트 지연으로 일시 실패할 수 있으니, 확정 진단은 `sim stop` 후 실행. - **맥↔VM 모든 메시지가 브리지 홉 하나를 건넌다.** 개발·teleop·시각화엔 충분; 고주파 폐루프 제어는 VM 안(또는 로봇 위)에서 완결할 것. - **UDP ignore 규칙이 없는 *다른* lima VM이 맥 로컬 DDS 디스커버리를 조용히 @@ -149,7 +161,7 @@ exit code 규약: 결정 로그와 리스크 레지스터: [PLAN.md](PLAN.md). 막히면 [docs/plan/known-issues.md](docs/plan/known-issues.md) — 이 도구의 토대인 -실측 함정 29개 DB. +실측 함정 30개 DB. ## 라이선스 diff --git a/README.md b/README.md index 3a5041e..f006301 100644 --- a/README.md +++ b/README.md @@ -75,10 +75,16 @@ rosmac shell -c 'ros2 topic echo /chatter --once' # VM topic received on the M ```bash rosmac sim panda-moveit # MoveIt (Panda arm) — /move_action usable from the Mac rosmac sim gazebo-diffbot # Gazebo Fortress headless + front camera +rosmac sim nav2-diffbot # Nav2 mobile navigation — /navigate_to_pose from the Mac rosmac sim list / status / stop / --attach -rosmac viz --layout panda # Foxglove connection (+ layout import guide) +rosmac viz --layout nav2 # Foxglove connection (+ layout import guide) ``` +`nav2-diffbot` runs SLAM + Nav2 on a lidar diffbot in a walled arena; drive it +with `/cmd_vel` to build the map, then send `/navigate_to_pose` goals from the Mac. +Large stacks like Nav2 auto-scope the bridge to their own interfaces — see the +capability matrix below (KI-30). + The native dev loop and a worked example (pick_demo) live in [docs/workflow.md](docs/workflow.md). ## Bring your own workspace @@ -121,6 +127,7 @@ Errors are shown as a cause + fix panel; only unexpected errors show a traceback - Bridge throughput: 10.3 MB/s (1 MB @ 10 Hz, no drops) - MoveGroup action round-trip: plan+execute, 3 consecutive goals SUCCEEDED +- Nav2 `/navigate_to_pose` from the Mac: 3 consecutive goals SUCCEEDED (scoped bridge) - Gazebo Fortress headless RTF: physics-only 1.00 / with camera (320×240 @ 15 Hz) 0.99 - Camera stream: VM 14.4 fps → Mac 14.4 fps (lossless) @@ -132,13 +139,20 @@ What works across the Mac ↔ VM zenoh bridge: |---|---|---| | Topics | ✅ | pub/sub both directions; 10.3 MB/s @ 10 Hz no drops. First subscription to a new topic takes a few seconds (bridge route creation) | | Services | ✅ | requires the pinned CycloneDDS RMW — with Fast DDS, discovery looks fine but every call times out (KI-16; why rosmac pins the RMW) | -| Actions | ✅ | MoveGroup plan+execute, 3/3 goals SUCCEEDED | +| Actions | ✅ | MoveGroup plan+execute, 3/3 goals SUCCEEDED; Nav2 `/navigate_to_pose` 3/3 SUCCEEDED from the Mac (with bridge scoping — see below) | | Parameters | ⚠️ partial | raw parameter services (`get/set_parameters`, …) work via `ros2 service call`; the `ros2 param` CLI does **not** — the bridge doesn't mirror remote nodes into the node graph, so `ros2 node list` won't show VM nodes | | rosbag2 | ✅ | record on Mac of VM topics (no loss), record in VM, play from either side reaches the other. Retrieve VM bags with `limactl cp -r rosmac:/path ~/dest` (D16) — see [docs/workflow.md](docs/workflow.md) | | Robot link (LAN) | 🧪 beta | `robot:` config → Mac bridge adds a TCP endpoint to a robot-side bridge (D15). Topics/services measured against a surrogate robot (2nd VM): 10 MB/s @ 10 Hz no drops, service RTT < 1 ms, auto-reconnect on robot restart. **Surrogate-verified** — real-hardware/WiFi numbers pending ([E.15 R5](docs/plan/e15-real-robot.md)). Setup: [docs/robot-setup.md](docs/robot-setup.md). **Trusted LAN only** — plaintext TCP, no auth/TLS | Structural limits (by design, not bugs): +- **Large stacks (Nav2, …) saturate the unscoped bridge (KI-30).** A full Nav2 + stack exposes ~174 services; bridging all of them floods Mac-side DDS + discovery so services/actions stop routing (topics still work). Presets for + such stacks declare `bridge_allow` and `rosmac sim` scopes the VM bridge to + just those interfaces (restored on `sim stop`) — the Nav2 goal works as a + result. While a scoped sim runs, `rosmac doctor`'s C8 round-trip can flap on + fresh-topic route latency; run doctor after `sim stop` for a definitive check. - **Every Mac↔VM message crosses one bridge hop.** Fine for dev, teleop and visualization; high-rate closed control loops belong inside the VM (or on the robot). @@ -152,7 +166,7 @@ Structural limits (by design, not bugs): Decision log and risk register: [PLAN.md](PLAN.md). When stuck: [docs/plan/known-issues.md](docs/plan/known-issues.md) — the field-measured -database of 29 pitfalls this tool is built on. +database of 30 pitfalls this tool is built on. ## License diff --git a/docs/workflow.ko.md b/docs/workflow.ko.md index b9ead8e..26721e7 100644 --- a/docs/workflow.ko.md +++ b/docs/workflow.ko.md @@ -50,6 +50,26 @@ feedback/result가 되돌아온다. Foxglove 3D 패널에서 팔이 움직이는 `python -m pdb $(which pick_demo)` 또는 IDE 인터프리터를 `~/micromamba/envs/ros_env/bin/python`으로 지정. +### 이동로봇 내비게이션 (`rosmac sim nav2-diffbot`) + +```bash +rosmac sim nav2-diffbot # Gazebo diffbot + 라이다 + SLAM + Nav2, READY까지 대기 +rosmac viz --layout nav2 # Foxglove: 지도 + 레이저 스캔 + 계획 경로 + +rosmac shell # 맥에서: +# /cmd_vel로 한 바퀴 돌려 SLAM이 지도를 만들게 한 뒤: +ros2 action send_goal /navigate_to_pose nav2_msgs/action/NavigateToPose \ + "{pose: {header: {frame_id: map}, pose: {position: {x: 1.5, y: 1.0}, orientation: {w: 1.0}}}}" +``` + +goal이 브리지를 넘어 VM의 `/navigate_to_pose`로 가고, 로봇이 플래닝·주행하며 +`/map`·`/plan`·`/scan`이 맥으로 돌아와 Foxglove에 그려진다. Nav2 풀스택은 서비스 +~174개를 노출해 무스코프 브리지엔 과해(맥 디스커버리 포화) 이 프리셋은 +`bridge_allow`를 선언하고 `rosmac sim`이 VM 브리지를 내비게이션 인터페이스로 +스코핑한다(`sim stop` 시 복원, KI-30). 맥에서 goal을 보내려면 맥 env에 +`ros-humble-nav2-msgs`가 필요하다 +(`micromamba install -c robostack-humble ros-humble-nav2-msgs`). + `rosmac deps`의 한계: **package.xml에 선언된** 의존성만 본다. 코드가 선언 없이 쓰는 실행 파일(launch의 `FindExecutable` 등, KI-26 사례)은 못 잡는다 — 그런 유형은 함정표와 doctor의 영역이다. diff --git a/docs/workflow.md b/docs/workflow.md index fe1ba03..1b7f2d7 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -52,6 +52,26 @@ The debugger attaches like plain Python: inside `rosmac shell`, run `python -m pdb $(which pick_demo)`, or point your IDE interpreter at `~/micromamba/envs/ros_env/bin/python`. +### Mobile navigation (`rosmac sim nav2-diffbot`) + +```bash +rosmac sim nav2-diffbot # Gazebo diffbot + lidar + SLAM + Nav2, wait until READY +rosmac viz --layout nav2 # Foxglove: map + laser scan + planned path + +rosmac shell # from the Mac: +# drive a lap with /cmd_vel to let SLAM build the map, then: +ros2 action send_goal /navigate_to_pose nav2_msgs/action/NavigateToPose \ + "{pose: {header: {frame_id: map}, pose: {position: {x: 1.5, y: 1.0}, orientation: {w: 1.0}}}}" +``` + +The goal crosses the bridge to the VM's `/navigate_to_pose`; the robot plans and +drives, and `/map` · `/plan` · `/scan` stream back to the Mac for Foxglove. +Nav2's full stack exposes ~174 services — too many for the unscoped bridge, which +would flood Mac-side discovery — so this preset declares `bridge_allow` and +`rosmac sim` scopes the VM bridge to just the navigation interfaces, restoring it +on `sim stop` (KI-30). Sending goals from the Mac needs `ros-humble-nav2-msgs` in +the Mac env (`micromamba install -c robostack-humble ros-humble-nav2-msgs`). + Limits of `rosmac deps`: it only sees dependencies **declared in package.xml**. Executables the code uses without declaring them (launch's `FindExecutable` etc., the KI-26 case) slip through — that class of problem belongs to the diff --git a/src/rosmac/assets/layouts/nav2.json b/src/rosmac/assets/layouts/nav2.json new file mode 100644 index 0000000..6980943 --- /dev/null +++ b/src/rosmac/assets/layouts/nav2.json @@ -0,0 +1,39 @@ +{ + "configById": { + "3D!nav2": { + "cameraState": { + "distance": 20, + "phi": 55, + "thetaOffset": 90 + }, + "followTf": "map", + "scene": {}, + "topics": { + "/map": { "visible": true }, + "/scan": { "visible": true, "colorField": "intensity", "colorMode": "colormap", "colorMap": "turbo" }, + "/plan": { "visible": true }, + "/global_costmap/costmap": { "visible": false } + }, + "layers": { + "grid": { "layerId": "foxglove.Grid", "visible": true } + } + }, + "Plot!vel": { + "paths": [ + { "value": "/odom.twist.twist.linear.x", "enabled": true, "timestampMethod": "receiveTime" }, + { "value": "/odom.twist.twist.angular.z", "enabled": true, "timestampMethod": "receiveTime" } + ], + "minYValue": -1, + "maxYValue": 1 + } + }, + "globalVariables": {}, + "userNodes": {}, + "playbackConfig": { "speed": 1 }, + "layout": { + "first": "3D!nav2", + "second": "Plot!vel", + "direction": "row", + "splitPercentage": 70 + } +} diff --git a/src/rosmac/assets/presets/nav2-diffbot.yaml b/src/rosmac/assets/presets/nav2-diffbot.yaml new file mode 100644 index 0000000..17cb64b --- /dev/null +++ b/src/rosmac/assets/presets/nav2-diffbot.yaml @@ -0,0 +1,38 @@ +name: nav2-diffbot +description: "Nav2 이동로봇 내비게이션 (Gazebo diffbot + gpu_lidar + SLAM, 맥에서 /navigate_to_pose)" +vm_apt: + - ros-humble-navigation2 + - ros-humble-nav2-bringup +vm_env: {} +# VM 브리지 스코핑 (KI-30 — nav2 서비스 174개가 무스코프 시 맥 디스커버리 포화). +# 항법에 필요한 인터페이스만 화이트리스트. nav2 내부 서비스는 VM 로컬 DDS라 무관. +bridge_allow: + action_servers: + - /navigate_to_pose + publishers: + - /scan + - /odom + - /map + - /map_metadata + - /tf + - /tf_static + - /plan + - /global_costmap/costmap + - /local_costmap/costmap + - /amcl_pose + - /clock + - /rosout + subscribers: + - /cmd_vel + - /goal_pose + - /initialpose + service_servers: [] + service_clients: [] + action_clients: [] +launch: + cmd: "ros2 launch ~/rosmac-presets/nav2-diffbot/nav2-diffbot.launch.py" +foxglove_layout: nav2.json +# health=/scan (N1: SLAM /map은 0.2m 이동 전 0×0 latch라 부적합). timeout은 +# N1 ready 17s + nav2 staggered 기동(+10s) 여유 → 넉넉히 60s. +health_topics: + - { name: /scan, timeout: 60 } diff --git a/src/rosmac/assets/presets/nav2-diffbot/nav2-diffbot.launch.py b/src/rosmac/assets/presets/nav2-diffbot/nav2-diffbot.launch.py new file mode 100644 index 0000000..e49feb7 --- /dev/null +++ b/src/rosmac/assets/presets/nav2-diffbot/nav2-diffbot.launch.py @@ -0,0 +1,99 @@ +"""nav2-diffbot 프리셋 headless launch (E.17 N1/N2 실측 확정 구성). + +Gazebo Fortress(diffbot + gpu_lidar, 벽 아레나) → ros_gz_bridge → +slam_toolbox(online async, mapping) → nav2_bringup(navigation, autostart). +맥에서 /navigate_to_pose 액션으로 목표 전송(브리지 스코핑 전제 — KI-30), +Foxglove로 map+scan+path 시각화. + +N1 실측 확정: +- lidar frame_id=lidar_link(), 정적 tf base_link→lidar_link +- DiffDrive frame_id=odom child=base_link, odom 50Hz, tf는 /model/vehicle_blue/tf 브리지 +- /clock 브리지 + 전 노드 use_sim_time +- nav2 기본 파라미터로 충분(base_link/odom/scan 기본값이 프레임 설계와 일치) +- 기동 타이밍: gz+bridge 먼저 → slam(+5s) → nav2(+10s) (run_spike.sh 순서) +""" + +import os + +from ament_index_python.packages import get_package_share_directory +from launch import LaunchDescription +from launch.actions import IncludeLaunchDescription, TimerAction +from launch.launch_description_sources import PythonLaunchDescriptionSource +from launch_ros.actions import Node + +PRESET = os.path.expanduser("~/rosmac-presets/nav2-diffbot") +WORLD = os.path.join(PRESET, "nav2_world.sdf") +SLAM_PARAMS = os.path.join(PRESET, "slam_params.yaml") + + +def generate_launch_description(): + pkg_ros_gz_sim = get_package_share_directory("ros_gz_sim") + pkg_slam = get_package_share_directory("slam_toolbox") + pkg_nav2 = get_package_share_directory("nav2_bringup") + + gz_sim = IncludeLaunchDescription( + PythonLaunchDescriptionSource( + os.path.join(pkg_ros_gz_sim, "launch", "gz_sim.launch.py") + ), + launch_arguments={"gz_args": f"-s --headless-rendering -r {WORLD}"}.items(), + ) + + bridge = Node( + package="ros_gz_bridge", + executable="parameter_bridge", + # 단방향(P2.4 실측) — cmd_vel만 ROS→GZ(]), 나머지 GZ→ROS([) + arguments=[ + "/model/vehicle_blue/cmd_vel@geometry_msgs/msg/Twist]gz.msgs.Twist", + "/model/vehicle_blue/odometry@nav_msgs/msg/Odometry[gz.msgs.Odometry", + "/model/vehicle_blue/tf@tf2_msgs/msg/TFMessage[gz.msgs.Pose_V", + "/scan@sensor_msgs/msg/LaserScan[gz.msgs.LaserScan", + "/clock@rosgraph_msgs/msg/Clock[gz.msgs.Clock", + ], + remappings=[ + ("/model/vehicle_blue/cmd_vel", "/cmd_vel"), + ("/model/vehicle_blue/odometry", "/odom"), + ("/model/vehicle_blue/tf", "/tf"), + ], + parameters=[ + {"qos_overrides./model/vehicle_blue.subscriber.reliability": "reliable"} + ], + output="screen", + ) + + static_tf = Node( + package="tf2_ros", + executable="static_transform_publisher", + arguments=[ + "--x", "0.648573", "--z", "0.675", + "--frame-id", "base_link", "--child-frame-id", "lidar_link", + ], + parameters=[{"use_sim_time": True}], + ) + + slam = IncludeLaunchDescription( + PythonLaunchDescriptionSource( + os.path.join(pkg_slam, "launch", "online_async_launch.py") + ), + launch_arguments={ + "use_sim_time": "true", + "slam_params_file": SLAM_PARAMS, + }.items(), + ) + + nav2 = IncludeLaunchDescription( + PythonLaunchDescriptionSource( + os.path.join(pkg_nav2, "launch", "navigation_launch.py") + ), + launch_arguments={"use_sim_time": "true"}.items(), + ) + + return LaunchDescription( + [ + gz_sim, + bridge, + static_tf, + # /clock·/scan이 흐른 뒤 SLAM, map→odom tf가 선 뒤 Nav2 (run_spike.sh 순서) + TimerAction(period=5.0, actions=[slam]), + TimerAction(period=10.0, actions=[nav2]), + ] + ) diff --git a/src/rosmac/assets/presets/nav2-diffbot/nav2_world.sdf b/src/rosmac/assets/presets/nav2-diffbot/nav2_world.sdf new file mode 100644 index 0000000..2f37676 --- /dev/null +++ b/src/rosmac/assets/presets/nav2-diffbot/nav2_world.sdf @@ -0,0 +1,606 @@ + + + + + + + 0.001 + 1.0 + + + ogre2 + + + + + + + + + + true + 0 0 10 0 0 0 + 1 1 1 1 + 0.5 0.5 0.5 1 + + 1000 + 0.9 + 0.01 + 0.001 + + -0.5 0.1 -0.9 + + + + true + + + + + 0 0 1 + 100 100 + + + + + + + 0 0 1 + 100 100 + + + + 0.8 0.8 0.8 1 + 0.8 0.8 0.8 1 + 0.8 0.8 0.8 1 + + + + + + + true + 0 6 0.5 0 0 0 + + + 12.4 0.2 1.0 + + + 12.4 0.2 1.0 + + 0.6 0.4 0.3 1 + 0.6 0.4 0.3 1 + + + + + + true + 0 -6 0.5 0 0 0 + + + 12.4 0.2 1.0 + + + 12.4 0.2 1.0 + + 0.6 0.4 0.3 1 + 0.6 0.4 0.3 1 + + + + + + true + 6 0 0.5 0 0 0 + + + 0.2 12.0 1.0 + + + 0.2 12.0 1.0 + + 0.6 0.4 0.3 1 + 0.6 0.4 0.3 1 + + + + + + true + -6 0 0.5 0 0 0 + + + 0.2 12.0 1.0 + + + 0.2 12.0 1.0 + + 0.6 0.4 0.3 1 + 0.6 0.4 0.3 1 + + + + + + true + 3.0 -2.0 0.5 0 0 0 + + + 1.0 1.0 1.0 + + + 1.0 1.0 1.0 + + 0.3 0.3 0.5 1 + 0.3 0.3 0.5 1 + + + + + + true + -3.0 3.0 0.5 0 0 0 + + + 1.0 1.0 1.0 + + + 1.0 1.0 1.0 + + 0.3 0.3 0.5 1 + 0.3 0.3 0.5 1 + + + + + + true + 0.0 -3.5 0.5 0 0 0 + + + 2.0 0.5 1.0 + + + 2.0 0.5 1.0 + + 0.3 0.3 0.5 1 + 0.3 0.3 0.5 1 + + + + + + + 0 2 0.325 0 -0 0 + + + -0.151427 -0 0.175 0 -0 0 + + 1.14395 + + 0.126164 + 0 + 0 + 0.416519 + 0 + 0.481014 + + + + + + 2.01142 1 0.568726 + + + + 0.5 0.5 1.0 1 + 0.5 0.5 1.0 1 + 0.0 0.0 1.0 1 + + + + + + 2.01142 1 0.568726 + + + + + 0.8 0 0.3 0 0 0 + + 1.047 + 320240 + 0.1100 + + 1 + 15 + camera + + + 0.8 0 0.5 0 0 0 + scan + 10 + lidar_link + + + + 360 + 1 + -3.14159 + 3.14159 + + + 0.1220.00.015 + + 1 + false + + + + + + 0.554283 0.625029 -0.025 -1.5707 0 0 + + 2 + + 0.145833 + 0 + 0 + 0.145833 + 0 + 0.125 + + + + + + 0.3 + + + + 0.2 0.2 0.2 1 + 0.2 0.2 0.2 1 + 0.2 0.2 0.2 1 + + + + + + 0.3 + + + + + + + 0.554282 -0.625029 -0.025 -1.5707 0 0 + + 2 + + 0.145833 + 0 + 0 + 0.145833 + 0 + 0.125 + + + + + + 0.3 + + + + 0.2 0.2 0.2 1 + 0.2 0.2 0.2 1 + 0.2 0.2 0.2 1 + + + + + + 0.3 + + + + + + + -0.957138 -0 -0.125 0 -0 0 + + 1 + + 0.1 + 0 + 0 + 0.1 + 0 + 0.1 + + + + + + 0.2 + + + + 0.2 0.2 0.2 1 + 0.2 0.2 0.2 1 + 0.2 0.2 0.2 1 + + + + + + 0.2 + + + + + + + chassis + left_wheel + + 0 0 1 + + -1.79769e+308 + 1.79769e+308 + + + + + + chassis + right_wheel + + 0 0 1 + + -1.79769e+308 + 1.79769e+308 + + + + + + chassis + caster + + + + left_wheel_joint + right_wheel_joint + 1.25 + 0.3 + 50 + odom + base_link + 1 + -1 + 2 + -2 + 0.5 + -0.5 + 1 + -1 + + + + + + 0 -2 0.325 0 -0 0 + + + -0.151427 -0 0.175 0 -0 0 + + 1.14395 + + 0.126164 + 0 + 0 + 0.416519 + 0 + 0.481014 + + + + + + 2.01142 1 0.568726 + + + + 0.5 1.0 0.5 1 + 0.5 1.0 0.5 1 + 0.0 1.0 0.0 1 + + + + + + 2.01142 1 0.568726 + + + + + + + 0.554283 0.625029 -0.025 -1.5707 0 0 + + 2 + + 0.145833 + 0 + 0 + 0.145833 + 0 + 0.125 + + + + + + 0.3 + + + + 0.2 0.2 0.2 1 + 0.2 0.2 0.2 1 + 0.2 0.2 0.2 1 + + + + + + 0.3 + + + + + + + 0.554282 -0.625029 -0.025 -1.5707 0 0 + + 2 + + 0.145833 + 0 + 0 + 0.145833 + 0 + 0.125 + + + + + + 0.3 + + + + 0.2 0.2 0.2 1 + 0.2 0.2 0.2 1 + 0.2 0.2 0.2 1 + + + + + + 0.3 + + + + + + + -0.957138 -0 -0.125 0 -0 0 + + 1 + + 0.1 + 0 + 0 + 0.1 + 0 + 0.1 + + + + + + 0.2 + + + + 0.2 0.2 0.2 1 + 0.2 0.2 0.2 1 + 0.2 0.2 0.2 1 + + + + + + 0.2 + + + + + + + chassis + left_wheel + + 0 0 1 + + -1.79769e+308 + 1.79769e+308 + + + + + + chassis + right_wheel + + 0 0 1 + + -1.79769e+308 + 1.79769e+308 + + + + + + chassis + caster + + + + left_wheel_joint + right_wheel_joint + 1.25 + 0.3 + 1 + -1 + 2 + -2 + 0.5 + -0.5 + 1 + -1 + + + + + + diff --git a/src/rosmac/assets/presets/nav2-diffbot/slam_params.yaml b/src/rosmac/assets/presets/nav2-diffbot/slam_params.yaml new file mode 100644 index 0000000..f418dd4 --- /dev/null +++ b/src/rosmac/assets/presets/nav2-diffbot/slam_params.yaml @@ -0,0 +1,26 @@ +slam_toolbox: + ros__parameters: + use_sim_time: true + solver_plugin: solver_plugins::CeresSolver + ceres_linear_solver: SPARSE_NORMAL_CHOLESKY + ceres_preconditioner: SCHUR_JACOBI + ceres_trust_strategy: LEVENBERG_MARQUARDT + ceres_dogleg_type: TRADITIONAL_DOGLEG + ceres_loss_function: None + + odom_frame: odom + map_frame: map + base_frame: base_link + scan_topic: /scan + mode: mapping + + map_update_interval: 2.0 + resolution: 0.05 + max_laser_range: 20.0 + minimum_time_interval: 0.5 + transform_timeout: 0.2 + tf_buffer_duration: 30.0 + stack_size_to_use: 40000000 + + minimum_travel_distance: 0.2 + minimum_travel_heading: 0.2 diff --git a/src/rosmac/cli.py b/src/rosmac/cli.py index 50bf800..83fcc05 100644 --- a/src/rosmac/cli.py +++ b/src/rosmac/cli.py @@ -197,7 +197,7 @@ def _start_viz(cfg: Config) -> None: @app.command() def viz( - layout: str | None = typer.Option(None, "--layout", help="Preset layout name (panda|diffbot)"), + layout: str | None = typer.Option(None, "--layout", help="Preset layout name (panda|diffbot|nav2)"), ) -> None: """Connect Foxglove visualization (start VM foxglove_bridge + open app).""" cfg = load() @@ -211,7 +211,7 @@ def viz( src = resources.files("rosmac") / "assets" / "layouts" / f"{layout}.json" if not src.is_file(): - raise UsageError(f"Layout '{layout}' not found (panda|diffbot)") + raise UsageError(f"Layout '{layout}' not found (panda|diffbot|nav2)") dest = Path.home() / ".rosmac" / "layouts" / f"{layout}.json" dest.parent.mkdir(parents=True, exist_ok=True) dest.write_text(src.read_text()) diff --git a/src/rosmac/sim.py b/src/rosmac/sim.py index 52612d2..e32ec1d 100644 --- a/src/rosmac/sim.py +++ b/src/rosmac/sim.py @@ -4,6 +4,7 @@ tmux인 이유: --attach 로그 관찰, 세션 유지, 이중 실행 감지 (phase2 2.2). """ +import json import time from importlib import resources from pathlib import Path @@ -11,7 +12,7 @@ import yaml from pydantic import BaseModel -from rosmac import conda, lima +from rosmac import bridge, conda, lima from rosmac.config import Config from rosmac.errors import RosmacError @@ -19,6 +20,23 @@ SIM_LOG = "/tmp/rosmac-sim.log" USER_PRESET_DIR = Path.home() / ".rosmac" / "presets" +# VM 브리지 스코핑 (KI-30) — nav2류 대형 스택은 서비스/액션 엔티티가 맥 디스커버리를 +# 포화시켜 무스코프 브리지로는 라우팅 실패. 프리셋이 bridge_allow를 선언하면 +# systemd 드롭인으로 브리지를 화이트리스트 스코프로 재기동, sim stop 시 복원. +_SCOPE_CFG_VM = "/etc/rosmac/bridge-scope.json5" +_SCOPE_DROPIN = "/etc/systemd/system/zenoh-bridge.service.d/rosmac-scope.conf" +_ALLOW_KEYS = ( + "publishers", + "subscribers", + "service_servers", + "service_clients", + "action_servers", + "action_clients", +) +# doctor C8 왕복 자가진단 토픽 (doctor.py: /rosmac/doctor/) — 스코프 중에도 +# 브리지 경계를 넘도록 pub/sub에 상시 주입 (정규식 매칭). +_DOCTOR_TOPIC = "/rosmac/doctor/.*" + class HealthTopic(BaseModel): name: str @@ -37,6 +55,10 @@ class Preset(BaseModel): launch: Launch foxglove_layout: str | None = None health_topics: list[HealthTopic] = [] + # 선언 시 VM 브리지를 이 인터페이스만 브리지하도록 스코핑 (KI-30). 키: + # publishers/subscribers/service_servers/service_clients/action_servers/action_clients. + # 미지정 카테고리는 deny(빈 목록). None이면 무스코프(기존 동작). + bridge_allow: dict[str, list[str]] | None = None def _asset_preset_dir(): @@ -118,6 +140,75 @@ def _push_preset_assets(cfg: Config, preset: Preset) -> None: ) +def bridge_scope_config(bridge_allow: dict[str, list[str]]) -> str: + """bridge_allow → zenoh-bridge-ros2dds allow config (JSON, JSON5 호환). + + 6개 카테고리를 모두 채운다 — 미지정 카테고리는 빈 목록(deny)으로 명시해 + "전부 허용" 기본값으로 새지 않게 한다 (KI-30 핵심 — 서비스류를 확실히 deny). + doctor C8 왕복 토픽(/rosmac/doctor/*)은 스코프 중에도 자가진단이 되도록 + pub/sub에 항상 주입한다. + """ + allow = {k: list(bridge_allow.get(k, [])) for k in _ALLOW_KEYS} + for direction in ("publishers", "subscribers"): + if _DOCTOR_TOPIC not in allow[direction]: + allow[direction] = [*allow[direction], _DOCTOR_TOPIC] + return json.dumps({"plugins": {"ros2dds": {"allow": allow}}}, indent=2) + + +def _apply_bridge_scope(cfg: Config, preset: Preset, progress=None) -> None: + """프리셋 bridge_allow로 VM 브리지를 스코핑 재기동 + 맥 브리지 라우트 갱신.""" + if not preset.bridge_allow: + return + cfg_text = bridge_scope_config(preset.bridge_allow) + dropin = ( + "[Service]\n" + "ExecStart=\n" + f"ExecStart=/usr/local/bin/zenoh-bridge-ros2dds -l tcp/0.0.0.0:{cfg.bridge.port}" + f" -c {_SCOPE_CFG_VM}\n" + ) + # config·드롭인을 파일로 push(셸 이스케이프 회피) 후 sudo로 배치·브리지 재기동 + # (KillSignal=SIGTERM → KI-17 안전) + lima.push(cfg.vm.name, cfg_text, "~/.rosmac-bridge-scope.json5") + lima.push(cfg.vm.name, dropin, "~/.rosmac-bridge-dropin.conf") + lima.shell( + cfg.vm.name, + f"sudo mkdir -p /etc/rosmac $(dirname {_SCOPE_DROPIN}) && " + f"sudo cp ~/.rosmac-bridge-scope.json5 {_SCOPE_CFG_VM} && " + f"sudo cp ~/.rosmac-bridge-dropin.conf {_SCOPE_DROPIN} && " + "sudo systemctl daemon-reload && sudo systemctl restart zenoh-bridge", + timeout=60, + ) + if progress: + progress("✓ VM bridge scoped to preset interfaces (KI-30)") + _reset_mac_bridge(cfg) + + +def _clear_bridge_scope(cfg: Config, progress=None) -> bool: + """스코프 드롭인이 있으면 제거하고 무스코프 브리지로 복원. 복원했으면 True.""" + present = lima.shell( + cfg.vm.name, f"test -f {_SCOPE_DROPIN} && echo yes || echo no" + ).strip() + if present != "yes": + return False + lima.shell( + cfg.vm.name, + f"sudo rm -f {_SCOPE_DROPIN} && " + "sudo systemctl daemon-reload && sudo systemctl restart zenoh-bridge", + timeout=60, + ) + if progress: + progress("✓ VM bridge scope cleared (unscoped restored)") + _reset_mac_bridge(cfg) + return True + + +def _reset_mac_bridge(cfg: Config) -> None: + """VM 브리지 재기동 후 맥 브리지의 낡은 라우트를 재기동으로 정리 (N2 실측 플로우).""" + if bridge.is_running(): + bridge.stop() + bridge.start(cfg) + + def start(cfg: Config, preset: Preset, progress=None) -> None: """tmux 세션으로 launch.cmd 실행. 이미 세션이 있으면 RuntimeError (R6 패턴).""" if session_alive(cfg): @@ -125,6 +216,8 @@ def start(cfg: Config, preset: Preset, progress=None) -> None: f"tmux session '{SESSION}' already exists — use rosmac sim status/stop or --attach" ) _push_preset_assets(cfg, preset) + # 브리지 스코핑을 launch 전에 적용 — 스택이 뜨는 대로 스코프된 브리지가 라우팅 (KI-30) + _apply_bridge_scope(cfg, preset, progress=progress) env_exports = " ".join( f"{k}={v}" for k, v in { @@ -170,7 +263,7 @@ def wait_healthy(cfg: Config, preset: Preset, progress=None) -> None: time.sleep(2) -def stop(cfg: Config) -> bool: +def stop(cfg: Config, progress=None) -> bool: alive = session_alive(cfg) if alive: lima.shell(cfg.vm.name, f"tmux kill-session -t {SESSION}", timeout=30) @@ -183,6 +276,8 @@ def stop(cfg: Config) -> bool: 'pkill -f "[p]arameter_bridge" 2>/dev/null; true', timeout=30, ) + # 스코핑됐던 브리지 복원 (멱등 — 드롭인 없으면 무동작) + _clear_bridge_scope(cfg, progress=progress) return alive diff --git a/tests/unit/test_sim.py b/tests/unit/test_sim.py index f09cd96..171e9b6 100644 --- a/tests/unit/test_sim.py +++ b/tests/unit/test_sim.py @@ -24,3 +24,50 @@ def test_user_preset_overrides(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) (tmp_path / "panda-moveit.yaml").write_text("name: panda-moveit\nlaunch: {cmd: echo custom}\n") p = sim.load_preset("panda-moveit") assert p.launch.cmd == "echo custom" + + +def test_nav2_preset_declares_bridge_scope() -> None: + p = sim.load_preset("nav2-diffbot") + assert p.name == "nav2-diffbot" + assert "ros-humble-navigation2" in p.vm_apt + assert p.bridge_allow is not None + assert p.bridge_allow["action_servers"] == ["/navigate_to_pose"] + assert "/scan" in p.bridge_allow["publishers"] + assert "/cmd_vel" in p.bridge_allow["subscribers"] + assert p.health_topics[0].name == "/scan" + + +def test_preset_without_bridge_allow_is_unscoped() -> None: + # 기존 소형 프리셋은 무스코프(bridge_allow None) — 기존 동작 유지 (임계 미만) + assert sim.load_preset("panda-moveit").bridge_allow is None + assert sim.load_preset("gazebo-diffbot").bridge_allow is None + + +def test_bridge_scope_config_fills_all_six_categories() -> None: + # 미지정 카테고리도 빈 목록으로 명시 — "전부 허용" 기본값으로 새지 않게 (KI-30) + import json + + cfg = json.loads(sim.bridge_scope_config({"action_servers": ["/navigate_to_pose"]})) + allow = cfg["plugins"]["ros2dds"]["allow"] + assert set(allow.keys()) == { + "publishers", + "subscribers", + "service_servers", + "service_clients", + "action_servers", + "action_clients", + } + assert allow["action_servers"] == ["/navigate_to_pose"] + assert allow["service_servers"] == [] # 내부 서비스 deny (포화 방지) + + +def test_bridge_scope_always_allows_doctor_roundtrip() -> None: + # doctor C8 왕복 토픽은 스코프 중에도 자가진단되도록 pub/sub에 상시 주입 + import json + + allow = json.loads(sim.bridge_scope_config({"publishers": ["/scan"]}))[ + "plugins" + ]["ros2dds"]["allow"] + assert "/rosmac/doctor/.*" in allow["publishers"] + assert "/rosmac/doctor/.*" in allow["subscribers"] + assert "/scan" in allow["publishers"] # 프리셋 지정 항목도 보존 From 53c1d6526249d248591ba7901bb4592a14e4c1af Mon Sep 17 00:00:00 2001 From: PythonToGo Date: Sun, 12 Jul 2026 00:34:55 +0200 Subject: [PATCH 2/4] =?UTF-8?q?[E.17]=20nav2-diffbot=20launch=20=EC=8B=A0?= =?UTF-8?q?=EB=A2=B0=EC=84=B1=20=EC=88=98=EC=A0=95=20=E2=80=94=20stagger?= =?UTF-8?q?=20=ED=83=80=EC=9D=B4=EB=B0=8D=20(slam@10,=20nav2@18)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit S0 실측: 촘촘한 타이밍(slam@5, nav2@10)에서 nav2 lifecycle 활성화가 간헐 실패 (~25-30%: bt_navigator 미활성/노드 10개). gz가 벽 아레나 월드 로드 + /clock·/scan 발행을 마치기 전에 slam/nav2가 떠서 CPU 경합 + 미준비 상태로 활성화 실패. run_spike.sh 검증 타이밍(gz→8s→slam→5s→nav2) + 여유로 stagger 조정 → 연속 7회 전부 건강(11노드, nav2 7노드 active). health(/scan) 게이트 60s 내 유지. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Usk65wXFwXBq6UgRxGn7Yi --- .../assets/presets/nav2-diffbot/nav2-diffbot.launch.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/rosmac/assets/presets/nav2-diffbot/nav2-diffbot.launch.py b/src/rosmac/assets/presets/nav2-diffbot/nav2-diffbot.launch.py index e49feb7..969bbef 100644 --- a/src/rosmac/assets/presets/nav2-diffbot/nav2-diffbot.launch.py +++ b/src/rosmac/assets/presets/nav2-diffbot/nav2-diffbot.launch.py @@ -92,8 +92,10 @@ def generate_launch_description(): gz_sim, bridge, static_tf, - # /clock·/scan이 흐른 뒤 SLAM, map→odom tf가 선 뒤 Nav2 (run_spike.sh 순서) - TimerAction(period=5.0, actions=[slam]), - TimerAction(period=10.0, actions=[nav2]), + # 신뢰성 우선 stagger (S0 실측: 촘촘하면 nav2 lifecycle 활성화가 간헐 실패). + # gz가 벽 아레나 월드 로드 + /clock·/scan 발행을 마칠 시간(~10s) 뒤 SLAM, + # SLAM이 map→odom tf를 세운 뒤(~8s) Nav2 — run_spike.sh 검증 타이밍 + 여유. + TimerAction(period=10.0, actions=[slam]), + TimerAction(period=18.0, actions=[nav2]), ] ) From 53612b3f22e4e4fada23413472a6a15eeb84520a Mon Sep 17 00:00:00 2001 From: PythonToGo Date: Sun, 12 Jul 2026 00:58:46 +0200 Subject: [PATCH 3/4] =?UTF-8?q?[E.17]=20=EC=8A=A4=EC=BD=94=ED=95=91=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=20=E2=86=92=20=EB=B8=8C=EB=A6=AC=EC=A7=80=20?= =?UTF-8?q?=EC=84=B8=EC=85=98=20=EB=A6=AC=EC=85=8B=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=8C=80=EC=B2=B4=20(KI-30=20=EC=A0=95=EC=A0=95,=20S0=20?= =?UTF-8?q?=EC=8B=A4=EC=B8=A1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit S0 게이트가 KI-30의 "nav2 서비스 174개 포화" 원인을 반증. 무스코프 브리지가 풀스택(서비스 155·액션 12)을 정상 라우팅하고 맥 goal 3/3 SUCCEEDED. N2/N3에서 스코핑이 통한 건 스코프 적용이 VM 브리지를 재시작한 부수효과였음. 진짜 원인 규명: 스택을 재기동해도 브리지를 켜둔 채면 죽은 스택의 라우트가 누적돼 새 스택의 액션 하위 서비스가 맥에서 디스커버리 실패(KI-17 계열). 실측: 동일 스택이 브리지 재시작 전 0/6 → 후 4/4. - sim.py: 프리셋별 스코핑 메커니즘(bridge_allow, _apply/_clear_bridge_scope, bridge_scope_config, systemd 드롭인) 제거. start()가 `_reset_bridge_session`으로 VM+맥 브리지를 리셋(rosmac up의 KI-17 방지와 동일). ~80줄 → 단순 재시작. - nav2-diffbot.yaml: bridge_allow 제거(무스코프). E2E: fresh 3/3, churn 후에도 goal 성공. - 유닛 4개(스코핑) 제거 + 무스코프 로딩 1개. 94 tests, ruff/mypy clean. - README/workflow/CHANGELOG(en/ko): "스코핑 필수" → "기본 브리지 + sim 브리지 리셋". Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Usk65wXFwXBq6UgRxGn7Yi --- CHANGELOG.md | 8 +- README.ko.md | 19 ++-- README.md | 20 ++-- docs/workflow.ko.md | 10 +- docs/workflow.md | 13 +-- src/rosmac/assets/presets/nav2-diffbot.yaml | 28 +----- src/rosmac/sim.py | 105 ++++---------------- tests/unit/test_sim.py | 44 +------- 8 files changed, 57 insertions(+), 190 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fca9c24..d9a051b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,10 +36,10 @@ `up`/`init`이 브리지 바이너리 버전을 비교해 자동 갱신 — pip 업그레이드만으로 신 핀 반영 (E.7) - **Nav2 프리셋 (`sim nav2-diffbot`)**: Gazebo diffbot + gpu_lidar + slam_toolbox - + Nav2, 맥에서 `/navigate_to_pose` goal 3/3 SUCCEEDED. 프리셋별 브리지 스코핑 - (`Preset.bridge_allow`) 도입 — 대형 스택(nav2 서비스 174개)이 맥 디스커버리를 - 포화시키는 문제(KI-30)를 `rosmac sim`이 VM 브리지를 필요한 인터페이스로 스코핑해 - 해결(`sim stop` 시 복원). `viz --layout nav2` (E.17) + + Nav2(풀스택, 기본 브리지), 맥에서 `/navigate_to_pose` goal 3/3 SUCCEEDED. + `rosmac sim`이 시작 시 브리지 세션을 리셋해 이전 스택 라우트 잔재로 인한 액션 + 디스커버리 실패를 방지(KI-17). launch 신뢰성 stagger 타이밍. `viz --layout nav2`. + 맥측 nav2_msgs는 수동 설치 필요(백로그). (E.17) ### 실측 검증 (Phase 0/2/4 게이트) - 브리지 대역폭 10.3 MB/s (1MB@10Hz 무손실), MoveGroup 액션 왕복 3연속 SUCCEEDED diff --git a/README.ko.md b/README.ko.md index 53997df..2bdf328 100644 --- a/README.ko.md +++ b/README.ko.md @@ -79,8 +79,9 @@ rosmac viz --layout nav2 # Foxglove 연결 (+레이아웃 안내) ``` `nav2-diffbot`은 벽 아레나에서 라이다 diffbot에 SLAM+Nav2를 돌린다. `/cmd_vel`로 -주행해 지도를 만든 뒤 맥에서 `/navigate_to_pose` goal을 보낸다. Nav2 같은 대형 -스택은 브리지를 자기 인터페이스로 자동 스코핑한다 — 아래 능력 매트릭스 참조(KI-30). +주행해 지도를 만든 뒤 맥에서 `/navigate_to_pose` goal을 보낸다. Nav2 풀스택도 +기본 브리지로 동작한다 — `rosmac sim`이 시작 시 브리지 세션을 리셋해 새 스택이 +신선한 라우트를 받게 한다(KI-17). 맥 네이티브 개발 루프와 예제(pick_demo)는 [docs/workflow.ko.md](docs/workflow.ko.md) 참조. @@ -124,7 +125,7 @@ exit code 규약: - 브리지 대역폭: 10.3 MB/s (1MB@10Hz 드랍 없음) - MoveGroup 액션 왕복: 플래닝+실행 goal 3연속 SUCCEEDED -- 맥에서 Nav2 `/navigate_to_pose`: goal 3연속 SUCCEEDED (스코프 브리지) +- 맥에서 Nav2 `/navigate_to_pose`: goal 3연속 SUCCEEDED (기본 브리지) - Gazebo Fortress headless RTF: 물리만 1.00 / 카메라(320x240@15Hz) 0.99 - 카메라 스트림: VM 14.4fps → 맥 14.4fps (무손실) @@ -136,19 +137,17 @@ exit code 규약: |---|---|---| | Topics | ✅ | 양방향 pub/sub; 10.3 MB/s @ 10Hz 무손실. 새 토픽의 첫 구독은 라우트 생성에 몇 초 소요 | | Services | ✅ | 핀된 CycloneDDS RMW 필수 — Fast DDS는 디스커버리는 되는데 모든 호출이 타임아웃 (KI-16; RMW를 핀한 이유) | -| Actions | ✅ | MoveGroup 플래닝+실행 goal 3/3 SUCCEEDED; 맥에서 Nav2 `/navigate_to_pose` 3/3 SUCCEEDED (브리지 스코핑 — 아래 참조) | +| Actions | ✅ | MoveGroup 플래닝+실행 goal 3/3 SUCCEEDED; 맥에서 Nav2 `/navigate_to_pose` 3/3 SUCCEEDED (풀스택, 기본 브리지) | | Parameters | ⚠️ 부분 | 원시 파라미터 서비스(`get/set_parameters` 등)는 `ros2 service call`로 동작; `ros2 param` CLI는 **불가** — 브리지가 원격 노드를 노드 그래프에 미러링하지 않아 `ros2 node list`에 VM 노드가 안 보임 | | rosbag2 | ✅ | 맥에서 VM 토픽 녹화(무손실)·VM 내 녹화·양쪽 어디서 재생해도 반대편 도달. VM bag 회수는 `limactl cp -r rosmac:/path ~/dest` (D16) — [docs/workflow.ko.md](docs/workflow.ko.md) 참조 | | Robot link (LAN) | 🧪 beta | `robot:` 설정 → 맥 브리지가 로봇 쪽 브리지로 TCP 엔드포인트 추가 (D15). 대리 로봇(제2 VM) 실측: 토픽/서비스 10 MB/s @ 10Hz 무손실, 서비스 RTT < 1 ms, 로봇 재시작 자동 재접속. **대리 로봇 검증** — 실기/WiFi 수치는 대기 ([E.15 R5](docs/plan/e15-real-robot.md)). 설치: [docs/robot-setup.ko.md](docs/robot-setup.ko.md). **신뢰 LAN 전용** — 평문 TCP, 인증/TLS 없음 | 구조적 한계 (버그가 아니라 설계): -- **대형 스택(Nav2 등)은 무스코프 브리지를 포화시킨다 (KI-30).** Nav2 풀스택은 - 서비스 ~174개를 노출하는데, 이걸 전부 브리지하면 맥 쪽 DDS 디스커버리가 넘쳐 - 서비스·액션 라우팅이 멈춘다(토픽은 정상). 이런 스택의 프리셋은 `bridge_allow`를 - 선언하고 `rosmac sim`이 VM 브리지를 그 인터페이스로 스코핑한다(`sim stop` 시 복원) - — 덕분에 Nav2 goal이 동작한다. 스코프 sim 실행 중에는 `rosmac doctor` C8 왕복이 - 신규 토픽 라우트 지연으로 일시 실패할 수 있으니, 확정 진단은 `sim stop` 후 실행. +- **낡은 브리지가 새 스택을 조용히 깨뜨린다 (KI-17).** 브리지를 켜둔 채 VM 시뮬 + 스택을 재기동하면 죽은 스택의 라우트가 남아, 새 스택의 액션 하위 서비스가 맥에서 + 디스커버리 안 된다(실측: 0/6 → 브리지 재시작 후 4/4). `rosmac sim`은 시작 시 + 브리지 세션을 리셋해 이를 방지 — Nav2 풀스택도 기본 브리지로 동작한다(스코핑 불필요). - **맥↔VM 모든 메시지가 브리지 홉 하나를 건넌다.** 개발·teleop·시각화엔 충분; 고주파 폐루프 제어는 VM 안(또는 로봇 위)에서 완결할 것. - **UDP ignore 규칙이 없는 *다른* lima VM이 맥 로컬 DDS 디스커버리를 조용히 diff --git a/README.md b/README.md index f006301..03d27cc 100644 --- a/README.md +++ b/README.md @@ -82,8 +82,8 @@ rosmac viz --layout nav2 # Foxglove connection (+ layout import guide) `nav2-diffbot` runs SLAM + Nav2 on a lidar diffbot in a walled arena; drive it with `/cmd_vel` to build the map, then send `/navigate_to_pose` goals from the Mac. -Large stacks like Nav2 auto-scope the bridge to their own interfaces — see the -capability matrix below (KI-30). +The full Nav2 stack works over the default bridge — `rosmac sim` resets the bridge +session on start so a new stack gets fresh routes (KI-17). The native dev loop and a worked example (pick_demo) live in [docs/workflow.md](docs/workflow.md). @@ -127,7 +127,7 @@ Errors are shown as a cause + fix panel; only unexpected errors show a traceback - Bridge throughput: 10.3 MB/s (1 MB @ 10 Hz, no drops) - MoveGroup action round-trip: plan+execute, 3 consecutive goals SUCCEEDED -- Nav2 `/navigate_to_pose` from the Mac: 3 consecutive goals SUCCEEDED (scoped bridge) +- Nav2 `/navigate_to_pose` from the Mac: 3 consecutive goals SUCCEEDED (default bridge) - Gazebo Fortress headless RTF: physics-only 1.00 / with camera (320×240 @ 15 Hz) 0.99 - Camera stream: VM 14.4 fps → Mac 14.4 fps (lossless) @@ -139,20 +139,18 @@ What works across the Mac ↔ VM zenoh bridge: |---|---|---| | Topics | ✅ | pub/sub both directions; 10.3 MB/s @ 10 Hz no drops. First subscription to a new topic takes a few seconds (bridge route creation) | | Services | ✅ | requires the pinned CycloneDDS RMW — with Fast DDS, discovery looks fine but every call times out (KI-16; why rosmac pins the RMW) | -| Actions | ✅ | MoveGroup plan+execute, 3/3 goals SUCCEEDED; Nav2 `/navigate_to_pose` 3/3 SUCCEEDED from the Mac (with bridge scoping — see below) | +| Actions | ✅ | MoveGroup plan+execute, 3/3 goals SUCCEEDED; Nav2 `/navigate_to_pose` 3/3 SUCCEEDED from the Mac (full stack, default bridge) | | Parameters | ⚠️ partial | raw parameter services (`get/set_parameters`, …) work via `ros2 service call`; the `ros2 param` CLI does **not** — the bridge doesn't mirror remote nodes into the node graph, so `ros2 node list` won't show VM nodes | | rosbag2 | ✅ | record on Mac of VM topics (no loss), record in VM, play from either side reaches the other. Retrieve VM bags with `limactl cp -r rosmac:/path ~/dest` (D16) — see [docs/workflow.md](docs/workflow.md) | | Robot link (LAN) | 🧪 beta | `robot:` config → Mac bridge adds a TCP endpoint to a robot-side bridge (D15). Topics/services measured against a surrogate robot (2nd VM): 10 MB/s @ 10 Hz no drops, service RTT < 1 ms, auto-reconnect on robot restart. **Surrogate-verified** — real-hardware/WiFi numbers pending ([E.15 R5](docs/plan/e15-real-robot.md)). Setup: [docs/robot-setup.md](docs/robot-setup.md). **Trusted LAN only** — plaintext TCP, no auth/TLS | Structural limits (by design, not bugs): -- **Large stacks (Nav2, …) saturate the unscoped bridge (KI-30).** A full Nav2 - stack exposes ~174 services; bridging all of them floods Mac-side DDS - discovery so services/actions stop routing (topics still work). Presets for - such stacks declare `bridge_allow` and `rosmac sim` scopes the VM bridge to - just those interfaces (restored on `sim stop`) — the Nav2 goal works as a - result. While a scoped sim runs, `rosmac doctor`'s C8 round-trip can flap on - fresh-topic route latency; run doctor after `sim stop` for a definitive check. +- **A stale bridge silently breaks a fresh stack (KI-17).** Restarting the VM + sim stack while the bridge keeps running leaves stale routes behind, so the new + stack's action sub-services fail to discover from the Mac (measured: 0/6, then + 4/4 after a bridge restart). `rosmac sim` resets the bridge session on start to + avoid this — the full Nav2 stack then works over the default bridge, no scoping. - **Every Mac↔VM message crosses one bridge hop.** Fine for dev, teleop and visualization; high-rate closed control loops belong inside the VM (or on the robot). diff --git a/docs/workflow.ko.md b/docs/workflow.ko.md index 26721e7..cdcb96d 100644 --- a/docs/workflow.ko.md +++ b/docs/workflow.ko.md @@ -63,11 +63,11 @@ ros2 action send_goal /navigate_to_pose nav2_msgs/action/NavigateToPose \ ``` goal이 브리지를 넘어 VM의 `/navigate_to_pose`로 가고, 로봇이 플래닝·주행하며 -`/map`·`/plan`·`/scan`이 맥으로 돌아와 Foxglove에 그려진다. Nav2 풀스택은 서비스 -~174개를 노출해 무스코프 브리지엔 과해(맥 디스커버리 포화) 이 프리셋은 -`bridge_allow`를 선언하고 `rosmac sim`이 VM 브리지를 내비게이션 인터페이스로 -스코핑한다(`sim stop` 시 복원, KI-30). 맥에서 goal을 보내려면 맥 env에 -`ros-humble-nav2-msgs`가 필요하다 +`/map`·`/plan`·`/scan`이 맥으로 돌아와 Foxglove에 그려진다. Nav2 풀스택(서비스 +155·액션 12)도 기본 브리지로 동작한다 — 스코핑 불필요. `rosmac sim`이 시작 시 +브리지 세션을 리셋해 새 스택이 신선한 라우트를 받게 하며, 이게 없으면 이전 스택의 +라우트 잔재가 맥 쪽 액션 디스커버리를 조용히 깨뜨린다(KI-17). 맥에서 goal을 +보내려면 맥 env에 `ros-humble-nav2-msgs`가 필요하다 (`micromamba install -c robostack-humble ros-humble-nav2-msgs`). `rosmac deps`의 한계: **package.xml에 선언된** 의존성만 본다. 코드가 선언 없이 diff --git a/docs/workflow.md b/docs/workflow.md index 1b7f2d7..5e3fac3 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -65,12 +65,13 @@ ros2 action send_goal /navigate_to_pose nav2_msgs/action/NavigateToPose \ ``` The goal crosses the bridge to the VM's `/navigate_to_pose`; the robot plans and -drives, and `/map` · `/plan` · `/scan` stream back to the Mac for Foxglove. -Nav2's full stack exposes ~174 services — too many for the unscoped bridge, which -would flood Mac-side discovery — so this preset declares `bridge_allow` and -`rosmac sim` scopes the VM bridge to just the navigation interfaces, restoring it -on `sim stop` (KI-30). Sending goals from the Mac needs `ros-humble-nav2-msgs` in -the Mac env (`micromamba install -c robostack-humble ros-humble-nav2-msgs`). +drives, and `/map` · `/plan` · `/scan` stream back to the Mac for Foxglove. The +full Nav2 stack (155 services, 12 actions) works over the default bridge — no +scoping. `rosmac sim` resets the bridge session on start so the new stack gets +fresh routes; without that, routes left over from a prior stack silently break +Mac-side action discovery (KI-17). Sending goals from the Mac needs +`ros-humble-nav2-msgs` in the Mac env +(`micromamba install -c robostack-humble ros-humble-nav2-msgs`). Limits of `rosmac deps`: it only sees dependencies **declared in package.xml**. Executables the code uses without declaring them (launch's `FindExecutable` diff --git a/src/rosmac/assets/presets/nav2-diffbot.yaml b/src/rosmac/assets/presets/nav2-diffbot.yaml index 17cb64b..3eceb72 100644 --- a/src/rosmac/assets/presets/nav2-diffbot.yaml +++ b/src/rosmac/assets/presets/nav2-diffbot.yaml @@ -4,31 +4,9 @@ vm_apt: - ros-humble-navigation2 - ros-humble-nav2-bringup vm_env: {} -# VM 브리지 스코핑 (KI-30 — nav2 서비스 174개가 무스코프 시 맥 디스커버리 포화). -# 항법에 필요한 인터페이스만 화이트리스트. nav2 내부 서비스는 VM 로컬 DDS라 무관. -bridge_allow: - action_servers: - - /navigate_to_pose - publishers: - - /scan - - /odom - - /map - - /map_metadata - - /tf - - /tf_static - - /plan - - /global_costmap/costmap - - /local_costmap/costmap - - /amcl_pose - - /clock - - /rosout - subscribers: - - /cmd_vel - - /goal_pose - - /initialpose - service_servers: [] - service_clients: [] - action_clients: [] +# 무스코프 — nav2 풀스택도 기본 브리지로 맥에서 정상 동작한다(S0 실측). 이전에 +# 실패로 본 것은 스택 재기동 시 브리지 라우트 잔재(KI-17) 때문이며, `rosmac sim`이 +# 시작 시 브리지 세션을 리셋해 해소한다(스코핑 불필요, KI-30 정정). launch: cmd: "ros2 launch ~/rosmac-presets/nav2-diffbot/nav2-diffbot.launch.py" foxglove_layout: nav2.json diff --git a/src/rosmac/sim.py b/src/rosmac/sim.py index e32ec1d..301ea19 100644 --- a/src/rosmac/sim.py +++ b/src/rosmac/sim.py @@ -4,7 +4,6 @@ tmux인 이유: --attach 로그 관찰, 세션 유지, 이중 실행 감지 (phase2 2.2). """ -import json import time from importlib import resources from pathlib import Path @@ -20,23 +19,6 @@ SIM_LOG = "/tmp/rosmac-sim.log" USER_PRESET_DIR = Path.home() / ".rosmac" / "presets" -# VM 브리지 스코핑 (KI-30) — nav2류 대형 스택은 서비스/액션 엔티티가 맥 디스커버리를 -# 포화시켜 무스코프 브리지로는 라우팅 실패. 프리셋이 bridge_allow를 선언하면 -# systemd 드롭인으로 브리지를 화이트리스트 스코프로 재기동, sim stop 시 복원. -_SCOPE_CFG_VM = "/etc/rosmac/bridge-scope.json5" -_SCOPE_DROPIN = "/etc/systemd/system/zenoh-bridge.service.d/rosmac-scope.conf" -_ALLOW_KEYS = ( - "publishers", - "subscribers", - "service_servers", - "service_clients", - "action_servers", - "action_clients", -) -# doctor C8 왕복 자가진단 토픽 (doctor.py: /rosmac/doctor/) — 스코프 중에도 -# 브리지 경계를 넘도록 pub/sub에 상시 주입 (정규식 매칭). -_DOCTOR_TOPIC = "/rosmac/doctor/.*" - class HealthTopic(BaseModel): name: str @@ -55,10 +37,6 @@ class Preset(BaseModel): launch: Launch foxglove_layout: str | None = None health_topics: list[HealthTopic] = [] - # 선언 시 VM 브리지를 이 인터페이스만 브리지하도록 스코핑 (KI-30). 키: - # publishers/subscribers/service_servers/service_clients/action_servers/action_clients. - # 미지정 카테고리는 deny(빈 목록). None이면 무스코프(기존 동작). - bridge_allow: dict[str, list[str]] | None = None def _asset_preset_dir(): @@ -140,68 +118,6 @@ def _push_preset_assets(cfg: Config, preset: Preset) -> None: ) -def bridge_scope_config(bridge_allow: dict[str, list[str]]) -> str: - """bridge_allow → zenoh-bridge-ros2dds allow config (JSON, JSON5 호환). - - 6개 카테고리를 모두 채운다 — 미지정 카테고리는 빈 목록(deny)으로 명시해 - "전부 허용" 기본값으로 새지 않게 한다 (KI-30 핵심 — 서비스류를 확실히 deny). - doctor C8 왕복 토픽(/rosmac/doctor/*)은 스코프 중에도 자가진단이 되도록 - pub/sub에 항상 주입한다. - """ - allow = {k: list(bridge_allow.get(k, [])) for k in _ALLOW_KEYS} - for direction in ("publishers", "subscribers"): - if _DOCTOR_TOPIC not in allow[direction]: - allow[direction] = [*allow[direction], _DOCTOR_TOPIC] - return json.dumps({"plugins": {"ros2dds": {"allow": allow}}}, indent=2) - - -def _apply_bridge_scope(cfg: Config, preset: Preset, progress=None) -> None: - """프리셋 bridge_allow로 VM 브리지를 스코핑 재기동 + 맥 브리지 라우트 갱신.""" - if not preset.bridge_allow: - return - cfg_text = bridge_scope_config(preset.bridge_allow) - dropin = ( - "[Service]\n" - "ExecStart=\n" - f"ExecStart=/usr/local/bin/zenoh-bridge-ros2dds -l tcp/0.0.0.0:{cfg.bridge.port}" - f" -c {_SCOPE_CFG_VM}\n" - ) - # config·드롭인을 파일로 push(셸 이스케이프 회피) 후 sudo로 배치·브리지 재기동 - # (KillSignal=SIGTERM → KI-17 안전) - lima.push(cfg.vm.name, cfg_text, "~/.rosmac-bridge-scope.json5") - lima.push(cfg.vm.name, dropin, "~/.rosmac-bridge-dropin.conf") - lima.shell( - cfg.vm.name, - f"sudo mkdir -p /etc/rosmac $(dirname {_SCOPE_DROPIN}) && " - f"sudo cp ~/.rosmac-bridge-scope.json5 {_SCOPE_CFG_VM} && " - f"sudo cp ~/.rosmac-bridge-dropin.conf {_SCOPE_DROPIN} && " - "sudo systemctl daemon-reload && sudo systemctl restart zenoh-bridge", - timeout=60, - ) - if progress: - progress("✓ VM bridge scoped to preset interfaces (KI-30)") - _reset_mac_bridge(cfg) - - -def _clear_bridge_scope(cfg: Config, progress=None) -> bool: - """스코프 드롭인이 있으면 제거하고 무스코프 브리지로 복원. 복원했으면 True.""" - present = lima.shell( - cfg.vm.name, f"test -f {_SCOPE_DROPIN} && echo yes || echo no" - ).strip() - if present != "yes": - return False - lima.shell( - cfg.vm.name, - f"sudo rm -f {_SCOPE_DROPIN} && " - "sudo systemctl daemon-reload && sudo systemctl restart zenoh-bridge", - timeout=60, - ) - if progress: - progress("✓ VM bridge scope cleared (unscoped restored)") - _reset_mac_bridge(cfg) - return True - - def _reset_mac_bridge(cfg: Config) -> None: """VM 브리지 재기동 후 맥 브리지의 낡은 라우트를 재기동으로 정리 (N2 실측 플로우).""" if bridge.is_running(): @@ -209,6 +125,21 @@ def _reset_mac_bridge(cfg: Config) -> None: bridge.start(cfg) +def _reset_bridge_session(cfg: Config, progress=None) -> None: + """sim 스택 시작 전에 양측 브리지 세션을 리셋 (KI-17/KI-30 진짜 해법 — S0 실측). + + 스택을 재기동해도 VM 브리지는 죽은 스택들의 라우트를 누적해, 새 스택의 액션 + 하위 서비스 디스커버리가 맥에서 오염돼 `wait_for_server`가 실패한다(0/6). + 브리지를 리셋하면 신선한 라우트로 동일 스택이 4/4 성공. `rosmac up`의 KI-17 + 방지와 동일 패턴. (스코핑은 불필요 — N2/N3에서 통한 건 스코프 적용이 브리지를 + 재시작한 부수효과였음.) + """ + lima.shell(cfg.vm.name, "sudo systemctl restart zenoh-bridge", timeout=30) + _reset_mac_bridge(cfg) + if progress: + progress("✓ bridge session reset (KI-17 — fresh routes for new stack)") + + def start(cfg: Config, preset: Preset, progress=None) -> None: """tmux 세션으로 launch.cmd 실행. 이미 세션이 있으면 RuntimeError (R6 패턴).""" if session_alive(cfg): @@ -216,8 +147,8 @@ def start(cfg: Config, preset: Preset, progress=None) -> None: f"tmux session '{SESSION}' already exists — use rosmac sim status/stop or --attach" ) _push_preset_assets(cfg, preset) - # 브리지 스코핑을 launch 전에 적용 — 스택이 뜨는 대로 스코프된 브리지가 라우팅 (KI-30) - _apply_bridge_scope(cfg, preset, progress=progress) + # 새 스택을 띄우기 전 브리지 세션 리셋 — 이전 스택의 라우트 잔재 제거 (KI-17/KI-30) + _reset_bridge_session(cfg, progress=progress) env_exports = " ".join( f"{k}={v}" for k, v in { @@ -276,8 +207,6 @@ def stop(cfg: Config, progress=None) -> bool: 'pkill -f "[p]arameter_bridge" 2>/dev/null; true', timeout=30, ) - # 스코핑됐던 브리지 복원 (멱등 — 드롭인 없으면 무동작) - _clear_bridge_scope(cfg, progress=progress) return alive diff --git a/tests/unit/test_sim.py b/tests/unit/test_sim.py index 171e9b6..cfd17b0 100644 --- a/tests/unit/test_sim.py +++ b/tests/unit/test_sim.py @@ -26,48 +26,10 @@ def test_user_preset_overrides(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) assert p.launch.cmd == "echo custom" -def test_nav2_preset_declares_bridge_scope() -> None: +def test_nav2_preset_loads_unscoped() -> None: + # nav2-diffbot은 무스코프 — 브리지 리셋(KI-17/KI-30 정정, sim 시작 시)이 진짜 해법 p = sim.load_preset("nav2-diffbot") assert p.name == "nav2-diffbot" assert "ros-humble-navigation2" in p.vm_apt - assert p.bridge_allow is not None - assert p.bridge_allow["action_servers"] == ["/navigate_to_pose"] - assert "/scan" in p.bridge_allow["publishers"] - assert "/cmd_vel" in p.bridge_allow["subscribers"] + assert not hasattr(p, "bridge_allow") # 스코핑 메커니즘 제거됨 assert p.health_topics[0].name == "/scan" - - -def test_preset_without_bridge_allow_is_unscoped() -> None: - # 기존 소형 프리셋은 무스코프(bridge_allow None) — 기존 동작 유지 (임계 미만) - assert sim.load_preset("panda-moveit").bridge_allow is None - assert sim.load_preset("gazebo-diffbot").bridge_allow is None - - -def test_bridge_scope_config_fills_all_six_categories() -> None: - # 미지정 카테고리도 빈 목록으로 명시 — "전부 허용" 기본값으로 새지 않게 (KI-30) - import json - - cfg = json.loads(sim.bridge_scope_config({"action_servers": ["/navigate_to_pose"]})) - allow = cfg["plugins"]["ros2dds"]["allow"] - assert set(allow.keys()) == { - "publishers", - "subscribers", - "service_servers", - "service_clients", - "action_servers", - "action_clients", - } - assert allow["action_servers"] == ["/navigate_to_pose"] - assert allow["service_servers"] == [] # 내부 서비스 deny (포화 방지) - - -def test_bridge_scope_always_allows_doctor_roundtrip() -> None: - # doctor C8 왕복 토픽은 스코프 중에도 자가진단되도록 pub/sub에 상시 주입 - import json - - allow = json.loads(sim.bridge_scope_config({"publishers": ["/scan"]}))[ - "plugins" - ]["ros2dds"]["allow"] - assert "/rosmac/doctor/.*" in allow["publishers"] - assert "/rosmac/doctor/.*" in allow["subscribers"] - assert "/scan" in allow["publishers"] # 프리셋 지정 항목도 보존 From 90afdcfec827b2920f5cbbfed556641acfd56c6b Mon Sep 17 00:00:00 2001 From: PythonToGo Date: Sun, 12 Jul 2026 12:23:41 +0200 Subject: [PATCH 4/4] =?UTF-8?q?[E.20]=20=ED=94=84=EB=A6=AC=EC=85=8B=20mac?= =?UTF-8?q?=5Fenv=5Fpkgs=20=E2=80=94=20=EB=A7=A5=20env=20msg=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EC=84=A4=EC=B9=98=20(E.20=20=EC=9E=94=EC=97=AC=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 맥에서 액션 goal을 보내려면 msg 패키지(nav2_msgs 등)가 맥 conda env에도 필요한데, 없으면 goal이 "server not available"로 조용히 실패했다(health/scan은 통과해 브리지 문제로 오인). `rosmac sim`이 프리셋 `mac_env_pkgs`를 맥 env에 자동 설치. - Preset.mac_env_pkgs 필드 + cli sim에서 설치 호출. - deps.ensure_installed(cfg, pkgs): 기존 installed_packages/install_missing 재사용, 미설치만 설치·반환(멱등). MoveIt(moveit_msgs)도 동일 필드로 커버 가능. - nav2-diffbot.yaml: mac_env_pkgs=[ros-humble-nav2-msgs]. - 유닛 2개(설치·멱등), nav2 프리셋 테스트에 필드 확인 추가. 96 tests, ruff/mypy clean. - E2E: nav2-msgs 제거 → sim이 자동 설치("✓ Mac env packages installed") → READY. - workflow(en/ko)·CHANGELOG: "수동 설치" → "sim 자동 설치". Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Usk65wXFwXBq6UgRxGn7Yi --- CHANGELOG.md | 5 ++++- docs/workflow.ko.md | 4 ++-- docs/workflow.md | 4 ++-- src/rosmac/assets/presets/nav2-diffbot.yaml | 4 ++++ src/rosmac/cli.py | 6 ++++++ src/rosmac/deps.py | 18 ++++++++++++++++++ src/rosmac/sim.py | 3 +++ tests/unit/test_deps.py | 21 +++++++++++++++++++++ tests/unit/test_sim.py | 2 ++ 9 files changed, 62 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d9a051b..0d296ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,7 +39,10 @@ + Nav2(풀스택, 기본 브리지), 맥에서 `/navigate_to_pose` goal 3/3 SUCCEEDED. `rosmac sim`이 시작 시 브리지 세션을 리셋해 이전 스택 라우트 잔재로 인한 액션 디스커버리 실패를 방지(KI-17). launch 신뢰성 stagger 타이밍. `viz --layout nav2`. - 맥측 nav2_msgs는 수동 설치 필요(백로그). (E.17) +- **프리셋 `mac_env_pkgs`**: 맥에서 액션 goal을 보내는 데 필요한 msg 패키지를 + `rosmac sim`이 맥 conda env에 자동 설치(멱등, `deps.ensure_installed`). nav2는 + nav2_msgs — 없으면 goal이 "server not available"로 조용히 실패하던 함정 제거. + (E.17/E.20) ### 실측 검증 (Phase 0/2/4 게이트) - 브리지 대역폭 10.3 MB/s (1MB@10Hz 무손실), MoveGroup 액션 왕복 3연속 SUCCEEDED diff --git a/docs/workflow.ko.md b/docs/workflow.ko.md index cdcb96d..38aaa82 100644 --- a/docs/workflow.ko.md +++ b/docs/workflow.ko.md @@ -67,8 +67,8 @@ goal이 브리지를 넘어 VM의 `/navigate_to_pose`로 가고, 로봇이 플 155·액션 12)도 기본 브리지로 동작한다 — 스코핑 불필요. `rosmac sim`이 시작 시 브리지 세션을 리셋해 새 스택이 신선한 라우트를 받게 하며, 이게 없으면 이전 스택의 라우트 잔재가 맥 쪽 액션 디스커버리를 조용히 깨뜨린다(KI-17). 맥에서 goal을 -보내려면 맥 env에 `ros-humble-nav2-msgs`가 필요하다 -(`micromamba install -c robostack-humble ros-humble-nav2-msgs`). +보내려면 맥 env에 `ros-humble-nav2-msgs`가 필요한데, `rosmac sim`이 자동 설치한다 +(프리셋의 `mac_env_pkgs`) — 수동 단계 없음. `rosmac deps`의 한계: **package.xml에 선언된** 의존성만 본다. 코드가 선언 없이 쓰는 실행 파일(launch의 `FindExecutable` 등, KI-26 사례)은 못 잡는다 — diff --git a/docs/workflow.md b/docs/workflow.md index 5e3fac3..2a6af33 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -70,8 +70,8 @@ full Nav2 stack (155 services, 12 actions) works over the default bridge — no scoping. `rosmac sim` resets the bridge session on start so the new stack gets fresh routes; without that, routes left over from a prior stack silently break Mac-side action discovery (KI-17). Sending goals from the Mac needs -`ros-humble-nav2-msgs` in the Mac env -(`micromamba install -c robostack-humble ros-humble-nav2-msgs`). +`ros-humble-nav2-msgs` in the Mac env — `rosmac sim` installs it automatically +(the preset's `mac_env_pkgs`), so no manual step. Limits of `rosmac deps`: it only sees dependencies **declared in package.xml**. Executables the code uses without declaring them (launch's `FindExecutable` diff --git a/src/rosmac/assets/presets/nav2-diffbot.yaml b/src/rosmac/assets/presets/nav2-diffbot.yaml index 3eceb72..837f8dd 100644 --- a/src/rosmac/assets/presets/nav2-diffbot.yaml +++ b/src/rosmac/assets/presets/nav2-diffbot.yaml @@ -3,6 +3,10 @@ description: "Nav2 이동로봇 내비게이션 (Gazebo diffbot + gpu_lidar + SL vm_apt: - ros-humble-navigation2 - ros-humble-nav2-bringup +# 맥에서 /navigate_to_pose goal을 보내려면 맥 conda env에도 msg 패키지가 필요 +# (없으면 goal이 "server not available"로 조용히 실패 — E.20). +mac_env_pkgs: + - ros-humble-nav2-msgs vm_env: {} # 무스코프 — nav2 풀스택도 기본 브리지로 맥에서 정상 동작한다(S0 실측). 이전에 # 실패로 본 것은 스택 재기동 시 브리지 라우트 잔재(KI-17) 때문이며, `rosmac sim`이 diff --git a/src/rosmac/cli.py b/src/rosmac/cli.py index 83fcc05..d6fcb17 100644 --- a/src/rosmac/cli.py +++ b/src/rosmac/cli.py @@ -643,6 +643,12 @@ def sim( installed = sim_mod.ensure_apt(cfg, preset.vm_apt, progress=lambda m: console.print(f" {m}")) if installed: console.print(f"✓ VM packages installed: {', '.join(installed)}") + # 맥 env msg 의존 (맥에서 액션 goal 보내는 데 필요 — 없으면 goal 침묵 실패, E.20) + mac_pkgs = depsmod.ensure_installed( + cfg, preset.mac_env_pkgs, progress=lambda m: console.print(f" {m}") + ) + if mac_pkgs: + console.print(f"✓ Mac env packages installed: {', '.join(mac_pkgs)}") sim_mod.start(cfg, preset) console.print(f"✓ tmux session '{sim_mod.SESSION}' started — logs: rosmac sim --attach") with console.status("[cyan]waiting for health topics…[/]"): diff --git a/src/rosmac/deps.py b/src/rosmac/deps.py index 0d34962..d390146 100644 --- a/src/rosmac/deps.py +++ b/src/rosmac/deps.py @@ -162,3 +162,21 @@ def install_missing(cfg: Config, pkgs: list[str], timeout: int = 1800) -> None: ], timeout=timeout, ) + + +def ensure_installed(cfg: Config, pkgs: list[str], progress=None) -> list[str]: + """맥 env에 미설치 패키지만 설치, 설치한 목록 반환 (멱등 — vm_apt의 맥판). + + 프리셋 mac_env_pkgs용: 맥에서 액션 goal을 보내려면 msg 패키지(nav2_msgs 등)가 + 맥 env에도 필요한데, 없으면 goal이 조용히 "server not available"로 실패한다 + (E.20 — health/scan은 통과하는데 goal만 실패해 브리지 문제로 오인하기 쉬움). + """ + if not pkgs: + return [] + missing = [p for p in pkgs if p not in installed_packages(cfg)] + if not missing: + return [] + if progress: + progress(f"conda install (mac env): {', '.join(missing)}") + install_missing(cfg, missing, timeout=600) + return missing diff --git a/src/rosmac/sim.py b/src/rosmac/sim.py index 301ea19..d9e138f 100644 --- a/src/rosmac/sim.py +++ b/src/rosmac/sim.py @@ -33,6 +33,9 @@ class Preset(BaseModel): name: str description: str = "" vm_apt: list[str] = [] + # 맥 conda env에 보장할 패키지 (msg 등) — 맥에서 액션 goal을 보내는 데 필요. + # vm_apt의 맥판. 없으면 goal이 조용히 "server not available"로 실패 (E.20). + mac_env_pkgs: list[str] = [] vm_env: dict[str, str] = {} launch: Launch foxglove_layout: str | None = None diff --git a/tests/unit/test_deps.py b/tests/unit/test_deps.py index 37044e6..9dfffad 100644 --- a/tests/unit/test_deps.py +++ b/tests/unit/test_deps.py @@ -23,6 +23,27 @@ def test_map_dep_rules() -> None: assert deps.map_dep("SomeCamelCase") is None +def test_ensure_installed_only_missing(monkeypatch) -> None: + cfg = Config() + monkeypatch.setattr(deps, "installed_packages", lambda _cfg: {"ros-humble-nav2-msgs"}) + called: list[list[str]] = [] + monkeypatch.setattr(deps, "install_missing", lambda _cfg, pkgs, timeout=1800: called.append(pkgs)) + # 이미 있는 것 + 없는 것 → 없는 것만 설치, 반환 + out = deps.ensure_installed(cfg, ["ros-humble-nav2-msgs", "ros-humble-moveit-msgs"]) + assert out == ["ros-humble-moveit-msgs"] + assert called == [["ros-humble-moveit-msgs"]] + + +def test_ensure_installed_noop_when_all_present(monkeypatch) -> None: + cfg = Config() + monkeypatch.setattr(deps, "installed_packages", lambda _cfg: {"ros-humble-nav2-msgs"}) + called: list[list[str]] = [] + monkeypatch.setattr(deps, "install_missing", lambda _cfg, pkgs, timeout=1800: called.append(pkgs)) + assert deps.ensure_installed(cfg, ["ros-humble-nav2-msgs"]) == [] + assert deps.ensure_installed(cfg, []) == [] + assert called == [] # 전부 있으면 micromamba install 호출 안 함 (멱등) + + def test_scan_workspace_collects_and_separates_local() -> None: declared, local, broken = deps.scan_workspace(FIXTURE_WS / "src") assert local == {"alpha", "beta"} diff --git a/tests/unit/test_sim.py b/tests/unit/test_sim.py index cfd17b0..3503865 100644 --- a/tests/unit/test_sim.py +++ b/tests/unit/test_sim.py @@ -33,3 +33,5 @@ def test_nav2_preset_loads_unscoped() -> None: assert "ros-humble-navigation2" in p.vm_apt assert not hasattr(p, "bridge_allow") # 스코핑 메커니즘 제거됨 assert p.health_topics[0].name == "/scan" + # 맥에서 goal 보내는 데 필요한 msg — sim이 맥 env에 자동 설치 (E.20) + assert "ros-humble-nav2-msgs" in p.mac_env_pkgs