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

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 (129) hide show
  1. package/cli/README.md +3 -8
  2. package/cli/bin/muscat-pk.mjs +0 -5
  3. package/cli/bundle-templates.mjs +1 -6
  4. package/cli/docker/Dockerfile.cpp +6 -28
  5. package/cli/docker/Dockerfile.dev +0 -32
  6. package/cli/docker/Dockerfile.python +0 -19
  7. package/cli/src/bridge.mjs +12 -45
  8. package/cli/src/build.mjs +79 -48
  9. package/cli/src/cpp.mjs +5 -11
  10. package/cli/src/dev.mjs +2 -7
  11. package/cli/src/devBack.mjs +40 -97
  12. package/cli/src/host_agent.py +83 -143
  13. package/cli/src/manifest.mjs +0 -11
  14. package/cli/src/mcp.mjs +54 -398
  15. package/cli/src/new.mjs +3 -13
  16. package/cli/src/ports.mjs +7 -21
  17. package/cli/src/python.mjs +20 -16
  18. package/cli/src/ros2.mjs +8 -16
  19. package/cli/src/sdk.mjs +21 -23
  20. package/cli/src/templates-embedded.mjs +4 -0
  21. package/cli/src/type.mjs +10 -44
  22. package/cli/src/uninstall.mjs +20 -3
  23. package/cli/src/update.mjs +8 -14
  24. package/cli/src/util.mjs +9 -70
  25. package/cli/storybook-dist/_stories-src/RBDatePicker/RBRangeDateInput.stories.tsx +1 -13
  26. package/cli/storybook-dist/_stories-src/RBInput/RBSelectBox.stories.tsx +1 -98
  27. package/cli/storybook-dist/assets/{Color-YHDXOIA2-C5ttlJWI.js → Color-YHDXOIA2-Du8dJ-nM.js} +1 -1
  28. package/cli/storybook-dist/assets/{Docs-CCfiEyzU.js → Docs--Fm8069p.js} +1 -1
  29. package/cli/storybook-dist/assets/{Docs-B3AV-YvP.js → Docs-Cb72AJnj.js} +1 -1
  30. package/cli/storybook-dist/assets/{Docs-BN206lKl.js → Docs-LdjYwuG4.js} +1 -1
  31. package/cli/storybook-dist/assets/{Docs-BqH2prGO.js → Docs-Sy5RDbtH.js} +1 -1
  32. package/cli/storybook-dist/assets/{Docs-DhncK7Aw.js → Docs-guGflRzZ.js} +1 -1
  33. package/cli/storybook-dist/assets/{Docs-CGN1_Ua8.js → Docs-pwGqGTV_.js} +1 -1
  34. package/cli/storybook-dist/assets/{DocsRenderer-CFRXHY34-D-w8Kq64.js → DocsRenderer-CFRXHY34-YGBLQ4BD.js} +1 -1
  35. package/cli/storybook-dist/assets/{NxAvatar.stories-oCcYtLwq.js → NxAvatar.stories-C1d4n8cq.js} +1 -1
  36. package/cli/storybook-dist/assets/{NxButton.stories-C5jUScIj.js → NxButton.stories-gCBoZdMq.js} +1 -1
  37. package/cli/storybook-dist/assets/{NxIcons.stories-DeqrVzVs.js → NxIcons.stories-Dg42riT5.js} +1 -1
  38. package/cli/storybook-dist/assets/{NxNumberInput.stories-gjsEulIN.js → NxNumberInput.stories-DB-vLM2J.js} +1 -1
  39. package/cli/storybook-dist/assets/{NxPagination.stories-BK6uSWIa.js → NxPagination.stories-CS8iMIak.js} +1 -1
  40. package/cli/storybook-dist/assets/{NxPointerHoldButton.stories-BDD5CvSZ.js → NxPointerHoldButton.stories-D66BopeS.js} +1 -1
  41. package/cli/storybook-dist/assets/{NxRangeInput.stories-C_nW26_x.js → NxRangeInput.stories-CZxddsJ2.js} +3 -3
  42. package/cli/storybook-dist/assets/{NxSelectBox.stories-DqLfKU9H.js → NxSelectBox.stories-CmezGGrm.js} +1 -1
  43. package/cli/storybook-dist/assets/{NxSlideRange.stories-CQjHA8yv.js → NxSlideRange.stories-CGAFMITi.js} +1 -1
  44. package/cli/storybook-dist/assets/{NxSwitch.stories-BMHCiPoL.js → NxSwitch.stories-B3QInkXX.js} +1 -1
  45. package/cli/storybook-dist/assets/{NxTextArea.stories-k7r1WVQh.js → NxTextArea.stories-BgJcgZNe.js} +1 -1
  46. package/cli/storybook-dist/assets/{NxTextInput.stories-Cqfe1I32.js → NxTextInput.stories-CwMQwI9L.js} +1 -1
  47. package/cli/storybook-dist/assets/{RBAccordion.stories-CJ3Ttuja.js → RBAccordion.stories-BuBVNzJT.js} +1 -1
  48. package/cli/storybook-dist/assets/{RBAccordionGroup.stories-BThv7tM0.js → RBAccordionGroup.stories-Crdb6kFF.js} +1 -1
  49. package/cli/storybook-dist/assets/{RBAlert.stories-CSMP8Vun.js → RBAlert.stories-CqXgUvA2.js} +1 -1
  50. package/cli/storybook-dist/assets/{RBBadge.stories-cBMA2R6F.js → RBBadge.stories-BCw_ksML.js} +1 -1
  51. package/cli/storybook-dist/assets/{RBBattery.stories-BKVOskqs.js → RBBattery.stories-0id4XYoc.js} +1 -1
  52. package/cli/storybook-dist/assets/{RBCard.stories-BTOfK0HC.js → RBCard.stories-CgpG-7Lk.js} +1 -1
  53. package/cli/storybook-dist/assets/{RBCheckBox.stories-DyE912wG.js → RBCheckBox.stories-BG02BoGX.js} +1 -1
  54. package/cli/storybook-dist/assets/{RBCheckIconButton.stories-0sLFU0kM.js → RBCheckIconButton.stories-vrjD4GQ4.js} +1 -1
  55. package/cli/storybook-dist/assets/{RBCheckLabelButton.stories-c8VecClE.js → RBCheckLabelButton.stories-POba8lU-.js} +1 -1
  56. package/cli/storybook-dist/assets/{RBChipTab.stories-CQ9Uav5_.js → RBChipTab.stories-XWJbFrHF.js} +1 -1
  57. package/cli/storybook-dist/assets/{RBChipsBoxTab.stories-Ap4NlhAZ.js → RBChipsBoxTab.stories-BdU0Dzdp.js} +1 -1
  58. package/cli/storybook-dist/assets/{RBColorIcons.stories-BPPXjif2.js → RBColorIcons.stories-C_0dCNXG.js} +1 -1
  59. package/cli/storybook-dist/assets/{RBCommand.stories-PDpYOKlm.js → RBCommand.stories-Dvrko5No.js} +1 -1
  60. package/cli/storybook-dist/assets/{RBConfirm.stories-BEWvMAYP.js → RBConfirm.stories-CkTn0sHa.js} +1 -1
  61. package/cli/storybook-dist/assets/{RBDateInput.stories-DLdyNnVF.js → RBDateInput.stories-BH3D08cs.js} +1 -1
  62. package/cli/storybook-dist/assets/{RBDialog.stories-HsawMNOb.js → RBDialog.stories-C2nNX9fl.js} +1 -1
  63. package/cli/storybook-dist/assets/{RBDivider.stories-dlWwOxik.js → RBDivider.stories-D-GrJ0QZ.js} +1 -1
  64. package/cli/storybook-dist/assets/{RBDrawer.stories-C87iqGk0.js → RBDrawer.stories-XLOnLOnh.js} +1 -1
  65. package/cli/storybook-dist/assets/{RBDropdownMenu.stories-B8EQhA0z.js → RBDropdownMenu.stories-BjBSeoV2.js} +1 -1
  66. package/cli/storybook-dist/assets/{RBEmptyView.stories-SzpIhNDw.js → RBEmptyView.stories-C6U2zKNE.js} +1 -1
  67. package/cli/storybook-dist/assets/{RBIconButton.stories-BQ0nM1gb.js → RBIconButton.stories-MCJq5Hta.js} +1 -1
  68. package/cli/storybook-dist/assets/{RBIcons.stories-C19g5M5z.js → RBIcons.stories-DxOCiCe0.js} +1 -1
  69. package/cli/storybook-dist/assets/{RBInlineLabelButton.stories-DbgI4-1P.js → RBInlineLabelButton.stories-BVZi1aEs.js} +1 -1
  70. package/cli/storybook-dist/assets/{RBJoyStick.stories-smF-vpGL.js → RBJoyStick.stories-BDThbEek.js} +1 -1
  71. package/cli/storybook-dist/assets/{RBLabelButton.stories-s53klwDv.js → RBLabelButton.stories-CaPEKUS6.js} +1 -1
  72. package/cli/storybook-dist/assets/{RBLight.stories-BnzkoR51.js → RBLight.stories-CihQzyEL.js} +1 -1
  73. package/cli/storybook-dist/assets/{RBMenubar.stories-CW014klu.js → RBMenubar.stories-J852XVFh.js} +1 -1
  74. package/cli/storybook-dist/assets/{RBMultiRangeInput.stories-qa4tH_4Y.js → RBMultiRangeInput.stories-DXyX0cOZ.js} +1 -1
  75. package/cli/storybook-dist/assets/{RBPagination.stories-YoQNUpQH.js → RBPagination.stories-C9_49ymO.js} +1 -1
  76. package/cli/storybook-dist/assets/{RBPopover.stories-Bhpi0OaH.js → RBPopover.stories-DxLE912-.js} +1 -1
  77. package/cli/storybook-dist/assets/{RBPrompt.stories-iQM5iAka.js → RBPrompt.stories-DlsfGqWb.js} +1 -1
  78. package/cli/storybook-dist/assets/{RBRadioBox.stories-BGyq4rzx.js → RBRadioBox.stories-hb5cNWW1.js} +1 -1
  79. package/cli/storybook-dist/assets/{RBRadioButton.stories-DF6BcUcJ.js → RBRadioButton.stories-CSuOgeN-.js} +1 -1
  80. package/cli/storybook-dist/assets/RBRangeDateInput.stories-KRg2glX8.js +15 -0
  81. package/cli/storybook-dist/assets/{RBResizable.stories-DSVHvwjp.js → RBResizable.stories-BetjF9vG.js} +1 -1
  82. package/cli/storybook-dist/assets/RBSelectBox.stories-ILLQQ8M8.js +6 -0
  83. package/cli/storybook-dist/assets/{RBSheet.stories-CAK9hb-p.js → RBSheet.stories-D3QbcMKq.js} +1 -1
  84. package/cli/storybook-dist/assets/{RBSideNumberInput.stories-DUymnH-9.js → RBSideNumberInput.stories-DcTh0IjM.js} +1 -1
  85. package/cli/storybook-dist/assets/{RBSlider.stories-BwlDh9--.js → RBSlider.stories-B59We7ei.js} +1 -1
  86. package/cli/storybook-dist/assets/{RBSpinner.stories-VOlkuP6t.js → RBSpinner.stories-9u3_ETbi.js} +1 -1
  87. package/cli/storybook-dist/assets/{RBSwitch.stories-BiW2dQnT.js → RBSwitch.stories-DrVm1WGX.js} +1 -1
  88. package/cli/storybook-dist/assets/{RBTab.stories-DFKVsy0C.js → RBTab.stories-D8Dm7cRW.js} +1 -1
  89. package/cli/storybook-dist/assets/{RBTable.stories-BWG0ZSYI.js → RBTable.stories-dRdZ8g1j.js} +1 -1
  90. package/cli/storybook-dist/assets/{RBTableRow.stories-JVTD83Mh.js → RBTableRow.stories-rZeoGwHZ.js} +1 -1
  91. package/cli/storybook-dist/assets/{RBTabs.stories-D-aN385u.js → RBTabs.stories-STRV2lgO.js} +1 -1
  92. package/cli/storybook-dist/assets/{RBTextArea.stories-Dx5xkmZS.js → RBTextArea.stories-CfgUbrbJ.js} +1 -1
  93. package/cli/storybook-dist/assets/{RBTextField.stories-DmVciExd.js → RBTextField.stories-B5t8-Y97.js} +1 -1
  94. package/cli/storybook-dist/assets/{RBTextInput.stories-CCf0VGEv.js → RBTextInput.stories-Bf1edN6l.js} +1 -1
  95. package/cli/storybook-dist/assets/{RBToast.stories-DT0DGn05.js → RBToast.stories-Cnm8JwQ6.js} +1 -1
  96. package/cli/storybook-dist/assets/{RBToggle.stories-Ce_q3EtP.js → RBToggle.stories-B0OCo6zd.js} +1 -1
  97. package/cli/storybook-dist/assets/{RBToggleGroup.stories-CN40YHsR.js → RBToggleGroup.stories-GdYO2Fxg.js} +1 -1
  98. package/cli/storybook-dist/assets/{RBTooltip.stories-L7btDEpA.js → RBTooltip.stories-DXJTCScO.js} +1 -1
  99. package/cli/storybook-dist/assets/SideNumberInput-CrF-a1Nr.js +1114 -0
  100. package/cli/storybook-dist/assets/browserUtils-CxJ5gea3.js +1 -0
  101. package/cli/storybook-dist/assets/{entry-preview-D_nGAXUk.js → entry-preview-DSRwIaVJ.js} +1 -1
  102. package/cli/storybook-dist/assets/{iframe-23VS_ZeI.js → iframe-Cj53Wn3d.js} +2 -2
  103. package/cli/storybook-dist/assets/{index-ClAc6FBW.js → index-C1OzuK6-.js} +1 -1
  104. package/cli/storybook-dist/assets/{index-Ckae_HWe.js → index-CkHtLEe8.js} +3 -3
  105. package/cli/storybook-dist/assets/preview-CEfMaO1t.css +1 -0
  106. package/cli/storybook-dist/assets/{preview-BVJwlGUF.js → preview-CILRoSnB.js} +1 -1
  107. package/cli/storybook-dist/assets/{preview-CD0LINug.js → preview-CgOtgtCD.js} +2 -2
  108. package/cli/storybook-dist/assets/preview-D_2um8Sd.js +3 -0
  109. package/cli/storybook-dist/assets/react-resizable-panels.browser-CtORL3tG.js +102 -0
  110. package/cli/storybook-dist/iframe.html +1 -1
  111. package/cli/storybook-dist/index.html +1 -1
  112. package/cli/storybook-dist/index.json +1 -1
  113. package/cli/storybook-dist/project.json +1 -1
  114. package/cli/templates/common/README.md +7 -211
  115. package/cli/templates/common/py/main.py +2 -29
  116. package/cli/templates/common/requirements.txt +3 -3
  117. package/cli/templates/cpp/cpp/CMakeLists.txt +7 -6
  118. package/cli/templates/cpp/cpp/main.cpp +10 -57
  119. package/cli/templates/ros2-cpp/ros2_ws/src/__PLUGIN_SLUG__/CMakeLists.txt +0 -4
  120. package/cli/templates/ros2-cpp/ros2_ws/src/__PLUGIN_SLUG__/src/node.cpp +4 -5
  121. package/package.json +3 -3
  122. package/cli/storybook-dist/assets/RBRangeDateInput.stories-COOQ_HNF.js +0 -15
  123. package/cli/storybook-dist/assets/RBSelectBox.stories-BookV8Sm.js +0 -74
  124. package/cli/storybook-dist/assets/SideNumberInput-B5_xLK8z.js +0 -1109
  125. package/cli/storybook-dist/assets/browserUtils-CQM9Za7e.js +0 -1
  126. package/cli/storybook-dist/assets/preview-P8yWWsME.js +0 -3
  127. package/cli/storybook-dist/assets/preview-zuAvMksL.css +0 -1
  128. package/cli/storybook-dist/assets/react-resizable-panels.browser-CYcsrUgg.js +0 -120
  129. package/cli/templates/readme-cpp/README.md +0 -993
