@rainbow-robotics/muscat-plugin-kit 1.3.2 → 1.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/cli/docker/Dockerfile.cpp +22 -5
  2. package/cli/docker/Dockerfile.dev +25 -5
  3. package/cli/docker/Dockerfile.python +19 -0
  4. package/cli/src/bridge.mjs +19 -2
  5. package/cli/src/build.mjs +31 -79
  6. package/cli/src/cpp.mjs +10 -6
  7. package/cli/src/dev.mjs +7 -2
  8. package/cli/src/devBack.mjs +44 -34
  9. package/cli/src/host_agent.py +143 -83
  10. package/cli/src/mcp.mjs +75 -16
  11. package/cli/src/new.mjs +5 -2
  12. package/cli/src/ports.mjs +21 -7
  13. package/cli/src/python.mjs +16 -20
  14. package/cli/src/ros2.mjs +13 -8
  15. package/cli/src/sdk.mjs +23 -21
  16. package/cli/src/templates-embedded.mjs +1 -1
  17. package/cli/src/type.mjs +42 -8
  18. package/cli/src/util.mjs +53 -5
  19. package/cli/storybook-dist/assets/{Color-YHDXOIA2-CRNvRzJD.js → Color-YHDXOIA2-DPI4Is-F.js} +1 -1
  20. package/cli/storybook-dist/assets/{Docs-C4S_Kfa9.js → Docs-CKTHz6Db.js} +1 -1
  21. package/cli/storybook-dist/assets/{Docs-DurNQvJo.js → Docs-CQnf2HJu.js} +1 -1
  22. package/cli/storybook-dist/assets/{Docs-BV0FgAF_.js → Docs-CbCty_Qg.js} +1 -1
  23. package/cli/storybook-dist/assets/{Docs-axk94PIl.js → Docs-DDYksosp.js} +1 -1
  24. package/cli/storybook-dist/assets/{Docs-80l_9VTs.js → Docs-Dow4_Rnl.js} +1 -1
  25. package/cli/storybook-dist/assets/{Docs-BgDx9SSn.js → Docs-TMe-1-I2.js} +1 -1
  26. package/cli/storybook-dist/assets/{DocsRenderer-CFRXHY34-CEdKXElc.js → DocsRenderer-CFRXHY34-DSTzrCAK.js} +1 -1
  27. package/cli/storybook-dist/assets/{NxAvatar.stories-CyZ4AE2b.js → NxAvatar.stories-B3OxXBgc.js} +1 -1
  28. package/cli/storybook-dist/assets/{NxButton.stories-Bx8UtGNd.js → NxButton.stories-UTqX8ZkA.js} +1 -1
  29. package/cli/storybook-dist/assets/{NxNumberInput.stories-skizAdd5.js → NxNumberInput.stories-GoLEe-uJ.js} +1 -1
  30. package/cli/storybook-dist/assets/{NxPagination.stories-gtIxt2u0.js → NxPagination.stories-Jj6dt6eG.js} +1 -1
  31. package/cli/storybook-dist/assets/{NxPointerHoldButton.stories-DSHMk3N5.js → NxPointerHoldButton.stories-tLN9u4nC.js} +1 -1
  32. package/cli/storybook-dist/assets/{NxRangeInput.stories-C1YtrFZ1.js → NxRangeInput.stories-C-MK73xb.js} +1 -1
  33. package/cli/storybook-dist/assets/{NxSelectBox.stories-DCySAtBp.js → NxSelectBox.stories-CBcFcOIX.js} +1 -1
  34. package/cli/storybook-dist/assets/{NxSlideRange.stories-gSaMOWo7.js → NxSlideRange.stories-oNQkoW66.js} +1 -1
  35. package/cli/storybook-dist/assets/{NxSwitch.stories-DF0DBiyG.js → NxSwitch.stories-CqBkO5GC.js} +1 -1
  36. package/cli/storybook-dist/assets/{NxTextArea.stories-Dill0M6z.js → NxTextArea.stories-DtysSyl4.js} +1 -1
  37. package/cli/storybook-dist/assets/{NxTextInput.stories-Bax14r48.js → NxTextInput.stories-BOLDbSgF.js} +1 -1
  38. package/cli/storybook-dist/assets/{RBAccordion.stories-CCwz_pIV.js → RBAccordion.stories-D9-wu9LY.js} +1 -1
  39. package/cli/storybook-dist/assets/{RBAccordionGroup.stories-BjDxxdPV.js → RBAccordionGroup.stories-BPfnIKm6.js} +1 -1
  40. package/cli/storybook-dist/assets/{RBAlert.stories-5z6GasgP.js → RBAlert.stories-CGn-c2ac.js} +1 -1
  41. package/cli/storybook-dist/assets/{RBBadge.stories-BwcM7i7w.js → RBBadge.stories-CBLmeoq1.js} +1 -1
  42. package/cli/storybook-dist/assets/{RBBattery.stories-DOeQH3rK.js → RBBattery.stories-oo47rbDh.js} +1 -1
  43. package/cli/storybook-dist/assets/{RBCard.stories-DcmiOXAV.js → RBCard.stories-SiD2VfQ4.js} +1 -1
  44. package/cli/storybook-dist/assets/{RBCheckBox.stories-BYru87fX.js → RBCheckBox.stories-sEEVrjSg.js} +1 -1
  45. package/cli/storybook-dist/assets/{RBCheckIconButton.stories-CXUjtR1a.js → RBCheckIconButton.stories-C5GQUjZf.js} +1 -1
  46. package/cli/storybook-dist/assets/{RBCheckLabelButton.stories-BSOQV7Du.js → RBCheckLabelButton.stories-DTaTqBvJ.js} +1 -1
  47. package/cli/storybook-dist/assets/{RBChipTab.stories-ByBmeukU.js → RBChipTab.stories-3AZr4kOz.js} +1 -1
  48. package/cli/storybook-dist/assets/{RBChipsBoxTab.stories-GtJ01RTr.js → RBChipsBoxTab.stories-hCJnBYQl.js} +1 -1
  49. package/cli/storybook-dist/assets/{RBColorIcons.stories-C9saET1u.js → RBColorIcons.stories-BX4RJygu.js} +1 -1
  50. package/cli/storybook-dist/assets/{RBCommand.stories-CcvnWlWZ.js → RBCommand.stories-BidZ4H8f.js} +1 -1
  51. package/cli/storybook-dist/assets/{RBConfirm.stories-hdBVSdBS.js → RBConfirm.stories-D3Txw1kT.js} +1 -1
  52. package/cli/storybook-dist/assets/{RBDateInput.stories-BE9JQl-E.js → RBDateInput.stories-CLxRaN6T.js} +1 -1
  53. package/cli/storybook-dist/assets/{RBDialog.stories-BaWuDgl_.js → RBDialog.stories-CEhmUGp-.js} +1 -1
  54. package/cli/storybook-dist/assets/{RBDivider.stories-D3INKm_Z.js → RBDivider.stories-peXP2KG9.js} +1 -1
  55. package/cli/storybook-dist/assets/{RBDrawer.stories-BZ8V0J6-.js → RBDrawer.stories-38iDf6uC.js} +1 -1
  56. package/cli/storybook-dist/assets/{RBDropdownMenu.stories-DxLi8HRw.js → RBDropdownMenu.stories-Dsi8tqSy.js} +1 -1
  57. package/cli/storybook-dist/assets/{RBEmptyView.stories-DQcvDt2S.js → RBEmptyView.stories-VMxWhzLP.js} +1 -1
  58. package/cli/storybook-dist/assets/{RBIconButton.stories-DJfuTpQ0.js → RBIconButton.stories-BPLhceGO.js} +1 -1
  59. package/cli/storybook-dist/assets/{RBIcons.stories-BSsyb0BK.js → RBIcons.stories-DuYdO951.js} +1 -1
  60. package/cli/storybook-dist/assets/{RBInlineLabelButton.stories-Di3th--x.js → RBInlineLabelButton.stories-CGNYhQyl.js} +1 -1
  61. package/cli/storybook-dist/assets/{RBJoyStick.stories-DfGazm4n.js → RBJoyStick.stories-CCmn-LMy.js} +1 -1
  62. package/cli/storybook-dist/assets/{RBLabelButton.stories-CLbxzW56.js → RBLabelButton.stories-IkKjthGk.js} +1 -1
  63. package/cli/storybook-dist/assets/{RBLight.stories-DeiuDm_P.js → RBLight.stories-eJFQOjPH.js} +1 -1
  64. package/cli/storybook-dist/assets/{RBMenubar.stories-BZQTV9e5.js → RBMenubar.stories-kg-xXGrW.js} +1 -1
  65. package/cli/storybook-dist/assets/{RBMultiRangeInput.stories-DDNmdi_n.js → RBMultiRangeInput.stories-Czr1r3gd.js} +1 -1
  66. package/cli/storybook-dist/assets/{RBPagination.stories-CLBCEyCU.js → RBPagination.stories-Dyu6Khnl.js} +1 -1
  67. package/cli/storybook-dist/assets/{RBPopover.stories-DRI5Kfa-.js → RBPopover.stories-CAM6hpf2.js} +1 -1
  68. package/cli/storybook-dist/assets/{RBPrompt.stories-DIAmypoJ.js → RBPrompt.stories-B4M-veaf.js} +1 -1
  69. package/cli/storybook-dist/assets/{RBRadioBox.stories-BS_N4aLf.js → RBRadioBox.stories-DeAOKvil.js} +1 -1
  70. package/cli/storybook-dist/assets/{RBRadioButton.stories-BTm8cYvm.js → RBRadioButton.stories-DjXwYXFy.js} +1 -1
  71. package/cli/storybook-dist/assets/{RBRangeDateInput.stories-D5hBsSyB.js → RBRangeDateInput.stories-9pzKhXGf.js} +1 -1
  72. package/cli/storybook-dist/assets/{RBResizable.stories-CxPEkbXQ.js → RBResizable.stories-B4Hi4DUD.js} +1 -1
  73. package/cli/storybook-dist/assets/{RBSelectBox.stories-FUQ7AcUi.js → RBSelectBox.stories-tVVv4c6z.js} +1 -1
  74. package/cli/storybook-dist/assets/{RBSheet.stories-mhVHH_71.js → RBSheet.stories-BwOCmaNN.js} +1 -1
  75. package/cli/storybook-dist/assets/{RBSideNumberInput.stories-5FMkLUim.js → RBSideNumberInput.stories-BbGOIitU.js} +1 -1
  76. package/cli/storybook-dist/assets/{RBSlider.stories-Bnhcp9pz.js → RBSlider.stories-yIDdawYQ.js} +1 -1
  77. package/cli/storybook-dist/assets/{RBSpinner.stories-Bf4416mP.js → RBSpinner.stories-CIJvpbgK.js} +1 -1
  78. package/cli/storybook-dist/assets/{RBSwitch.stories-BNhFjRaV.js → RBSwitch.stories-Bo8hrSiq.js} +1 -1
  79. package/cli/storybook-dist/assets/{RBTab.stories-Bsr7jlfb.js → RBTab.stories-Cq3N4Dgb.js} +1 -1
  80. package/cli/storybook-dist/assets/{RBTable.stories-BkA6pKzs.js → RBTable.stories-DDeZf-Wb.js} +1 -1
  81. package/cli/storybook-dist/assets/{RBTableRow.stories-D07Z7D5-.js → RBTableRow.stories-BeKVMSo3.js} +1 -1
  82. package/cli/storybook-dist/assets/{RBTabs.stories-DHnZiV21.js → RBTabs.stories-DovB7PPB.js} +1 -1
  83. package/cli/storybook-dist/assets/{RBTextArea.stories-CtLMLuV_.js → RBTextArea.stories-D7H4RvP8.js} +1 -1
  84. package/cli/storybook-dist/assets/{RBTextField.stories-Chz0i1-u.js → RBTextField.stories-CmkNXeEk.js} +1 -1
  85. package/cli/storybook-dist/assets/{RBTextInput.stories-Dk5cZfUF.js → RBTextInput.stories-hnxbQJsb.js} +1 -1
  86. package/cli/storybook-dist/assets/{RBToast.stories-tZxbjmr8.js → RBToast.stories-BklGGkNh.js} +1 -1
  87. package/cli/storybook-dist/assets/{RBToggle.stories-VTe7a4xZ.js → RBToggle.stories-C56AXyxq.js} +1 -1
  88. package/cli/storybook-dist/assets/{RBToggleGroup.stories-WiFnGGYx.js → RBToggleGroup.stories-CpLPdAF0.js} +1 -1
  89. package/cli/storybook-dist/assets/{RBTooltip.stories-B_L4W7Mn.js → RBTooltip.stories-BdOnCwGk.js} +1 -1
  90. package/cli/storybook-dist/assets/{SideNumberInput-os5wKKf7.js → SideNumberInput-DLmM2_Dy.js} +80 -80
  91. package/cli/storybook-dist/assets/{entry-preview-COJgWzHb.js → entry-preview-tBizwvut.js} +1 -1
  92. package/cli/storybook-dist/assets/{iframe-DQEjJuXA.js → iframe-BVXraKDc.js} +2 -2
  93. package/cli/storybook-dist/assets/{index-DEgQqQ_-.js → index-CI3FxSvF.js} +3 -3
  94. package/cli/storybook-dist/assets/{preview-BZ5PpEp_.js → preview-BUOha-H6.js} +2 -2
  95. package/cli/storybook-dist/assets/preview-DGIRF99V.js +3 -0
  96. package/cli/storybook-dist/iframe.html +1 -1
  97. package/cli/storybook-dist/project.json +1 -1
  98. package/cli/templates/common/README.md +65 -4
  99. package/cli/templates/common/py/main.py +5 -1
  100. package/cli/templates/common/requirements.txt +3 -3
  101. package/cli/templates/cpp/cpp/CMakeLists.txt +1 -2
  102. package/cli/templates/cpp/cpp/main.cpp +48 -8
  103. package/cli/templates/readme-cpp/README.md +969 -0
  104. package/cli/templates/ros2-cpp/ros2_ws/src/__PLUGIN_SLUG__/CMakeLists.txt +1 -1
  105. package/package.json +3 -3
  106. package/cli/storybook-dist/assets/preview-CmCb8Xea.js +0 -3
