@rainbow-robotics/muscat-plugin-kit 1.3.6-dev.8 → 1.3.7

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 (125) hide show
  1. package/cli/README.md +8 -3
  2. package/cli/bin/muscat-pk.mjs +5 -0
  3. package/cli/bundle-templates.mjs +6 -1
  4. package/cli/docker/Dockerfile.cpp +28 -6
  5. package/cli/docker/Dockerfile.dev +32 -0
  6. package/cli/docker/Dockerfile.python +19 -0
  7. package/cli/src/bridge.mjs +45 -12
  8. package/cli/src/build.mjs +48 -79
  9. package/cli/src/cpp.mjs +11 -5
  10. package/cli/src/dev.mjs +7 -2
  11. package/cli/src/devBack.mjs +97 -40
  12. package/cli/src/host_agent.py +143 -83
  13. package/cli/src/manifest.mjs +11 -0
  14. package/cli/src/mcp.mjs +398 -54
  15. package/cli/src/new.mjs +13 -3
  16. package/cli/src/ports.mjs +21 -7
  17. package/cli/src/python.mjs +16 -20
  18. package/cli/src/ros2.mjs +16 -8
  19. package/cli/src/sdk.mjs +23 -21
  20. package/cli/src/type.mjs +44 -10
  21. package/cli/src/uninstall.mjs +3 -20
  22. package/cli/src/update.mjs +14 -8
  23. package/cli/src/util.mjs +70 -9
  24. package/cli/storybook-dist/_stories-src/RBDatePicker/RBRangeDateInput.stories.tsx +13 -1
  25. package/cli/storybook-dist/_stories-src/RBInput/RBSelectBox.stories.tsx +98 -1
  26. package/cli/storybook-dist/assets/{Color-YHDXOIA2-Du8dJ-nM.js → Color-YHDXOIA2-CdTgUqrV.js} +1 -1
  27. package/cli/storybook-dist/assets/{Docs-Sy5RDbtH.js → Docs-C8wx5zsC.js} +1 -1
  28. package/cli/storybook-dist/assets/{Docs-guGflRzZ.js → Docs-CbRKwHpQ.js} +1 -1
  29. package/cli/storybook-dist/assets/{Docs-Cb72AJnj.js → Docs-Cf0s04QW.js} +1 -1
  30. package/cli/storybook-dist/assets/{Docs-pwGqGTV_.js → Docs-CsxMprX5.js} +1 -1
  31. package/cli/storybook-dist/assets/{Docs-LdjYwuG4.js → Docs-FyPFrbUS.js} +1 -1
  32. package/cli/storybook-dist/assets/{Docs--Fm8069p.js → Docs-YtqU-aVu.js} +1 -1
  33. package/cli/storybook-dist/assets/{DocsRenderer-CFRXHY34-YGBLQ4BD.js → DocsRenderer-CFRXHY34-VSENNRoY.js} +1 -1
  34. package/cli/storybook-dist/assets/{NxAvatar.stories-C1d4n8cq.js → NxAvatar.stories-oCcYtLwq.js} +1 -1
  35. package/cli/storybook-dist/assets/{NxButton.stories-gCBoZdMq.js → NxButton.stories-C5jUScIj.js} +1 -1
  36. package/cli/storybook-dist/assets/{NxIcons.stories-Dg42riT5.js → NxIcons.stories-DeqrVzVs.js} +1 -1
  37. package/cli/storybook-dist/assets/{NxNumberInput.stories-DB-vLM2J.js → NxNumberInput.stories-gjsEulIN.js} +1 -1
  38. package/cli/storybook-dist/assets/{NxPagination.stories-CS8iMIak.js → NxPagination.stories-BK6uSWIa.js} +1 -1
  39. package/cli/storybook-dist/assets/{NxPointerHoldButton.stories-D66BopeS.js → NxPointerHoldButton.stories-BDD5CvSZ.js} +1 -1
  40. package/cli/storybook-dist/assets/{NxRangeInput.stories-CZxddsJ2.js → NxRangeInput.stories-C_nW26_x.js} +3 -3
  41. package/cli/storybook-dist/assets/{NxSelectBox.stories-CmezGGrm.js → NxSelectBox.stories-DqLfKU9H.js} +1 -1
  42. package/cli/storybook-dist/assets/{NxSlideRange.stories-CGAFMITi.js → NxSlideRange.stories-CQjHA8yv.js} +1 -1
  43. package/cli/storybook-dist/assets/{NxSwitch.stories-B3QInkXX.js → NxSwitch.stories-BMHCiPoL.js} +1 -1
  44. package/cli/storybook-dist/assets/{NxTextArea.stories-BgJcgZNe.js → NxTextArea.stories-k7r1WVQh.js} +1 -1
  45. package/cli/storybook-dist/assets/{NxTextInput.stories-CwMQwI9L.js → NxTextInput.stories-Cqfe1I32.js} +1 -1
  46. package/cli/storybook-dist/assets/{RBAccordion.stories-BuBVNzJT.js → RBAccordion.stories-CJ3Ttuja.js} +1 -1
  47. package/cli/storybook-dist/assets/{RBAccordionGroup.stories-Crdb6kFF.js → RBAccordionGroup.stories-BThv7tM0.js} +1 -1
  48. package/cli/storybook-dist/assets/{RBAlert.stories-CqXgUvA2.js → RBAlert.stories-CSMP8Vun.js} +1 -1
  49. package/cli/storybook-dist/assets/{RBBadge.stories-BCw_ksML.js → RBBadge.stories-cBMA2R6F.js} +1 -1
  50. package/cli/storybook-dist/assets/{RBBattery.stories-0id4XYoc.js → RBBattery.stories-BKVOskqs.js} +1 -1
  51. package/cli/storybook-dist/assets/{RBCard.stories-CgpG-7Lk.js → RBCard.stories-BTOfK0HC.js} +1 -1
  52. package/cli/storybook-dist/assets/{RBCheckBox.stories-BG02BoGX.js → RBCheckBox.stories-DyE912wG.js} +1 -1
  53. package/cli/storybook-dist/assets/{RBCheckIconButton.stories-vrjD4GQ4.js → RBCheckIconButton.stories-0sLFU0kM.js} +1 -1
  54. package/cli/storybook-dist/assets/{RBCheckLabelButton.stories-POba8lU-.js → RBCheckLabelButton.stories-c8VecClE.js} +1 -1
  55. package/cli/storybook-dist/assets/{RBChipTab.stories-XWJbFrHF.js → RBChipTab.stories-CQ9Uav5_.js} +1 -1
  56. package/cli/storybook-dist/assets/{RBChipsBoxTab.stories-BdU0Dzdp.js → RBChipsBoxTab.stories-Ap4NlhAZ.js} +1 -1
  57. package/cli/storybook-dist/assets/{RBColorIcons.stories-C_0dCNXG.js → RBColorIcons.stories-BPPXjif2.js} +1 -1
  58. package/cli/storybook-dist/assets/{RBCommand.stories-Dvrko5No.js → RBCommand.stories-PDpYOKlm.js} +1 -1
  59. package/cli/storybook-dist/assets/{RBConfirm.stories-CkTn0sHa.js → RBConfirm.stories-BEWvMAYP.js} +1 -1
  60. package/cli/storybook-dist/assets/{RBDateInput.stories-BH3D08cs.js → RBDateInput.stories-DLdyNnVF.js} +1 -1
  61. package/cli/storybook-dist/assets/{RBDialog.stories-C2nNX9fl.js → RBDialog.stories-HsawMNOb.js} +1 -1
  62. package/cli/storybook-dist/assets/{RBDivider.stories-D-GrJ0QZ.js → RBDivider.stories-dlWwOxik.js} +1 -1
  63. package/cli/storybook-dist/assets/{RBDrawer.stories-XLOnLOnh.js → RBDrawer.stories-C87iqGk0.js} +1 -1
  64. package/cli/storybook-dist/assets/{RBDropdownMenu.stories-BjBSeoV2.js → RBDropdownMenu.stories-B8EQhA0z.js} +1 -1
  65. package/cli/storybook-dist/assets/{RBEmptyView.stories-C6U2zKNE.js → RBEmptyView.stories-SzpIhNDw.js} +1 -1
  66. package/cli/storybook-dist/assets/{RBIconButton.stories-MCJq5Hta.js → RBIconButton.stories-BQ0nM1gb.js} +1 -1
  67. package/cli/storybook-dist/assets/{RBIcons.stories-DxOCiCe0.js → RBIcons.stories-C19g5M5z.js} +1 -1
  68. package/cli/storybook-dist/assets/{RBInlineLabelButton.stories-BVZi1aEs.js → RBInlineLabelButton.stories-DbgI4-1P.js} +1 -1
  69. package/cli/storybook-dist/assets/{RBJoyStick.stories-BDThbEek.js → RBJoyStick.stories-smF-vpGL.js} +1 -1
  70. package/cli/storybook-dist/assets/{RBLabelButton.stories-CaPEKUS6.js → RBLabelButton.stories-s53klwDv.js} +1 -1
  71. package/cli/storybook-dist/assets/{RBLight.stories-CihQzyEL.js → RBLight.stories-BnzkoR51.js} +1 -1
  72. package/cli/storybook-dist/assets/{RBMenubar.stories-J852XVFh.js → RBMenubar.stories-CW014klu.js} +1 -1
  73. package/cli/storybook-dist/assets/{RBMultiRangeInput.stories-DXyX0cOZ.js → RBMultiRangeInput.stories-qa4tH_4Y.js} +1 -1
  74. package/cli/storybook-dist/assets/{RBPagination.stories-C9_49ymO.js → RBPagination.stories-YoQNUpQH.js} +1 -1
  75. package/cli/storybook-dist/assets/{RBPopover.stories-DxLE912-.js → RBPopover.stories-Bhpi0OaH.js} +1 -1
  76. package/cli/storybook-dist/assets/{RBPrompt.stories-DlsfGqWb.js → RBPrompt.stories-iQM5iAka.js} +1 -1
  77. package/cli/storybook-dist/assets/{RBRadioBox.stories-hb5cNWW1.js → RBRadioBox.stories-BGyq4rzx.js} +1 -1
  78. package/cli/storybook-dist/assets/{RBRadioButton.stories-CSuOgeN-.js → RBRadioButton.stories-DF6BcUcJ.js} +1 -1
  79. package/cli/storybook-dist/assets/RBRangeDateInput.stories-COOQ_HNF.js +15 -0
  80. package/cli/storybook-dist/assets/{RBResizable.stories-BetjF9vG.js → RBResizable.stories-DSVHvwjp.js} +1 -1
  81. package/cli/storybook-dist/assets/RBSelectBox.stories-BookV8Sm.js +74 -0
  82. package/cli/storybook-dist/assets/{RBSheet.stories-D3QbcMKq.js → RBSheet.stories-CAK9hb-p.js} +1 -1
  83. package/cli/storybook-dist/assets/{RBSideNumberInput.stories-DcTh0IjM.js → RBSideNumberInput.stories-DUymnH-9.js} +1 -1
  84. package/cli/storybook-dist/assets/{RBSlider.stories-B59We7ei.js → RBSlider.stories-BwlDh9--.js} +1 -1
  85. package/cli/storybook-dist/assets/{RBSpinner.stories-9u3_ETbi.js → RBSpinner.stories-VOlkuP6t.js} +1 -1
  86. package/cli/storybook-dist/assets/{RBSwitch.stories-DrVm1WGX.js → RBSwitch.stories-BiW2dQnT.js} +1 -1
  87. package/cli/storybook-dist/assets/{RBTab.stories-D8Dm7cRW.js → RBTab.stories-DFKVsy0C.js} +1 -1
  88. package/cli/storybook-dist/assets/{RBTable.stories-dRdZ8g1j.js → RBTable.stories-BWG0ZSYI.js} +1 -1
  89. package/cli/storybook-dist/assets/{RBTableRow.stories-rZeoGwHZ.js → RBTableRow.stories-JVTD83Mh.js} +1 -1
  90. package/cli/storybook-dist/assets/{RBTabs.stories-STRV2lgO.js → RBTabs.stories-D-aN385u.js} +1 -1
  91. package/cli/storybook-dist/assets/{RBTextArea.stories-CfgUbrbJ.js → RBTextArea.stories-Dx5xkmZS.js} +1 -1
  92. package/cli/storybook-dist/assets/{RBTextField.stories-B5t8-Y97.js → RBTextField.stories-DmVciExd.js} +1 -1
  93. package/cli/storybook-dist/assets/{RBTextInput.stories-Bf1edN6l.js → RBTextInput.stories-CCf0VGEv.js} +1 -1
  94. package/cli/storybook-dist/assets/{RBToast.stories-Cnm8JwQ6.js → RBToast.stories-DT0DGn05.js} +1 -1
  95. package/cli/storybook-dist/assets/{RBToggle.stories-B0OCo6zd.js → RBToggle.stories-Ce_q3EtP.js} +1 -1
  96. package/cli/storybook-dist/assets/{RBToggleGroup.stories-GdYO2Fxg.js → RBToggleGroup.stories-CN40YHsR.js} +1 -1
  97. package/cli/storybook-dist/assets/{RBTooltip.stories-DXJTCScO.js → RBTooltip.stories-L7btDEpA.js} +1 -1
  98. package/cli/storybook-dist/assets/SideNumberInput-B5_xLK8z.js +1109 -0
  99. package/cli/storybook-dist/assets/browserUtils-CQM9Za7e.js +1 -0
  100. package/cli/storybook-dist/assets/{entry-preview-DSRwIaVJ.js → entry-preview-raBQdKjS.js} +1 -1
  101. package/cli/storybook-dist/assets/{iframe-Cj53Wn3d.js → iframe-BmIue__l.js} +2 -2
  102. package/cli/storybook-dist/assets/{index-CkHtLEe8.js → index-CSIe-YDr.js} +3 -3
  103. package/cli/storybook-dist/assets/{index-C1OzuK6-.js → index-ClAc6FBW.js} +1 -1
  104. package/cli/storybook-dist/assets/{preview-CILRoSnB.js → preview-BVJwlGUF.js} +1 -1
  105. package/cli/storybook-dist/assets/{preview-D_2um8Sd.js → preview-CUgthyJT.js} +1 -1
  106. package/cli/storybook-dist/assets/{preview-CgOtgtCD.js → preview-DD51RTKv.js} +2 -2
  107. package/cli/storybook-dist/assets/preview-zuAvMksL.css +1 -0
  108. package/cli/storybook-dist/assets/react-resizable-panels.browser-CYcsrUgg.js +120 -0
  109. package/cli/storybook-dist/iframe.html +1 -1
  110. package/cli/storybook-dist/index.json +1 -1
  111. package/cli/storybook-dist/project.json +1 -1
  112. package/cli/templates/common/README.md +211 -7
  113. package/cli/templates/common/py/main.py +29 -2
  114. package/cli/templates/cpp/cpp/CMakeLists.txt +6 -7
  115. package/cli/templates/cpp/cpp/main.cpp +57 -10
  116. package/cli/templates/readme-cpp/README.md +993 -0
  117. package/cli/templates/ros2-cpp/ros2_ws/src/__PLUGIN_SLUG__/CMakeLists.txt +4 -0
  118. package/cli/templates/ros2-cpp/ros2_ws/src/__PLUGIN_SLUG__/src/node.cpp +5 -4
  119. package/package.json +3 -3
  120. package/cli/storybook-dist/assets/RBRangeDateInput.stories-KRg2glX8.js +0 -15
  121. package/cli/storybook-dist/assets/RBSelectBox.stories-ILLQQ8M8.js +0 -6
  122. package/cli/storybook-dist/assets/SideNumberInput-CrF-a1Nr.js +0 -1114
  123. package/cli/storybook-dist/assets/browserUtils-CxJ5gea3.js +0 -1
  124. package/cli/storybook-dist/assets/preview-CEfMaO1t.css +0 -1
  125. package/cli/storybook-dist/assets/react-resizable-panels.browser-CtORL3tG.js +0 -102