@@ -8,12 +8,11 @@
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, 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";
11
+ import { existsSync, watch } from "node:fs";
12
+ import { resolve } from "node:path";
13
+ import { ensureDocker, ensureIdeVenv, findPluginRoot, log, promptRobotConnection, realtimeFlags } from "./util.mjs";
15
14
  import { ensureRemoteZenohEnv, usesHostNetwork } from "./zenohDev.mjs";
16
- import { PLUGIN_ID_RE, assertManifestPorts, assertManifestVariables, manifestRuntime, readManifest, rosNamespace, validateManifest } from "./manifest.mjs";
15
+ import { assertManifestPorts, assertManifestVariables, manifestRuntime, readManifest, validateManifest } from "./manifest.mjs";
17
16
  import {
18
17
  formatPortRanges,
19
18
  portEnvFlags,
@@ -53,67 +52,6 @@ export function stopDevBackContainers() {
53
52
  }
54
53
  }
55
54
 
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
-
117
55
  // ── IDE tooling venv (best-effort, non-blocking) ────────────────────────────
118
56
  // `muscat-pk new` already creates this up front (see new.mjs); this just
119
57
  // refreshes it on every `dev`/`dev:back` in case requirements.txt changed.
@@ -157,18 +95,39 @@ async function runPython(root, portRanges) {
157
95
  // container shares the host's network stack, so it reaches the bridge at
158
96
  // localhost:10000 and the robot's Zenoh router / reverse-dial path directly
159
97
  // — no netns sharing, port publishing, host.docker.internal, or byte-proxy.
160
- const net = backendNetwork(portRanges);
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}`] : [];
161
105
  // Only the container's OWN reverse-dial path needs to publish a listen port.
162
- // With a shared netns the port is already reachable; with the byte-proxy
106
+ // With host networking the port is already on the host; with the byte-proxy
163
107
  // the container just connects out — neither needs -p.
164
108
  const standaloneZenohPortFlags =
165
- !net.shared && !proxyEndpoint && zenohPort ? ["-p", `${zenohPort}:${zenohPort}`] : [];
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"];
166
125
  const envFlags = [
167
126
  // docker exec below has no -t (no pseudo-TTY), so Python defaults to
168
127
  // block-buffered stdout — print() output wouldn't show up until the
169
128
  // buffer fills or the process exits. Force unbuffered.
170
129
  "-e", "PYTHONUNBUFFERED=1",
171
- ...net.env,
130
+ ...gatewayFlags,
172
131
  ...(pluginId ? ["-e", `RB_PLUGIN_ID=${pluginId}`] : []),
173
132
  ...(robotIp ? ["-e", `MUSCAT_ROBOT_HOST=${robotIp}`] : []),
174
133
  ...(robotToken ? ["-e", `MUSCAT_ROBOT_TOKEN=${robotToken}`] : []),
@@ -184,18 +143,19 @@ async function runPython(root, portRanges) {
184
143
  const docker = (args, opts = {}) =>
185
144
  spawnSync("docker", args, { cwd: root, stdio: "inherit", ...opts });
186
145
 
187
- // start a warm, long-lived container with the plugin mounted.
188
- // 호스트 uid 로 돌린다(dockerUserFlags) — root 로 쓴 build/·install/ 은 사용자가 지우지 못한다.
146
+ // start a warm, long-lived container with the plugin mounted
189
147
  docker(["rm", "-f", container], { stdio: "ignore" });
190
148
  log.step("starting Python dev container…");
191
149
  const up = docker(
192
- ["run", "-d", "--name", container, ...net.run, ...standaloneZenohPortFlags, ...realtimeFlags(), ...dockerUserFlags(),
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,
193
154
  "-e", "PIP_ROOT_USER_ACTION=ignore",
194
155
  "-v", `${root}:/plugin`, "-w", "/plugin", PYTHON_DEV_IMAGE, "sleep", "infinity"],
195
156
  { stdio: "inherit" },
196
157
  );
197
158
  if (up.status !== 0) throw new Error("failed to start dev container.");
198
- startMediaServer(container);
199
159
  const stopPortMonitor = startPortContractMonitor({ container, ranges: portRanges, log, allow: [zenohPort] });
200
160
 
201
161
  log.step("pip install requirements + watchfiles (in container)…");
@@ -413,7 +373,7 @@ runpy.run_path("/plugin/py/main.py", run_name="__main__")
413
373
  PY`]);