@@ -10,18 +10,35 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
10
10
  cmake \
11
11
  && rm -rf /var/lib/apt/lists/*
12
12
 
13
- # Rainbow Robotics C++ SDK (rainbow-rb-sdk-dev): headers + CMake config for
14
- # find_package(rb_sdk). Pinned to the robot's SDK version — the binary links
15
- # librb_sdk.so.<ver> by SONAME and the robot provides that exact .so at runtime.
13
+ # Rainbow Robotics C++ SDK for find_package(rb_sdk).
16
14
  # Override with MUSCAT_PK_RB_SDK_VERSION=<ver> (+ MUSCAT_PK_REBUILD_IMAGE=1).
17
- ARG RB_SDK_VERSION=0.1.2~dev1
15
+ ARG RB_SDK_VERSION=0.1.3
18
16
  RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl \
19
17
  && install -d /etc/apt/keyrings \
20
18
  && curl -fsSL https://rainbowrobotics.github.io/rb-sdk/rb-sdk-apt-repo.gpg.key -o /etc/apt/keyrings/rb-sdk.asc \
21
- && echo 'deb [signed-by=/etc/apt/keyrings/rb-sdk.asc] https://rainbowrobotics.github.io/rb-sdk dev main' > /etc/apt/sources.list.d/rb-sdk.list \
19
+ && printf 'deb [signed-by=/etc/apt/keyrings/rb-sdk.asc] https://rainbowrobotics.github.io/rb-sdk %s main\n' stable dev > /etc/apt/sources.list.d/rb-sdk.list \
22
20
  && apt-get update && apt-get install -y --no-install-recommends "rainbow-rb-sdk-dev=${RB_SDK_VERSION}" \
23
21
  && rm -rf /var/lib/apt/lists/*
24
22
 
23
+ # MediaMTX — same version/ports as the Muscat runtime (rby2 scripts/backend/runtime/mediamtx.yml),
24
+ # plus webrtcAdditionalHosts so a browser on the dev host reaches the WebRTC media port.
25
+ ARG MEDIAMTX_VERSION=1.12.3
26
+ RUN set -eux; \
27
+ apt-get update; apt-get install -y --no-install-recommends ca-certificates curl; rm -rf /var/lib/apt/lists/*; \
28
+ case "$(uname -m)" in x86_64) a=amd64 ;; aarch64|arm64) a=arm64 ;; *) exit 1 ;; esac; \
29
+ curl -fsSL "https://github.com/bluenviron/mediamtx/releases/download/v${MEDIAMTX_VERSION}/mediamtx_v${MEDIAMTX_VERSION}_linux_${a}.tar.gz" \
30
+ | tar -xz -C /usr/local/bin mediamtx; \
31
+ mkdir -p /etc/mediamtx; \
32
+ printf '%s\n' \
33
+ 'logLevel: warn' \
34
+ 'rtsp: yes' 'rtspAddress: :8554' \
35
+ 'webrtc: yes' 'webrtcAddress: :8889' \
36
+ 'webrtcLocalUDPAddress: :8189' 'webrtcLocalTCPAddress: :8189' \
37
+ 'webrtcAdditionalHosts: [127.0.0.1]' 'webrtcAllowOrigin: "*"' \
38
+ 'hls: no' 'rtmp: no' 'srt: no' 'api: no' \
39
+ 'paths:' ' all_others:' ' source: publisher' \
40
+ > /etc/mediamtx/mediamtx.yml
41
+
25
42
  WORKDIR /plugin
26
43
 
27
44
  CMD ["bash"]
@@ -9,18 +9,38 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
9
9
  python3-colcon-common-extensions \
10
10
  && rm -rf /var/lib/apt/lists/*
11
11
 
12
- # Rainbow Robotics C++ SDK (rainbow-rb-sdk-dev): headers + CMake config for
13
- # find_package(rb_sdk). Pinned to the robot's SDK version — the binary links
14
- # librb_sdk.so.<ver> by SONAME and the robot provides that exact .so at runtime.
12
+ # Rainbow Robotics C++ SDK for find_package(rb_sdk).
15
13
  # Override with MUSCAT_PK_RB_SDK_VERSION=<ver> (+ MUSCAT_PK_REBUILD_IMAGE=1).
16
- ARG RB_SDK_VERSION=0.1.2~dev1
14
+ ARG RB_SDK_VERSION=0.1.3
17
15
  RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl \
18
16
  && install -d /etc/apt/keyrings \
19
17
  && curl -fsSL https://rainbowrobotics.github.io/rb-sdk/rb-sdk-apt-repo.gpg.key -o /etc/apt/keyrings/rb-sdk.asc \
20
- && echo 'deb [signed-by=/etc/apt/keyrings/rb-sdk.asc] https://rainbowrobotics.github.io/rb-sdk dev main' > /etc/apt/sources.list.d/rb-sdk.list \
18
+ && printf 'deb [signed-by=/etc/apt/keyrings/rb-sdk.asc] https://rainbowrobotics.github.io/rb-sdk %s main\n' stable dev > /etc/apt/sources.list.d/rb-sdk.list \
21
19
  && apt-get update && apt-get install -y --no-install-recommends "rainbow-rb-sdk-dev=${RB_SDK_VERSION}" \
22
20
  && rm -rf /var/lib/apt/lists/*
23
21
 
22
+ # MediaMTX — same version/ports as the Muscat runtime (rby2 scripts/backend/runtime/mediamtx.yml),
23
+ # plus webrtcAdditionalHosts so a browser on the dev host reaches the WebRTC media port.
24
+ ARG MEDIAMTX_VERSION=1.12.3
25
+ RUN set -eux; \
26
+ apt-get update; apt-get install -y --no-install-recommends ca-certificates curl; rm -rf /var/lib/apt/lists/*; \
27
+ case "$(uname -m)" in x86_64) a=amd64 ;; aarch64|arm64) a=arm64 ;; *) exit 1 ;; esac; \
28
+ curl -fsSL "https://github.com/bluenviron/mediamtx/releases/download/v${MEDIAMTX_VERSION}/mediamtx_v${MEDIAMTX_VERSION}_linux_${a}.tar.gz" \
29
+ | tar -xz -C /usr/local/bin mediamtx; \
30
+ mkdir -p /etc/mediamtx; \
31
+ printf '%s\n' \
32
+ 'logLevel: warn' \
33
+ 'rtsp: yes' 'rtspAddress: :8554' \
34
+ 'webrtc: yes' 'webrtcAddress: :8889' \
35
+ 'webrtcLocalUDPAddress: :8189' 'webrtcLocalTCPAddress: :8189' \
36
+ 'webrtcAdditionalHosts: [127.0.0.1]' 'webrtcAllowOrigin: "*"' \
37
+ 'hls: no' 'rtmp: no' 'srt: no' 'api: no' \
38
+ 'paths:' ' all_others:' ' source: publisher' \
39
+ > /etc/mediamtx/mediamtx.yml
40
+
41
+ # `docker exec` skips /ros_entrypoint.sh — source ROS for the login shells dev:back runs.
42
+ RUN echo ". /opt/ros/${ROS_DISTRO}/setup.sh" > /etc/profile.d/ros.sh
43
+
24
44
  WORKDIR /plugin
25
45
 
26
46
  CMD ["bash"]
@@ -15,6 +15,25 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
15
15
  # cpp SDK shared lib must be present for the python module to load. Install the
16
16
  # rainbow cpp SDK (apt, pinned to the robot's version) here once it exists.
17
17
 
18
+ # MediaMTX — same version/ports as the Muscat runtime (rby2 scripts/backend/runtime/mediamtx.yml),
19
+ # plus webrtcAdditionalHosts so a browser on the dev host reaches the WebRTC media port.
20
+ ARG MEDIAMTX_VERSION=1.12.3
21
+ RUN set -eux; \
22
+ apt-get update; apt-get install -y --no-install-recommends ca-certificates curl; rm -rf /var/lib/apt/lists/*; \
23
+ case "$(uname -m)" in x86_64) a=amd64 ;; aarch64|arm64) a=arm64 ;; *) exit 1 ;; esac; \
24
+ curl -fsSL "https://github.com/bluenviron/mediamtx/releases/download/v${MEDIAMTX_VERSION}/mediamtx_v${MEDIAMTX_VERSION}_linux_${a}.tar.gz" \
25
+ | tar -xz -C /usr/local/bin mediamtx; \
26
+ mkdir -p /etc/mediamtx; \
27
+ printf '%s\n' \
28
+ 'logLevel: warn' \
29
+ 'rtsp: yes' 'rtspAddress: :8554' \
30
+ 'webrtc: yes' 'webrtcAddress: :8889' \
31
+ 'webrtcLocalUDPAddress: :8189' 'webrtcLocalTCPAddress: :8189' \
32
+ 'webrtcAdditionalHosts: [127.0.0.1]' 'webrtcAllowOrigin: "*"' \
33
+ 'hls: no' 'rtmp: no' 'srt: no' 'api: no' \
34
+ 'paths:' ' all_others:' ' source: publisher' \
35
+ > /etc/mediamtx/mediamtx.yml
36
+
18
37
  WORKDIR /plugin
19
38
 
20
39
  CMD ["bash"]
@@ -8,6 +8,8 @@ import { createServer, connect } from "node:net";
8
8
  import { existsSync } from "node:fs";
9
9
  import { resolve } from "node:path";
10
10
  import { canSkipImageBuild, hasBin, kitRoot, log, pruneMuskatImages } from "./util.mjs";
11
+ import { MEDIA_PORTS, mediaPortFlags } from "./ports.mjs";
12
+ import { usesHostNetwork } from "./zenohDev.mjs";
11
13
 
12
14
  export const BRIDGE_IMAGE = "muscat-pk-bridge";
13
15
  export const BRIDGE_CONTAINER = "muscat-pk-bridge-run";
@@ -63,9 +65,16 @@ function ensureImage() {
63
65
  pruneMuskatImages();
64
66
  }
65
67
 
68
+ /** A running Muscat container (e.g. muscat-dev) that owns host port 10000, or null. */
69
+ export function localMuscatContainer() {
70
+ const r = spawnSync("docker", ["ps", "--filter", `publish=${BRIDGE_PORT}`, "--format", "{{.Names}}"], { encoding: "utf8" });
71
+ return (r.stdout || "").split("\n").map((n) => n.trim()).find((n) => n && n !== BRIDGE_CONTAINER) ?? null;
72
+ }
73
+
66
74
  /**
67
75
  * Build the image, preflight the port, and start the bridge container.
68
- * Returns true if bridge started, false if port was already in use (caller decides).
76
+ * Returns false if port 10000 was already in use (caller decides), else { media }:
77
+ * whether the MediaMTX ports are free for the backend's own MediaMTX.
69
78
  */