@@ -8,11 +8,12 @@
8
8
  // ros2 : Jazzy + Python 3.12 container, colcon build, run the node, restart
9
9
  // on source changes (rclcpp rebuilds on change).
10
10
  import { spawn, spawnSync } from "node:child_process";
11
- import { existsSync, watch } from "node:fs";
12
- import { resolve } from "node:path";
13
- import { ensureDocker, ensureIdeVenv, findPluginRoot, log, promptRobotConnection, realtimeFlags } from "./util.mjs";
11
+ import { existsSync, mkdirSync, readFileSync, watch, writeFileSync } from "node:fs";
12
+ import { isIP } from "node:net";
13
+ import { dirname, resolve } from "node:path";
14
+ import { dockerUserFlags, ensureDocker, ensureIdeVenv, findPluginRoot, log, promptRobotConnection, realtimeFlags } from "./util.mjs";
14
15
  import { ensureRemoteZenohEnv, usesHostNetwork } from "./zenohDev.mjs";
15
- import { assertManifestPorts, assertManifestVariables, manifestRuntime, readManifest, validateManifest } from "./manifest.mjs";
16
+ import { PLUGIN_ID_RE, assertManifestPorts, assertManifestVariables, manifestRuntime, readManifest, rosNamespace, validateManifest } from "./manifest.mjs";
16
17
  import {
17
18
  formatPortRanges,
18
19
  portEnvFlags,
@@ -52,6 +53,67 @@ export function stopDevBackContainers() {
52
53
  }
53
54
  }