414
374
 
415
375
  if (pluginId) log.step(`RB_PLUGIN_ID=${pluginId}`);
416
- if (usesHostNetwork()) log.step("network: host (native Linux — direct robot/bridge access)");
376
+ if (useHostNet) log.step("network: host (native Linux — direct robot/bridge access)");
417
377
  if (proxyEndpoint) log.step(`Zenoh via host byte-proxy → ${proxyEndpoint}`);
418
378
  else if (zenohAdvertiseHost && zenohPort) log.step(`Zenoh advertise → ${zenohAdvertiseHost}:${zenohPort}`);
419
379
  log.step("watchfiles → py/main.py (reload on any .py change in py/)");
@@ -469,14 +429,10 @@ async function runRos2(root, portRanges) {
469
429
  const container = `${ROS2_DEV_IMAGE}-run`;
470
430
  const robotIp = process.env.MUSCAT_ROBOT_HOST ?? "";
471
431
  const robotToken = process.env.MUSCAT_ROBOT_TOKEN ?? "";
472
- const net = backendNetwork(portRanges);
473
432
  const envFlags = [
474
433
  // Same rationale as runPython(): no -t on docker exec below means
475
434
  // rclpy's Python process stdout is block-buffered, not line-buffered.
476
435
  "-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,
480
436
  ...(pluginId ? ["-e", `RB_PLUGIN_ID=${pluginId}`] : []),
481
437
  ...(robotIp ? ["-e", `MUSCAT_ROBOT_HOST=${robotIp}`] : []),
482
438
  ...(robotToken ? ["-e", `MUSCAT_ROBOT_TOKEN=${robotToken}`] : []),
@@ -489,11 +445,10 @@ async function runRos2(root, portRanges) {
489
445
  docker(["rm", "-f", container], { stdio: "ignore" });
490
446
  log.step("starting ROS2 dev container…");
491
447
  const up = docker(
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"],
448
+ ["run", "-d", "--name", container, ...publishPortFlags(portRanges), ...realtimeFlags(), "-e", "PIP_ROOT_USER_ACTION=ignore", "-v", `${root}:/plugin`, "-w", "/plugin", ROS2_DEV_IMAGE, "sleep", "infinity"],
493
449
  { stdio: "inherit" },
494
450
  );
495
451
  if (up.status !== 0) throw new Error("failed to start dev container.");
496
- startMediaServer(container);
497
452
  const stopPortMonitor = startPortContractMonitor({ container, ranges: portRanges, log, allow: [process.env.MUSCAT_ZENOH_PORT] });
498
453
 
499
454
  // one-time deps + symlink build (edits to .py reflect without rebuilding)
@@ -504,24 +459,15 @@ async function runRos2(root, portRanges) {
504
459
  let child = null;
505
460
  const startNode = () => {
506
461
  if (child) child.kill();
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);
462
+ log.step(`ros2 run ${pkg} ${pkg}_node`);
516
463
  child = spawn(
517
464
  "docker",
518
- ["exec", ...envFlags, ...(peers.length ? ["-e", `ROS_STATIC_PEERS=${peers.join(";")}`] : []), container, "bash", "-lc",
519
- `cd ros2_ws && source install/setup.bash && ${run}`],
465
+ ["exec", ...envFlags, container, "bash", "-lc",
466
+ `cd ros2_ws && source install/setup.bash && ros2 run ${pkg} ${pkg}_node`],
520
467
  { cwd: root, stdio: "inherit" },
521
468
  );
522
469
  };
523
470
  startNode();
524
- serveRosPeers(root, pluginId, startNode);
525
471
 
526
472
  // restart node on source change. rclpy reflects .py edits live (symlink-install);
527
473
  // rclcpp (C++) and package-metadata changes require a colcon rebuild.
@@ -567,9 +513,7 @@ async function runCpp(root, portRanges) {
567
513
  const container = `${CPP_DEV_IMAGE}-run`;
568
514
  const robotIp = process.env.MUSCAT_ROBOT_HOST ?? "";
569
515
  const robotToken = process.env.MUSCAT_ROBOT_TOKEN ?? "";
570
- const net = backendNetwork(portRanges);
571
516
  const envFlags = [
572
- ...net.env,
573
517
  ...(pluginId ? ["-e", `RB_PLUGIN_ID=${pluginId}`] : []),
574
518
  ...(robotIp ? ["-e", `MUSCAT_ROBOT_HOST=${robotIp}`] : []),
575
519
  ...(robotToken ? ["-e", `MUSCAT_ROBOT_TOKEN=${robotToken}`] : []),
@@ -582,11 +526,10 @@ async function runCpp(root, portRanges) {
582
526
  docker(["rm", "-f", container], { stdio: "ignore" });
583
527
  log.step("starting C++ dev container…");
584
528
  const up = docker(
585
- ["run", "-d", "--name", container, ...net.run, ...realtimeFlags(), ...dockerUserFlags(), "-v", `${root}:/plugin`, "-w", "/plugin", CPP_DEV_IMAGE, "sleep", "infinity"],
529
+ ["run", "-d", "--name", container, ...publishPortFlags(portRanges), ...realtimeFlags(), "-v", `${root}:/plugin`, "-w", "/plugin", CPP_DEV_IMAGE, "sleep", "infinity"],
586
530
  { stdio: "inherit" },
587
531
  );
588
532
  if (up.status !== 0) throw new Error("failed to start dev container.");
589
- startMediaServer(container);
590
533
  const stopPortMonitor = startPortContractMonitor({ container, ranges: portRanges, log, allow: [process.env.MUSCAT_ZENOH_PORT] });
591
534
 
592
535
  const build = () => {
@@ -1,7 +1,10 @@
1
- """Host-side Zenoh byte-proxy for Docker Desktop containers (Mac/WSL remote dev).
1
+ """Host-side raw Zenoh byte-proxy.
2
2
 
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.
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.
5
8
 
6
9
  Env: MUSCAT_ROBOT_HOST, MUSCAT_ROBOT_TOKEN, MUSCAT_ZENOH_ADVERTISE_HOST,
7
10
  MUSCAT_ZENOH_PORT (robot-facing), MUSCAT_PROXY_PORT (container-facing).
@@ -9,21 +12,13 @@ MUSCAT_ZENOH_PORT (robot-facing), MUSCAT_PROXY_PORT (container-facing).
9
12
 
10
13
  from __future__ import annotations
11
14
 
15
+ import asyncio
12
16
  import contextlib
13
17
  import json
14
18
  import os
15
- import signal
16
19
  import socket
17
20
  import struct
18
- import sys
19
21
  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}
27
22
 
28
23
 
29
24
  def _send_frame(conn: socket.socket, lock: threading.Lock, obj: dict) -> None:
@@ -49,156 +44,102 @@ def _recv_frame(conn: socket.socket) -> dict | None:
49
44
  return json.loads(buf.decode())
50
45
 
51
46
 
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:
47
+ def _handle_conn(conn: socket.socket, zenoh_client) -> None:
78
48
  send_lock = threading.Lock()
79
- subs: dict[str, zenoh.Subscriber] = {}
49
+ sub_ids: dict[str, int] = {}
80
50
  try:
81
51
  while True:
82
52
  req = _recv_frame(conn)
83
53
  if req is None:
84
54
  return
85
55
  op = req.get("type")
86
- rid = req.get("id")
87
56
 
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)
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}"})
124
112
  except (ConnectionResetError, BrokenPipeError, OSError):
125
113
  pass
126
114
  finally:
127
- for sub in subs.values():
128
- with contextlib.suppress(Exception):
129
- sub.undeclare()
130
- conn.close()
131
-
132
-
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)
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
115
+ for sub_id in sub_ids.values():
161
116
  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}")
117
+ zenoh_client._client.undeclare_subscriber(sub_id)
118
+ except Exception:
119
+ pass
120
+ conn.close()
177
121
 
178
122
 
179
123
  def main() -> None:
180
- # dev stops us with SIGTERM; exit via `finally` to deregister.
181
- signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
124
+ from rainbow.rb_sdk.base import RBBaseSDK
125
+
182
126
  host = os.environ["MUSCAT_ROBOT_HOST"]
183
127
  token = os.environ.get("MUSCAT_ROBOT_TOKEN", "")
184
128
  advertise = os.environ["MUSCAT_ZENOH_ADVERTISE_HOST"]
185
129
  zenoh_port = int(os.environ["MUSCAT_ZENOH_PORT"])
186
130
  proxy_port = int(os.environ["MUSCAT_PROXY_PORT"])
187
131
 
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)
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)
194
135
 
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)
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)
202
143
 