70
79
  export async function startBridge({ zenohPort = null, pluginPorts = [] } = {}) {
71
80
  assertDockerReady();
@@ -88,6 +97,13 @@ export async function startBridge({ zenohPort = null, pluginPorts = [] } = {}) {
88
97
  }
89
98
  const publishable = pluginPorts.filter((port) => !busy.includes(port));
90
99
 
100
+ const mediaBusy = [];
101
+ for (const port of MEDIA_PORTS) {
102
+ if (await isPortInUse(port)) mediaBusy.push(port);
103
+ }
104
+ const media = mediaBusy.length === 0;
105
+ if (!media) log.warn(`MediaMTX port(s) already in use: ${mediaBusy.join(", ")} — video streaming disabled in dev.`);
106
+
91
107
  ensureImage();
92
108
 
93
109
  log.step("starting Socket.IO bridge container…");
@@ -98,6 +114,7 @@ export async function startBridge({ zenohPort = null, pluginPorts = [] } = {}) {
98
114
  "-p", `127.0.0.1:${BRIDGE_PORT}:${BRIDGE_PORT}`,
99
115
  ...(zenohPort ? ["-p", `${zenohPort}:${zenohPort}`] : []),
100
116
  ...publishable.flatMap((port) => ["-p", `127.0.0.1:${port}:${port}`]),
117
+ ...(media && !usesHostNetwork() ? mediaPortFlags() : []),
101
118
  ];
102
119
  const up = spawnSync(
103
120
  "docker",
@@ -111,7 +128,7 @@ export async function startBridge({ zenohPort = null, pluginPorts = [] } = {}) {
111
128
  if (zenohPort) log.step(`Zenoh dev peer port published on :${zenohPort}`);
112
129
  if (publishable.length > 0)
113
130
  log.step(`plugin backend ports published on 127.0.0.1: ${publishable.join(", ")}`);
114
- return true;
131
+ return { media };
115
132
  }
116
133
 
117
134
  export function stopBridge() {
package/cli/src/build.mjs CHANGED
@@ -75,33 +75,19 @@ function runViteBuild(pluginRoot, { mode = "production" } = {}) {
75
75
  // rclpy: arch-agnostic (pure Python) → dist/ros2_ws/install/ (built once)
76
76
  // rclcpp: arch-specific C++ binaries → dist/ros2_ws/install-{arch}/ per arch
77
77
  export async function exportRos2Build(root, archs = ["amd64", "arm64"]) {
78
- if (manifestRuntime(root) !== "ros2") return [];
78
+ if (manifestRuntime(root) !== "ros2") return;
79
79
  const { assertDockerReady, ensureImage, colconBuild, ROS2_DEV_IMAGE, ARCH_DOCKER_PLATFORM } = await import("./ros2.mjs");
80
- try {
81
- assertDockerReady();
82
- } catch (e) {
83
- log.warn(`ros2 build skipped: ${e.message}`);
84
- return [];
85
- }
80
+ assertDockerReady();
86
81
 
87
82
  const manifest = JSON.parse(readFileSync(resolve(root, "manifest.json"), "utf8"));
88
- const rosClient = manifest.runtime?.rosClient;
89
-
90
- const skipped = [];
91
- if (rosClient === "rclcpp") {
83
+ if (manifest.runtime?.rosClient === "rclcpp") {
92
84
  // C++ ROS2: compile per arch so each robot picks the right install-{arch}/
93
85
  for (const arch of archs) {
94
86
  const dockerPlatform = ARCH_DOCKER_PLATFORM[arch];
95
- if (!dockerPlatform) { log.warn(`unknown arch '${arch}' — skipped`); skipped.push(arch); continue; }
96
- const archImage = `${ROS2_DEV_IMAGE}-${arch}`;
97
- try {
98
- await ensureImage(archImage, { platform: dockerPlatform });
99
- colconBuild(root, { symlink: false, arch });
100
- log.ok(`dist/ros2_ws/install-${arch}/ ready`);
101
- } catch {
102
- log.warn(`${arch} ros2 build skipped — QEMU not available`);
103
- skipped.push(arch);
104
- }
87
+ if (!dockerPlatform) throw new Error(`unknown arch '${arch}'`);
88
+ await ensureImage(`${ROS2_DEV_IMAGE}-${arch}`, { platform: dockerPlatform });
89
+ colconBuild(root, { symlink: false, arch });
90
+ log.ok(`dist/ros2_ws/install-${arch}/ ready`);
105
91
  }
106
92
  } else {
107
93
  // rclpy / pure Python: arch-agnostic, build once
@@ -109,54 +95,37 @@ export async function exportRos2Build(root, archs = ["amd64", "arm64"]) {
109
95
  colconBuild(root, { symlink: false });
110
96
  log.ok("dist/ros2_ws/install/ ready");
111
97
  }
112
- return skipped;
113
98
  }
114
99
 
115
100
  // For cpp plugins, compile cpp/ inside the Ubuntu container (Release) per arch
116
101
  // and copy each binary into dist/{arch}/{entry} so the ZIP carries relocatable
117
- // robot-compatible executables. rb-runtime prefers dist/{arch}/ and falls back
118
- // to dist/{entry} (legacy). Skipped (with a warning) if Docker is unavailable.
102
+ // robot-compatible executables.
119
103
  export async function exportCppBuild(root, distDir, archs = ["amd64", "arm64"]) {
120
- if (manifestRuntime(root) !== "cpp") return [];
104
+ if (manifestRuntime(root) !== "cpp") return;
121
105
  const { assertDockerReady, ensureImage, cmakeBuild, CPP_DEV_IMAGE, ARCH_DOCKER_PLATFORM } = await import("./cpp.mjs");
122
- try {
123
- assertDockerReady();
124
- } catch (e) {
125
- log.warn(`cpp build skipped: ${e.message}`);
126
- return [];
127
- }
106
+ assertDockerReady();
128
107
 
129
108
  const entry = JSON.parse(readFileSync(resolve(root, "manifest.json"), "utf8")).runtime?.entry;
130
- if (!entry) {
131
- log.warn("manifest.json runtime.entry not set — cpp build skipped");
132
- return;
133
- }
109
+ if (!entry) throw new Error("manifest.json runtime.entry is required for cpp (the built executable name)");
134
110
 
135
- const skipped = [];
136
111
  for (const arch of archs) {
137
112
  const dockerPlatform = ARCH_DOCKER_PLATFORM[arch];
138
- if (!dockerPlatform) { log.warn(`unknown arch '${arch}' — skipped`); skipped.push(arch); continue; }
139
- const archImage = `${CPP_DEV_IMAGE}-${arch}`;
140
- try {
141
- await ensureImage(archImage, { platform: dockerPlatform });
142
- cmakeBuild(root, { buildType: "Release", arch });
143
- const built = resolve(root, `cpp/build-${arch}`, entry);
144
- if (existsSync(built)) {
145
- const archDir = resolve(distDir, arch);
146
- mkdirSync(archDir, { recursive: true });
147
- const dest = resolve(archDir, entry);
148
- copyFileSync(built, dest);
149
- chmodSync(dest, 0o755);
150
- log.ok(`cpp binary → dist/${arch}/${entry}`);
151
- } else {
152
- log.warn(`cpp binary not found at cpp/build-${arch}/${entry}`);
153
- }
154
- } catch {
155
- log.warn(`${arch} cpp build skipped — QEMU not available`);
156
- skipped.push(arch);
113
+ if (!dockerPlatform) throw new Error(`unknown arch '${arch}'`);
114
+ await ensureImage(`${CPP_DEV_IMAGE}-${arch}`, { platform: dockerPlatform });
115
+ cmakeBuild(root, { buildType: "Release", arch });
116
+ const built = resolve(root, `cpp/build-${arch}`, entry);
117
+ if (!existsSync(built)) {
118
+ throw new Error(`cpp/build-${arch}/${entry} not found — the CMake target name must equal runtime.entry`);
157
119
  }
120
+ const archDir = resolve(distDir, arch);
121
+ mkdirSync(archDir, { recursive: true });
122
+ const dest = resolve(archDir, entry);
123
+ copyFileSync(built, dest);
124
+ chmodSync(dest, 0o755);
125
+ const libDir = resolve(root, `cpp/build-${arch}`, "lib");
126
+ if (existsSync(libDir)) cpSync(libDir, resolve(archDir, "lib"), { recursive: true, verbatimSymlinks: true });
127
+ log.ok(`cpp binary → dist/${arch}/${entry}${existsSync(libDir) ? " (+ lib/)" : ""}`);
158
128
  }
159
- return skipped;
160
129
  }
161
130
 
162
131
  const PRERELEASE_RE = /\.(dev|a|b|rc|alpha|beta)\d*/i;
@@ -426,11 +395,12 @@ export async function runBuild(args) {
426
395
 
427
396
  await promptVersion(root);
428
397
  const archs = await promptArch(args);
398
+ // Drop a previous ZIP of this version so a failed build can't leave a stale one to upload.
399
+ const version = JSON.parse(readFileSync(resolve(root, "manifest.json"), "utf8")).version || "0.0.0";
400
+ rmSync(resolve(root, `${basename(root)}-${version}.zip`), { force: true });
429
401
 
430
402
  const { pluginRoot, distDir } = await buildPlugin({ root, minify: true });
431
403
 
432
- const skippedArches = { wheels: [], ros2: [], cpp: [] };
433
-
434
404
  // python / rclpy: lock + wheel vendor in Ubuntu 24.04 container
435
405
  if (!noLock) {
436
406
  const manifest = JSON.parse(readFileSync(resolve(root, "manifest.json"), "utf8")).runtime ?? {};
@@ -439,30 +409,12 @@ export async function runBuild(args) {
439
409
  (manifest.type === "ros2" && manifest.rosClient === "rclpy");
440
410
  if (needsLock) {
441
411
  const { lockAndVendor } = await import("./python.mjs");
442
- try {
443
- skippedArches.wheels = await lockAndVendor(pluginRoot, archs);
444
- } catch (e) {
445
- log.warn(`python lock/vendor skipped: ${e.message}`);
446
- skippedArches.wheels = archs;
447
- }
412
+ await lockAndVendor(pluginRoot, archs);
448
413
  }
449
414
  }
450
415
 
451
- skippedArches.ros2 = await exportRos2Build(root, archs) ?? [];
452
- skippedArches.cpp = await exportCppBuild(root, distDir, archs) ?? [];
416
+ await exportRos2Build(root, archs);
417
+ await exportCppBuild(root, distDir, archs);
453
418
  calcInstalledSize(pluginRoot);
454
419
  if (!noZip) packZip(pluginRoot);
455
-
456
- // Build summary
457
- const allSkipped = [...new Set([...skippedArches.wheels, ...skippedArches.ros2, ...skippedArches.cpp])];
458
- if (allSkipped.length > 0) {
459
- const line = "─".repeat(58);
460
- console.log(`\n${line}`);
461
- log.warn(`Cross-arch builds skipped (QEMU not available): ${allSkipped.join(", ")}`);
462
- if (skippedArches.wheels.length) log.warn(` wheels: ${skippedArches.wheels.join(", ")}`);
463
- if (skippedArches.ros2.length) log.warn(` ros2: ${skippedArches.ros2.join(", ")}`);
464
- if (skippedArches.cpp.length) log.warn(` cpp: ${skippedArches.cpp.join(", ")}`);
465
- log.warn(` Enable: docker run --privileged --rm tonistiigi/binfmt --install all`);
466
- console.log(line);
467
- }
468
420
  }
package/cli/src/cpp.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  // Used by dev:back (interactive dev loop) and build (export the binary into the ZIP).
4
4
  import { spawnSync } from "node:child_process";
5
5
  import { dirname } from "node:path";
6
- import { canSkipImageBuild, dockerUserFlags, hasBin, log, pruneMuskatImages, resolveDockerfile } from "./util.mjs";
6
+ import { canSkipImageBuild, imageKey, imageKeyLabel, dockerUserFlags, hasBin, log, pruneMuskatImages, resolveDockerfile } from "./util.mjs";
7
7
 
8
8
  export const CPP_DEV_IMAGE = "muscat-pk-cpp-dev";
9
9
 
@@ -15,15 +15,16 @@ export function assertDockerReady() {
15
15
 
16
16
  /** Build the dev image from the kit's bundled Dockerfile (Docker layer-caches). */
17
17
  export async function ensureImage(image = CPP_DEV_IMAGE, { platform = null } = {}) {
18
- if (canSkipImageBuild(image)) {
18
+ const dockerfile = await resolveDockerfile("Dockerfile.cpp");
19
+ const sdkVer = process.env.MUSCAT_PK_RB_SDK_VERSION;
20
+ const key = imageKey(dockerfile, sdkVer ?? "");
21
+ if (canSkipImageBuild(image, key)) {
19
22
  log.step(`${image} already built — skipping docker build`);
20
23
  return;
21
24
  }
22
- const dockerfile = await resolveDockerfile("Dockerfile.cpp");
23
25
  log.step(`docker build -t ${image}${platform ? ` (${platform})` : ""} (first run is slow, then cached)…`);
24
26
  const platformFlags = platform ? ["--platform", platform] : [];
25
- const sdkVer = process.env.MUSCAT_PK_RB_SDK_VERSION;
26
- const buildArgs = sdkVer ? ["--build-arg", `RB_SDK_VERSION=${sdkVer}`] : [];
27
+ const buildArgs = [...(sdkVer ? ["--build-arg", `RB_SDK_VERSION=${sdkVer}`] : []), ...imageKeyLabel(key)];
27
28
  const r = spawnSync(
28
29
  "docker",
29
30
  ["build", ...platformFlags, ...buildArgs, "-t", image, "-f", dockerfile, dirname(dockerfile)],
@@ -52,9 +53,12 @@ export function cmakeBuild(root, { image = CPP_DEV_IMAGE, buildType = "Debug", a
52
53
  const platformFlags = dockerPlatform ? ["--platform", dockerPlatform] : [];
53
54
  // arch 빌드 시 arch별 이미지 사용 (로컬 이미지와 platform 불일치 방지)
54
55
  const actualImage = arch ? `${CPP_DEV_IMAGE}-${arch}` : image;
56
+ // Export builds ship lib/ next to the binary: librb_sdk.so (robot has no SDK) + any .so the
57
+ // plugin copied there itself. Found via RPATH $ORIGIN/lib.
55
58
  const script = [
56
- `cmake -S cpp -B ${buildDir} -DCMAKE_BUILD_TYPE=${buildType}`,
59
+ `cmake -S cpp -B ${buildDir} -DCMAKE_BUILD_TYPE=${buildType}` + (arch ? ` -DCMAKE_BUILD_RPATH='$ORIGIN/lib'` : ""),
57
60
  `cmake --build ${buildDir} -j`,
61
+ ...(arch ? [`rm -f ${buildDir}/lib/librb_sdk.so.* && if readelf -d $(find ${buildDir} -maxdepth 1 -type f -executable) 2>/dev/null | grep -q librb_sdk; then mkdir -p ${buildDir}/lib && cp -a $(dpkg -L rainbow-rb-sdk | grep -E '/librb_sdk[.]so[.][0-9]') ${buildDir}/lib/; fi`] : []),
58
62
  ].join(" && ");
59
63
  const label = arch ? `cmake build (${buildType}, ${arch}, in container)…` : `cmake build (${buildType}, in container)…`;
60
64
  log.step(label);
package/cli/src/dev.mjs CHANGED
@@ -6,7 +6,7 @@ import { resolve } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { runDevUi } from "./devUi.mjs";
8
8
  import { runDevBack, stopDevBackContainers } from "./devBack.mjs";
9
- import { startBridge, stopBridge, BRIDGE_CONTAINER, BRIDGE_PORT } from "./bridge.mjs";
9
+ import { localMuscatContainer, startBridge, stopBridge, BRIDGE_CONTAINER, BRIDGE_PORT } from "./bridge.mjs";
10
10
  import { assertManifestPorts, assertManifestVariables } from "./manifest.mjs";
11
11
  import { formatPortRanges } from "./ports.mjs";
12
12
  import {
@@ -138,7 +138,11 @@ export async function runDev(args = []) {
138
138
  pluginPorts: usesHostNetwork() ? [] : portRanges,
139
139
  });
140
140
 
141
- if (!bridgeStarted) {
141
+ const muscat = bridgeStarted ? null : localMuscatContainer();
142
+ if (muscat) {
143
+ log.step(`local Muscat container '${muscat}' owns :${BRIDGE_PORT} — backend joins its network (gateway, MediaMTX)`);
144
+ process.env.MUSCAT_PK_MUSCAT_CONTAINER = muscat;
145
+ } else if (!bridgeStarted) {
142
146
  log.warn(`port ${BRIDGE_PORT} is already in use — can't start Socket.IO bridge.`);
143
147
  const answer = await prompt(" Proceed without Socket.IO bridge? [y/N]: ");
144
148
  if (answer.toLowerCase() !== "y") return;
@@ -147,6 +151,7 @@ export async function runDev(args = []) {
147
151
  // Tell dev:back which bridge container to share a netns with, so the backend
148
152
  // reaches the bridge at localhost:10000 (no host.docker.internal).
149
153
  process.env.MUSCAT_PK_BRIDGE_CONTAINER = BRIDGE_CONTAINER;
154
+ if (bridgeStarted.media) process.env.MUSCAT_PK_MEDIA = "1";
150
155
  }
151
156
 
152
157
  process.env.MUSCAT_PK_MANAGED_DEV = "1";
@@ -52,6 +52,30 @@ export function stopDevBackContainers() {
52
52
  }
53
53
  }
54
54
 
55
+ // Where the backend's 127.0.0.1 points. A local Muscat container (muscat-dev): join its
56
+ // netns so the gateway and MediaMTX resolve like on the robot. Under `dev`: the bridge's
57
+ // netns. Standalone dev:back: its own netns, gateway via the host.
58
+ function backendNetwork(portRanges) {
59
+ const muscat = process.env.MUSCAT_PK_MUSCAT_CONTAINER;
60
+ if (muscat) return { run: ["--network", `container:${muscat}`], env: [], shared: true };
61
+ if (usesHostNetwork()) return { run: ["--network", "host"], env: [], shared: true };
62
+ const bridge = process.env.MUSCAT_PK_BRIDGE_CONTAINER;
63
+ if (bridge) return { run: ["--network", `container:${bridge}`], env: [], shared: true };
64
+ return {
65
+ // host.docker.internal is automatic on Docker Desktop; this makes it resolve on Linux too.
66
+ run: [...publishPortFlags(portRanges), "--add-host", "host.docker.internal:host-gateway"],
67
+ env: ["-e", "RB_PLUGIN_GATEWAY_URL=http://host.docker.internal:10000"],
68
+ shared: false,
69
+ };
70
+ }
71
+
72
+ // Our own MediaMTX, only when `dev` found its ports free (a local Muscat brings its own).
73
+ function startMediaServer(container) {
74
+ if (process.env.MUSCAT_PK_MEDIA !== "1") return;
75
+ spawnSync("docker", ["exec", "-d", container, "mediamtx", "/etc/mediamtx/mediamtx.yml"], { stdio: "ignore" });
76
+ log.step("MediaMTX: publish rtsp://127.0.0.1:8554/<path> · play http://localhost:8889/<path>");
77
+ }
78
+
55
79
  // ── IDE tooling venv (best-effort, non-blocking) ────────────────────────────
56
80
  // `muscat-pk new` already creates this up front (see new.mjs); this just
57
81
  // refreshes it on every `dev`/`dev:back` in case requirements.txt changed.
@@ -95,39 +119,18 @@ async function runPython(root, portRanges) {
95
119
  // container shares the host's network stack, so it reaches the bridge at
96
120
  // localhost:10000 and the robot's Zenoh router / reverse-dial path directly
97
121
  // — no netns sharing, port publishing, host.docker.internal, or byte-proxy.
98
- const useHostNet = usesHostNetwork();
99
- // Share the bridge container's network namespace so localhost:10000 is the
100
- // bridge (set by `muscat-pk dev`). Standalone dev:back has no bridge to join.
101
- const bridgeContainer = process.env.MUSCAT_PK_BRIDGE_CONTAINER;
102
- const netFlags = useHostNet
103
- ? ["--network", "host"]
104
- : bridgeContainer ? ["--network", `container:${bridgeContainer}`] : [];
122
+ const net = backendNetwork(portRanges);
105
123
  // Only the container's OWN reverse-dial path needs to publish a listen port.
106
- // With host networking the port is already on the host; with the byte-proxy
124
+ // With a shared netns the port is already reachable; with the byte-proxy
107
125
  // the container just connects out — neither needs -p.
108
126
  const standaloneZenohPortFlags =
109
- !useHostNet && !bridgeContainer && !proxyEndpoint && zenohPort
110
- ? ["-p", `${zenohPort}:${zenohPort}`]
111
- : [];
112
- // Standalone dev:back owns its netns, so it publishes its own declared ports.
113
- // Under `dev` the bridge owns the netns and published them already.
114
- const pluginPortFlags =
115
- !useHostNet && !bridgeContainer ? publishPortFlags(portRanges) : [];
116
- const hostGatewayFlags = useHostNet || bridgeContainer
117
- ? []
118
- : ["--add-host", "host.docker.internal:host-gateway"];
119
- // With host networking or the bridge's netns, 127.0.0.1:10000 (the SDK's
120
- // gateway default) already reaches the bridge. Otherwise the container's own
121
- // 127.0.0.1 is itself — host.docker.internal reaches the host.
122
- const gatewayFlags = useHostNet || bridgeContainer
123
- ? []
124
- : ["-e", "RB_PLUGIN_GATEWAY_URL=http://host.docker.internal:10000"];
127
+ !net.shared && !proxyEndpoint && zenohPort ? ["-p", `${zenohPort}:${zenohPort}`] : [];
125
128
  const envFlags = [
126
129
  // docker exec below has no -t (no pseudo-TTY), so Python defaults to
127
130
  // block-buffered stdout — print() output wouldn't show up until the
128
131
  // buffer fills or the process exits. Force unbuffered.
129
132
  "-e", "PYTHONUNBUFFERED=1",
130
- ...gatewayFlags,
133
+ ...net.env,
131
134
  ...(pluginId ? ["-e", `RB_PLUGIN_ID=${pluginId}`] : []),
132
135
  ...(robotIp ? ["-e", `MUSCAT_ROBOT_HOST=${robotIp}`] : []),
133
136
  ...(robotToken ? ["-e", `MUSCAT_ROBOT_TOKEN=${robotToken}`] : []),
@@ -147,15 +150,13 @@ async function runPython(root, portRanges) {
147
150
  docker(["rm", "-f", container], { stdio: "ignore" });
148
151
  log.step("starting Python dev container…");
149
152
  const up = docker(
150
- ["run", "-d", "--name", container, ...netFlags, ...standaloneZenohPortFlags, ...pluginPortFlags, ...realtimeFlags(),
151
- // host.docker.internal is automatic on Docker Desktop (macOS/Windows);
152
- // this makes it resolve on Linux too, for the no-bridge gateway fallback above.
153
- ...hostGatewayFlags,
153
+ ["run", "-d", "--name", container, ...net.run, ...standaloneZenohPortFlags, ...realtimeFlags(),
154
154
  "-e", "PIP_ROOT_USER_ACTION=ignore",
155
155
  "-v", `${root}:/plugin`, "-w", "/plugin", PYTHON_DEV_IMAGE, "sleep", "infinity"],
156
156
  { stdio: "inherit" },
157
157
  );
158
158
  if (up.status !== 0) throw new Error("failed to start dev container.");
159
+ startMediaServer(container);
159
160
  const stopPortMonitor = startPortContractMonitor({ container, ranges: portRanges, log, allow: [zenohPort] });
160
161
 
161
162
  log.step("pip install requirements + watchfiles (in container)…");
@@ -373,7 +374,7 @@ runpy.run_path("/plugin/py/main.py", run_name="__main__")
373
374
  PY`]);
374
375
 
375
376
  if (pluginId) log.step(`RB_PLUGIN_ID=${pluginId}`);
376
- if (useHostNet) log.step("network: host (native Linux — direct robot/bridge access)");
377
+ if (usesHostNetwork()) log.step("network: host (native Linux — direct robot/bridge access)");
377
378
  if (proxyEndpoint) log.step(`Zenoh via host byte-proxy → ${proxyEndpoint}`);
378
379
  else if (zenohAdvertiseHost && zenohPort) log.step(`Zenoh advertise → ${zenohAdvertiseHost}:${zenohPort}`);
379
380
  log.step("watchfiles → py/main.py (reload on any .py change in py/)");
@@ -429,10 +430,12 @@ async function runRos2(root, portRanges) {
429
430
  const container = `${ROS2_DEV_IMAGE}-run`;
430
431
  const robotIp = process.env.MUSCAT_ROBOT_HOST ?? "";
431
432
  const robotToken = process.env.MUSCAT_ROBOT_TOKEN ?? "";
433
+ const net = backendNetwork(portRanges);
432
434
  const envFlags = [
433
435
  // Same rationale as runPython(): no -t on docker exec below means
434
436
  // rclpy's Python process stdout is block-buffered, not line-buffered.
435
437
  "-e", "PYTHONUNBUFFERED=1",
438
+ ...net.env,
436
439
  ...(pluginId ? ["-e", `RB_PLUGIN_ID=${pluginId}`] : []),
437
440
  ...(robotIp ? ["-e", `MUSCAT_ROBOT_HOST=${robotIp}`] : []),
438
441
  ...(robotToken ? ["-e", `MUSCAT_ROBOT_TOKEN=${robotToken}`] : []),
@@ -445,10 +448,11 @@ async function runRos2(root, portRanges) {
445
448
  docker(["rm", "-f", container], { stdio: "ignore" });
446
449
  log.step("starting ROS2 dev container…");
447
450
  const up = docker(
448
- ["run", "-d", "--name", container, ...publishPortFlags(portRanges), ...realtimeFlags(), "-e", "PIP_ROOT_USER_ACTION=ignore", "-v", `${root}:/plugin`, "-w", "/plugin", ROS2_DEV_IMAGE, "sleep", "infinity"],
451
+ ["run", "-d", "--name", container, ...net.run, ...realtimeFlags(), "-e", "PIP_ROOT_USER_ACTION=ignore", "-v", `${root}:/plugin`, "-w", "/plugin", ROS2_DEV_IMAGE, "sleep", "infinity"],
449
452
  { stdio: "inherit" },
450
453
  );
451
454
  if (up.status !== 0) throw new Error("failed to start dev container.");
455
+ startMediaServer(container);
452
456
  const stopPortMonitor = startPortContractMonitor({ container, ranges: portRanges, log, allow: [process.env.MUSCAT_ZENOH_PORT] });
453
457
 
454
458
  // one-time deps + symlink build (edits to .py reflect without rebuilding)
@@ -459,11 +463,14 @@ async function runRos2(root, portRanges) {
459
463
  let child = null;
460
464
  const startNode = () => {
461
465
  if (child) child.kill();
462
- log.step(`ros2 run ${pkg} ${pkg}_node`);
466
+ // Same command the robot runs: rclpy execs the console script, rclcpp uses `ros2 run`.
467
+ const launch = manifest.runtime?.launch || `${pkg}_node`;
468
+ const run = isRclcpp ? `ros2 run ${pkg} ${launch}` : `exec ${launch}`;
469
+ log.step(run);
463
470
  child = spawn(
464
471
  "docker",
465
472
  ["exec", ...envFlags, container, "bash", "-lc",
466
- `cd ros2_ws && source install/setup.bash && ros2 run ${pkg} ${pkg}_node`],
473
+ `cd ros2_ws && source install/setup.bash && ${run}`],
467
474
  { cwd: root, stdio: "inherit" },
468
475
  );
469
476
  };
@@ -513,7 +520,9 @@ async function runCpp(root, portRanges) {
513
520
  const container = `${CPP_DEV_IMAGE}-run`;
514
521
  const robotIp = process.env.MUSCAT_ROBOT_HOST ?? "";
515
522
  const robotToken = process.env.MUSCAT_ROBOT_TOKEN ?? "";
523
+ const net = backendNetwork(portRanges);
516
524
  const envFlags = [
525
+ ...net.env,
517
526
  ...(pluginId ? ["-e", `RB_PLUGIN_ID=${pluginId}`] : []),
518
527
  ...(robotIp ? ["-e", `MUSCAT_ROBOT_HOST=${robotIp}`] : []),
519
528
  ...(robotToken ? ["-e", `MUSCAT_ROBOT_TOKEN=${robotToken}`] : []),
@@ -526,10 +535,11 @@ async function runCpp(root, portRanges) {
526
535
  docker(["rm", "-f", container], { stdio: "ignore" });
527
536
  log.step("starting C++ dev container…");
528
537
  const up = docker(
529
- ["run", "-d", "--name", container, ...publishPortFlags(portRanges), ...realtimeFlags(), "-v", `${root}:/plugin`, "-w", "/plugin", CPP_DEV_IMAGE, "sleep", "infinity"],
538
+ ["run", "-d", "--name", container, ...net.run, ...realtimeFlags(), "-v", `${root}:/plugin`, "-w", "/plugin", CPP_DEV_IMAGE, "sleep", "infinity"],
530
539
  { stdio: "inherit" },
531
540
  );
532
541
  if (up.status !== 0) throw new Error("failed to start dev container.");
542
+ startMediaServer(container);
533
543
  const stopPortMonitor = startPortContractMonitor({ container, ranges: portRanges, log, allow: [process.env.MUSCAT_ZENOH_PORT] });
534
544
 
535
545
  const build = () => {