54
55
 
56
+ // Where the backend's 127.0.0.1 points. A local Muscat container (muscat-dev): join its
57
+ // netns so the gateway and MediaMTX resolve like on the robot. Under `dev`: the bridge's
58
+ // netns. Standalone dev:back: its own netns, gateway via the host.
59
+ function backendNetwork(portRanges) {
60
+ const muscat = process.env.MUSCAT_PK_MUSCAT_CONTAINER;
61
+ if (muscat) return { run: ["--network", `container:${muscat}`], env: [], shared: true };
62
+ if (usesHostNetwork()) return { run: ["--network", "host"], env: [], shared: true };
63
+ const bridge = process.env.MUSCAT_PK_BRIDGE_CONTAINER;
64
+ if (bridge) return { run: ["--network", `container:${bridge}`], env: [], shared: true };
65
+ return {
66
+ // host.docker.internal is automatic on Docker Desktop; this makes it resolve on Linux too.
67
+ run: [...publishPortFlags(portRanges), "--add-host", "host.docker.internal:host-gateway"],
68
+ env: ["-e", "RB_PLUGIN_GATEWAY_URL=http://host.docker.internal:10000"],
69
+ shared: false,
70
+ };
71
+ }
72
+
73
+ // rb.rosPeers in dev: answer muscat/plugin/<id>/ros_peers/{get,set} on the gateway the way
74
+ // Muscat (common) does on the robot — keep the list in .muscat-dev/ and restart the node
75
+ // with ROS_STATIC_PEERS.
76
+ const ROS_PEERS_FILE = ".muscat-dev/ros-peers.json";
77
+
78
+ function readRosPeers(root) {
79
+ try {
80
+ return JSON.parse(readFileSync(resolve(root, ROS_PEERS_FILE), "utf8"));
81
+ } catch {
82
+ return [];
83
+ }
84
+ }
85
+
86
+ async function serveRosPeers(root, pluginId, restart) {
87
+ if (!pluginId) return;
88
+ const { RbWs } = await import("@rainbow-robotics/plugin-ui/ws");
89
+ const ws = await Promise.race([
90
+ RbWs.connect("http://127.0.0.1:10000", pluginId).catch(() => null),
91
+ new Promise((r) => setTimeout(() => r(null), 5000)),
92
+ ]);
93
+ if (!ws) return log.warn("rb.rosPeers unavailable in dev: plugin gateway not reachable on :10000");
94
+ const base = `/muscat/plugin/${pluginId}/ros_peers`;
95
+ await ws.serve(`${base}/get`, () => ({ peers: readRosPeers(root) }));
96
+ await ws.serve(`${base}/set`, (payload) => {
97
+ const peers = payload?.peers;
98
+ if (!Array.isArray(peers) || !peers.every((p) => typeof p === "string" && isIP(p))) {
99
+ return { ok: false, error: "peers must be a list of IP addresses" };
100
+ }
101
+ const file = resolve(root, ROS_PEERS_FILE);
102
+ mkdirSync(dirname(file), { recursive: true });
103
+ writeFileSync(file, JSON.stringify(peers) + "\n");
104
+ log.step(`ROS_STATIC_PEERS → ${peers.join(";") || "(none)"} — restarting node`);
105
+ restart();
106
+ return { ok: true, peers };
107
+ });
108
+ }
109
+
110
+ // Our own MediaMTX, only when `dev` found its ports free (a local Muscat brings its own).
111
+ function startMediaServer(container) {
112
+ if (process.env.MUSCAT_PK_MEDIA !== "1") return;
113
+ spawnSync("docker", ["exec", "-d", container, "mediamtx", "/etc/mediamtx/mediamtx.yml"], { stdio: "ignore" });
114
+ log.step("MediaMTX: publish rtsp://127.0.0.1:8554/<path> · play http://localhost:8889/<path>");
115
+ }
116
+
55
117
  // ── IDE tooling venv (best-effort, non-blocking) ────────────────────────────