203
144
  srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
204
145
  srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
@@ -209,13 +150,12 @@ def main() -> None:
209
150
  try:
210
151
  while True:
211
152
  conn, _addr = srv.accept()
212
- threading.Thread(target=_handle_conn, args=(conn, session), daemon=True).start()
153
+ threading.Thread(target=_handle_conn, args=(conn, peer.zenoh_client), daemon=True).start()
213
154
  except KeyboardInterrupt:
214
155
  pass
215
156
  finally:
216
- peer.close()
217
157
  with contextlib.suppress(Exception):
218
- session.close()
158
+ asyncio.run(peer.disconnect())
219
159
 
220
160
 
221
161
  if __name__ == "__main__":
@@ -11,15 +11,6 @@ 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
- }
23
14
  const RUNTIME_TYPES = ["none", "python", "cpp", "ros2"];
24
15
  const ROS_CLIENTS = ["rclpy", "rclcpp"];
25
16
  const I18N_LANGS = ["ko", "en", "ja", "zh", "vi", "th", "es"];
@@ -201,8 +192,6 @@ export function validateManifest(manifest) {
201
192
  if (typeof manifest[key] !== "string" || manifest[key].length === 0)
202
193
  errors.push(`"${key}" is required`);
203
194
  }
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)`);
206
195
  if (typeof manifest.author !== "string" || manifest.author.length === 0)
207
196
  errors.push(`"author" is required`);
208
197
  if (typeof manifest.email !== "string" || manifest.email.length === 0)