56
118
  // `muscat-pk new` already creates this up front (see new.mjs); this just
57
119
  // refreshes it on every `dev`/`dev:back` in case requirements.txt changed.
@@ -95,39 +157,18 @@ async function runPython(root, portRanges) {
95
157
  // container shares the host's network stack, so it reaches the bridge at
96
158
  // localhost:10000 and the robot's Zenoh router / reverse-dial path directly
97
159
  // — 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}`] : [];
160
+ const net = backendNetwork(portRanges);
105
161
  // 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
162
+ // With a shared netns the port is already reachable; with the byte-proxy
107
163
  // the container just connects out — neither needs -p.
108
164
  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"];
165
+ !net.shared && !proxyEndpoint && zenohPort ? ["-p", `${zenohPort}:${zenohPort}`] : [];
125
166
  const envFlags = [
126
167
  // docker exec below has no -t (no pseudo-TTY), so Python defaults to
127
168
  // block-buffered stdout — print() output wouldn't show up until the
128
169
  // buffer fills or the process exits. Force unbuffered.
129
170
  "-e", "PYTHONUNBUFFERED=1",
130
- ...gatewayFlags,
171
+ ...net.env,
131
172
  ...(pluginId ? ["-e", `RB_PLUGIN_ID=${pluginId}`] : []),
132
173
  ...(robotIp ? ["-e", `MUSCAT_ROBOT_HOST=${robotIp}`] : []),
133
174
  ...(robotToken ? ["-e", `MUSCAT_ROBOT_TOKEN=${robotToken}`] : []),
@@ -143,19 +184,18 @@ async function runPython(root, portRanges) {
143
184
  const docker = (args, opts = {}) =>
144
185
  spawnSync("docker", args, { cwd: root, stdio: "inherit", ...opts });
145
186
 
146
- // start a warm, long-lived container with the plugin mounted
187
+ // start a warm, long-lived container with the plugin mounted.
188
+ // 호스트 uid 로 돌린다(dockerUserFlags) — root 로 쓴 build/·install/ 은 사용자가 지우지 못한다.
147
189
  docker(["rm", "-f", container], { stdio: "ignore" });
148
190
  log.step("starting Python dev container…");
149
191
  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,
192
+ ["run", "-d", "--name", container, ...net.run, ...standaloneZenohPortFlags, ...realtimeFlags(), ...dockerUserFlags(),
154
193
  "-e", "PIP_ROOT_USER_ACTION=ignore",
155
194
  "-v", `${root}:/plugin`, "-w", "/plugin", PYTHON_DEV_IMAGE, "sleep", "infinity"],
156
195
  { stdio: "inherit" },
157
196
  );
158
197
  if (up.status !== 0) throw new Error("failed to start dev container.");
198
+ startMediaServer(container);
159
199
  const stopPortMonitor = startPortContractMonitor({ container, ranges: portRanges, log, allow: [zenohPort] });
160
200
 
161
201
  log.step("pip install requirements + watchfiles (in container)…");
@@ -373,7 +413,7 @@ runpy.run_path("/plugin/py/main.py", run_name="__main__")
373
413
  PY`]);
374
414
 
375
415
  if (pluginId) log.step(`RB_PLUGIN_ID=${pluginId}`);
376
- if (useHostNet) log.step("network: host (native Linux — direct robot/bridge access)");
416
+ if (usesHostNetwork()) log.step("network: host (native Linux — direct robot/bridge access)");
377
417
  if (proxyEndpoint) log.step(`Zenoh via host byte-proxy → ${proxyEndpoint}`);
378
418
  else if (zenohAdvertiseHost && zenohPort) log.step(`Zenoh advertise → ${zenohAdvertiseHost}:${zenohPort}`);
379
419
  log.step("watchfiles → py/main.py (reload on any .py change in py/)");
@@ -429,10 +469,14 @@ async function runRos2(root, portRanges) {
429
469
  const container = `${ROS2_DEV_IMAGE}-run`;
430
470
  const robotIp = process.env.MUSCAT_ROBOT_HOST ?? "";
431
471
  const robotToken = process.env.MUSCAT_ROBOT_TOKEN ?? "";
472
+ const net = backendNetwork(portRanges);
432
473
  const envFlags = [
433
474
  // Same rationale as runPython(): no -t on docker exec below means
434
475
  // rclpy's Python process stdout is block-buffered, not line-buffered.
435
476
  "-e", "PYTHONUNBUFFERED=1",
477
+ // Same as the robot: ROS discovery stays on this host (allow a PC with ROS_STATIC_PEERS).
478
+ "-e", "ROS_AUTOMATIC_DISCOVERY_RANGE=LOCALHOST",
479
+ ...net.env,
436
480
  ...(pluginId ? ["-e", `RB_PLUGIN_ID=${pluginId}`] : []),
437
481
  ...(robotIp ? ["-e", `MUSCAT_ROBOT_HOST=${robotIp}`] : []),
438
482
  ...(robotToken ? ["-e", `MUSCAT_ROBOT_TOKEN=${robotToken}`] : []),
@@ -445,10 +489,11 @@ async function runRos2(root, portRanges) {
445
489
  docker(["rm", "-f", container], { stdio: "ignore" });
446
490
  log.step("starting ROS2 dev container…");
447
491
  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"],
492
+ ["run", "-d", "--name", container, ...net.run, ...realtimeFlags(), ...dockerUserFlags(), "-e", "PIP_ROOT_USER_ACTION=ignore", "-v", `${root}:/plugin`, "-w", "/plugin", ROS2_DEV_IMAGE, "sleep", "infinity"],
449
493
  { stdio: "inherit" },
450
494
  );
451
495
  if (up.status !== 0) throw new Error("failed to start dev container.");
496
+ startMediaServer(container);
452
497
  const stopPortMonitor = startPortContractMonitor({ container, ranges: portRanges, log, allow: [process.env.MUSCAT_ZENOH_PORT] });
453
498
 
454
499
  // one-time deps + symlink build (edits to .py reflect without rebuilding)
@@ -459,15 +504,24 @@ async function runRos2(root, portRanges) {
459
504
  let child = null;
460
505
  const startNode = () => {
461
506
  if (child) child.kill();
462
- log.step(`ros2 run ${pkg} ${pkg}_node`);
507
+ // killing the `docker exec` client doesn't stop the node inside the container
508
+ spawnSync("docker", ["exec", container, "pkill", "-f", `${pkg}_node|${manifest.runtime?.launch || pkg}`], { stdio: "ignore" });
509
+ const peers = readRosPeers(root);
510
+ // Same command the robot runs: rclpy execs the console script, rclcpp uses `ros2 run`;
511
+ // relative topics land under the plugin's namespace (rosNamespace).
512
+ const launch = manifest.runtime?.launch || `${pkg}_node`;
513
+ const nsArgs = PLUGIN_ID_RE.test(pluginId) ? ` --ros-args -r __ns:=${rosNamespace(pluginId)}` : "";
514
+ const run = (isRclcpp ? `ros2 run ${pkg} ${launch}` : `exec ${launch}`) + nsArgs;
515
+ log.step(run);
463
516
  child = spawn(
464
517
  "docker",
465
- ["exec", ...envFlags, container, "bash", "-lc",
466
- `cd ros2_ws && source install/setup.bash && ros2 run ${pkg} ${pkg}_node`],
518
+ ["exec", ...envFlags, ...(peers.length ? ["-e", `ROS_STATIC_PEERS=${peers.join(";")}`] : []), container, "bash", "-lc",
519
+ `cd ros2_ws && source install/setup.bash && ${run}`],
467
520
  { cwd: root, stdio: "inherit" },
468
521
  );
469
522
  };
470
523
  startNode();
524
+ serveRosPeers(root, pluginId, startNode);
471
525
 
472
526
  // restart node on source change. rclpy reflects .py edits live (symlink-install);
473
527
  // rclcpp (C++) and package-metadata changes require a colcon rebuild.
@@ -513,7 +567,9 @@ async function runCpp(root, portRanges) {
513
567
  const container = `${CPP_DEV_IMAGE}-run`;
514
568
  const robotIp = process.env.MUSCAT_ROBOT_HOST ?? "";
515
569
  const robotToken = process.env.MUSCAT_ROBOT_TOKEN ?? "";
570
+ const net = backendNetwork(portRanges);
516
571
  const envFlags = [
572
+ ...net.env,
517
573
  ...(pluginId ? ["-e", `RB_PLUGIN_ID=${pluginId}`] : []),
518
574
  ...(robotIp ? ["-e", `MUSCAT_ROBOT_HOST=${robotIp}`] : []),
519
575
  ...(robotToken ? ["-e", `MUSCAT_ROBOT_TOKEN=${robotToken}`] : []),
@@ -526,10 +582,11 @@ async function runCpp(root, portRanges) {
526
582
  docker(["rm", "-f", container], { stdio: "ignore" });
527
583
  log.step("starting C++ dev container…");
528
584
  const up = docker(
529
- ["run", "-d", "--name", container, ...publishPortFlags(portRanges), ...realtimeFlags(), "-v", `${root}:/plugin`, "-w", "/plugin", CPP_DEV_IMAGE, "sleep", "infinity"],
585
+ ["run", "-d", "--name", container, ...net.run, ...realtimeFlags(), ...dockerUserFlags(), "-v", `${root}:/plugin`, "-w", "/plugin", CPP_DEV_IMAGE, "sleep", "infinity"],
530
586
  { stdio: "inherit" },
531
587
  );
532
588
  if (up.status !== 0) throw new Error("failed to start dev container.");
589
+ startMediaServer(container);
533
590
  const stopPortMonitor = startPortContractMonitor({ container, ranges: portRanges, log, allow: [process.env.MUSCAT_ZENOH_PORT] });
534
591
 
535
592
  const build = () => {
@@ -1,10 +1,7 @@
1
- """Host-side raw Zenoh byte-proxy.
1
+ """Host-side Zenoh byte-proxy for Docker Desktop containers (Mac/WSL remote dev).
2
2
 
3
- Runs on the host as a normal Zenoh peer (reverse-dials the robot, same as the
4
- SDK's own connect()) and serves a tiny domain-agnostic byte protocol over TCP
5
- — {query, put, subscribe} by keyexpr + bytes only — so a Docker Desktop
6
- container, which cannot join the robot's Zenoh mesh itself, can talk to a
7
- process that can.
3
+ Joins the robot's Zenoh mesh with eclipse-zenoh (no rainbow SDK) and relays
4
+ {query, put, subscribe} bytes over TCP to the dev container.
8
5
 
9
6
  Env: MUSCAT_ROBOT_HOST, MUSCAT_ROBOT_TOKEN, MUSCAT_ZENOH_ADVERTISE_HOST,
10
7
  MUSCAT_ZENOH_PORT (robot-facing), MUSCAT_PROXY_PORT (container-facing).
@@ -12,13 +9,21 @@ MUSCAT_ZENOH_PORT (robot-facing), MUSCAT_PROXY_PORT (container-facing).
12
9
 
13
10
  from __future__ import annotations
14
11
 
15
- import asyncio
16
12
  import contextlib
17
13
  import json
18
14
  import os
15
+ import signal
19
16
  import socket
20
17
  import struct
18
+ import sys
21
19
  import threading
20
+ import urllib.error
21
+ import urllib.request
22
+
23
+ import zenoh
24
+
25
+ PEER_TTL = 30
26
+ QUERY_TARGETS = {0: zenoh.QueryTarget.BEST_MATCHING, 1: zenoh.QueryTarget.ALL, 2: zenoh.QueryTarget.ALL_COMPLETE}
22
27
 
23
28
 
24
29
  def _send_frame(conn: socket.socket, lock: threading.Lock, obj: dict) -> None:
@@ -44,102 +49,156 @@ def _recv_frame(conn: socket.socket) -> dict | None:
44
49
  return json.loads(buf.decode())
45
50
 
46
51
 
47
- def _handle_conn(conn: socket.socket, zenoh_client) -> None:
52
+ def _att(sample) -> str:
53
+ return sample.attachment.to_string() if sample.attachment is not None else ""
54
+
55
+
56
+ def _query(session: zenoh.Session, req: dict) -> list[dict]:
57
+ payload = bytes.fromhex(req["payload_hex"]) if req.get("payload_hex") else None
58
+ replies = session.get(
59
+ req["keyexpr"],
60
+ payload=payload,
61
+ timeout=req.get("timeout_ms", 2000) / 1000,
62
+ target=QUERY_TARGETS.get(req.get("target", 0), zenoh.QueryTarget.BEST_MATCHING),
63
+ )
64
+ out = []
65
+ for reply in replies:
66
+ if reply.ok is not None:
67
+ s = reply.ok
68
+ out.append({"key": str(s.key_expr), "payload_hex": s.payload.to_bytes().hex(),
69
+ "att": _att(s), "encoding": str(s.encoding), "is_error": False})
70
+ else:
71
+ e = reply.err
72
+ out.append({"key": req["keyexpr"], "payload_hex": e.payload.to_bytes().hex(),
73
+ "att": "", "encoding": str(e.encoding), "is_error": True})
74
+ return out
75
+
76
+
77
+ def _handle_conn(conn: socket.socket, session: zenoh.Session) -> None:
48
78
  send_lock = threading.Lock()
49
- sub_ids: dict[str, int] = {}
79
+ subs: dict[str, zenoh.Subscriber] = {}
50
80
  try:
51
81
  while True:
52
82
  req = _recv_frame(conn)
53
83
  if req is None:
54
84
  return
55
85
  op = req.get("type")
86
+ rid = req.get("id")
56
87
 
57
- if op == "query":
58
- payload = bytes.fromhex(req["payload_hex"]) if req.get("payload_hex") else None
59
- try:
60
- replies = zenoh_client._client.query(
61
- req["keyexpr"], payload, req.get("timeout_ms", 2000), req.get("target", 0)
62
- )
63
- out = {
64
- "type": "query_resp", "id": req["id"], "ok": True,
65
- "replies": [
66
- {"key": k, "payload_hex": bytes(p).hex(), "att": a, "encoding": e, "is_error": ie}
67
- for (k, p, a, e, ie) in replies
68
- ],
69
- }
70
- except Exception as exc:
71
- out = {"type": "query_resp", "id": req["id"], "ok": False, "error": f"{type(exc).__name__}: {exc}"}
72
- _send_frame(conn, send_lock, out)
73
-
74
- elif op == "put":
75
- payload = bytes.fromhex(req["payload_hex"]) if req.get("payload_hex") else b""
76
- try:
77
- zenoh_client._client.publish(req["keyexpr"], payload, "", 0, 5, False)
78
- out = {"type": "put_resp", "id": req["id"], "ok": True}
79
- except Exception as exc:
80
- out = {"type": "put_resp", "id": req["id"], "ok": False, "error": f"{type(exc).__name__}: {exc}"}
81
- _send_frame(conn, send_lock, out)
82
-
83
- elif op == "subscribe":
84
- keyexpr = req["keyexpr"]
85
-
86
- def on_sample(topic: str, payload_bytes: bytes, att: str, _ke: str = keyexpr) -> None:
87
- _send_frame(conn, send_lock, {
88
- "type": "sample", "keyexpr": _ke, "key": topic,
89
- "payload_hex": bytes(payload_bytes).hex(), "att": att,
90
- })
91
-
92
- try:
93
- sub_id = zenoh_client._client.declare_subscriber(keyexpr, on_sample)
94
- sub_ids[keyexpr] = sub_id
95
- out = {"type": "subscribe_resp", "id": req["id"], "ok": True}
96
- except Exception as exc:
97
- out = {"type": "subscribe_resp", "id": req["id"], "ok": False, "error": f"{type(exc).__name__}: {exc}"}
98
- _send_frame(conn, send_lock, out)
99
-
100
- elif op == "unsubscribe":
101
- keyexpr = req["keyexpr"]
102
- sub_id = sub_ids.pop(keyexpr, None)
103
- if sub_id is not None:
104
- try:
105
- zenoh_client._client.undeclare_subscriber(sub_id)
106
- except Exception:
107
- pass
108
- _send_frame(conn, send_lock, {"type": "unsubscribe_resp", "id": req["id"], "ok": True})
109
-
110
- else:
111
- _send_frame(conn, send_lock, {"type": "error", "error": f"unknown op {op!r}"})
88
+ try:
89
+ if op == "query":
90
+ out = {"type": "query_resp", "id": rid, "ok": True, "replies": _query(session, req)}
91
+
92
+ elif op == "put":
93
+ payload = bytes.fromhex(req["payload_hex"]) if req.get("payload_hex") else b""
94
+ session.put(req["keyexpr"], payload)
95
+ out = {"type": "put_resp", "id": rid, "ok": True}
96
+
97
+ elif op == "subscribe":
98
+ keyexpr = req["keyexpr"]
99
+
100
+ def on_sample(s, _ke: str = keyexpr) -> None:
101
+ _send_frame(conn, send_lock, {
102
+ "type": "sample", "keyexpr": _ke, "key": str(s.key_expr),
103
+ "payload_hex": s.payload.to_bytes().hex(), "att": _att(s),
104
+ })
105
+
106
+ old = subs.pop(keyexpr, None)
107
+ if old is not None:
108
+ old.undeclare()
109
+ subs[keyexpr] = session.declare_subscriber(keyexpr, on_sample)
110
+ out = {"type": "subscribe_resp", "id": rid, "ok": True}
111
+
112
+ elif op == "unsubscribe":
113
+ sub = subs.pop(req["keyexpr"], None)
114
+ if sub is not None:
115
+ with contextlib.suppress(Exception):
116
+ sub.undeclare()
117
+ out = {"type": "unsubscribe_resp", "id": rid, "ok": True}
118
+
119
+ else:
120
+ out = {"type": "error", "error": f"unknown op {op!r}"}
121
+ except Exception as exc:
122
+ out = {"type": f"{op}_resp", "id": rid, "ok": False, "error": f"{type(exc).__name__}: {exc}"}
123
+ _send_frame(conn, send_lock, out)
112
124
  except (ConnectionResetError, BrokenPipeError, OSError):
113
125
  pass
114
126
  finally:
115
- for sub_id in sub_ids.values():
116
- try:
117
- zenoh_client._client.undeclare_subscriber(sub_id)
118
- except Exception:
119
- pass
127
+ for sub in subs.values():
128
+ with contextlib.suppress(Exception):
129
+ sub.undeclare()
120
130
  conn.close()
121
131
 
122
132
 
123
- def main() -> None:
124
- from rainbow.rb_sdk.base import RBBaseSDK
133
+ class Peer:
134
+ """Registers this host with the robot's common: POST /zenoh/peer, heartbeat, DELETE."""
135
+
136
+ def __init__(self, host: str, token: str, ip: str, port: int) -> None:
137
+ self.base = f"http://{host}:8000"
138
+ self.headers = {"Content-Type": "application/json"}
139
+ if token:
140
+ self.headers["Authorization"] = f"Bearer {token}"
141
+ self.body = {"ip": ip, "port": port, "ttl": PEER_TTL}
142
+ self.peer_id: str | None = None
143
+ self.stop = threading.Event()
144
+
145
+ def _http(self, method: str, path: str, body: dict | None = None) -> dict:
146
+ data = json.dumps(body).encode() if body is not None else None
147
+ req = urllib.request.Request(self.base + path, data=data, headers=self.headers, method=method)
148
+ with urllib.request.urlopen(req, timeout=10) as resp:
149
+ raw = resp.read()
150
+ return json.loads(raw) if raw else {}
151
+
152
+ def register(self) -> None:
153
+ self.peer_id = self._http("POST", "/zenoh/peer", self.body)["peer_id"]
154
+ print(f"[host-agent] registered with {self.base} as zenoh peer {self.peer_id}", flush=True)
125
155
 
156
+ def maintain(self) -> None:
157
+ while True:
158
+ wait = PEER_TTL / 2 if self.peer_id else 3
159
+ if self.stop.wait(wait):
160
+ return
161
+ try:
162
+ if self.peer_id is None:
163
+ self.register()
164
+ elif not self._http("POST", f"/zenoh/peer/{self.peer_id}/heartbeat", {"ttl": PEER_TTL}).get("ok"):
165
+ print(f"[host-agent] heartbeat rejected for {self.peer_id}, re-registering", flush=True)
166
+ self.peer_id = None
167
+ except Exception as exc:
168
+ print(f"[host-agent] peer maintenance failed ({exc}); retrying", flush=True)
169
+ if self.peer_id is not None and isinstance(exc, urllib.error.HTTPError) and exc.code == 404:
170
+ self.peer_id = None
171
+
172
+ def close(self) -> None:
173
+ self.stop.set()
174
+ if self.peer_id:
175
+ with contextlib.suppress(Exception):
176
+ self._http("DELETE", f"/zenoh/peer/{self.peer_id}")
177
+
178
+
179
+ def main() -> None:
180
+ # dev stops us with SIGTERM; exit via `finally` to deregister.
181
+ signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
126
182
  host = os.environ["MUSCAT_ROBOT_HOST"]
127
183
  token = os.environ.get("MUSCAT_ROBOT_TOKEN", "")
128
184
  advertise = os.environ["MUSCAT_ZENOH_ADVERTISE_HOST"]
129
185
  zenoh_port = int(os.environ["MUSCAT_ZENOH_PORT"])
130
186
  proxy_port = int(os.environ["MUSCAT_PROXY_PORT"])
131
187
 
132
- # The robot reverse-dials the advertised host:port; force it to the host's
133
- # real LAN IP (matches what dev.mjs computed via ensureRemoteZenohEnv).
134
- RBBaseSDK._detect_local_ip = staticmethod(lambda _host: advertise)
188
+ # Listen only; the robot dials in after registration.
189
+ conf = zenoh.Config()
190
+ conf.insert_json5("mode", '"peer"')
191
+ conf.insert_json5("listen/endpoints", json.dumps([f"tcp/0.0.0.0:{zenoh_port}"]))
192
+ conf.insert_json5("scouting/multicast/enabled", "false")
193
+ session = zenoh.open(conf)
135
194
 
136
- peer = RBBaseSDK()
137
- connect_kwargs = {"host": host, "zenoh_port": zenoh_port, "mesh_timeout": 15.0}
138
- if token:
139
- connect_kwargs["token"] = token
140
-
141
- asyncio.run(peer.connect(**connect_kwargs))
142
- print(f"[host-agent] zenoh peer joined robot mesh (listening :{zenoh_port})", flush=True)
195
+ peer = Peer(host, token, advertise, zenoh_port)
196
+ try:
197
+ peer.register()
198
+ except Exception as exc:
199
+ print(f"[host-agent] registration with {peer.base} failed, retrying in background: {exc}", flush=True)
200
+ threading.Thread(target=peer.maintain, daemon=True, name="zenoh-peer").start()
201
+ print(f"[host-agent] zenoh peer listening :{zenoh_port} (advertised as {advertise})", flush=True)
143
202
 
144
203
  srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
145
204
  srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
@@ -150,12 +209,13 @@ def main() -> None:
150
209
  try:
151
210
  while True:
152
211
  conn, _addr = srv.accept()
153
- threading.Thread(target=_handle_conn, args=(conn, peer.zenoh_client), daemon=True).start()
212
+ threading.Thread(target=_handle_conn, args=(conn, session), daemon=True).start()
154
213
  except KeyboardInterrupt:
155
214
  pass
156
215
  finally:
216
+ peer.close()
157
217
  with contextlib.suppress(Exception):
158
- asyncio.run(peer.disconnect())
218
+ session.close()
159
219
 
160
220
 
161
221
  if __name__ == "__main__":
@@ -11,6 +11,15 @@ export const CONTRIBUTION_POINTS = [
11
11
  "program.panel",
12
12
  ];
13
13
  const ICON_SIZES = ["16", "48", "128"];
14
+ // Reverse-domain id: "."-separated parts of lowercase letters/digits joined by single "-",
15
+ // each part starting with a letter. Keeps the ROS namespace derived from it (rosNamespace)
16
+ // valid and collision-free (no "_" in ids, so "." and "-" map to distinct characters).
17
+ export const PLUGIN_ID_RE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*(\.[a-z][a-z0-9]*(-[a-z0-9]+)*)*$/;
18
+
19
+ /** ROS namespace for a plugin id: "." → "/", "-" → "_" (com.example.my-plugin → /com/example/my_plugin). */
20
+ export function rosNamespace(id) {
21
+ return "/" + id.replace(/\./g, "/").replace(/-/g, "_");
22
+ }
14
23
  const RUNTIME_TYPES = ["none", "python", "cpp", "ros2"];
15
24
  const ROS_CLIENTS = ["rclpy", "rclcpp"];
16
25
  const I18N_LANGS = ["ko", "en", "ja", "zh", "vi", "th", "es"];
@@ -192,6 +201,8 @@ export function validateManifest(manifest) {
192
201
  if (typeof manifest[key] !== "string" || manifest[key].length === 0)
193
202
  errors.push(`"${key}" is required`);
194
203
  }
204
+ if (typeof manifest.id === "string" && manifest.id && !PLUGIN_ID_RE.test(manifest.id))
205
+ errors.push(`"id" must be "."-separated parts of lowercase letters/digits and single "-", each starting with a letter (e.g. com.example.my-plugin)`);
195
206
  if (typeof manifest.author !== "string" || manifest.author.length === 0)
196
207
  errors.push(`"author" is required`);
197
208
  if (typeof manifest.email !== "string" || manifest.email.length === 0)