@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.
- package/cli/README.md +8 -3
- package/cli/bin/muscat-pk.mjs +5 -0
- package/cli/bundle-templates.mjs +6 -1
- package/cli/docker/Dockerfile.cpp +28 -6
- package/cli/docker/Dockerfile.dev +32 -0
- package/cli/docker/Dockerfile.python +19 -0
- package/cli/src/bridge.mjs +45 -12
- package/cli/src/build.mjs +48 -79
- package/cli/src/cpp.mjs +11 -5
- package/cli/src/dev.mjs +7 -2
- package/cli/src/devBack.mjs +97 -40
- package/cli/src/host_agent.py +143 -83
- package/cli/src/manifest.mjs +11 -0
- package/cli/src/mcp.mjs +398 -54
- package/cli/src/new.mjs +13 -3
- package/cli/src/ports.mjs +21 -7
- package/cli/src/python.mjs +16 -20
- package/cli/src/ros2.mjs +16 -8
- package/cli/src/sdk.mjs +23 -21
- package/cli/src/type.mjs +44 -10
- package/cli/src/uninstall.mjs +3 -20
- package/cli/src/update.mjs +14 -8
- package/cli/src/util.mjs +70 -9
- package/cli/storybook-dist/_stories-src/RBDatePicker/RBRangeDateInput.stories.tsx +13 -1
- package/cli/storybook-dist/_stories-src/RBInput/RBSelectBox.stories.tsx +98 -1
- package/cli/storybook-dist/assets/{Color-YHDXOIA2-Du8dJ-nM.js → Color-YHDXOIA2-CdTgUqrV.js} +1 -1
- package/cli/storybook-dist/assets/{Docs-Sy5RDbtH.js → Docs-C8wx5zsC.js} +1 -1
- package/cli/storybook-dist/assets/{Docs-guGflRzZ.js → Docs-CbRKwHpQ.js} +1 -1
- package/cli/storybook-dist/assets/{Docs-Cb72AJnj.js → Docs-Cf0s04QW.js} +1 -1
- package/cli/storybook-dist/assets/{Docs-pwGqGTV_.js → Docs-CsxMprX5.js} +1 -1
- package/cli/storybook-dist/assets/{Docs-LdjYwuG4.js → Docs-FyPFrbUS.js} +1 -1
- package/cli/storybook-dist/assets/{Docs--Fm8069p.js → Docs-YtqU-aVu.js} +1 -1
- package/cli/storybook-dist/assets/{DocsRenderer-CFRXHY34-YGBLQ4BD.js → DocsRenderer-CFRXHY34-VSENNRoY.js} +1 -1
- package/cli/storybook-dist/assets/{NxAvatar.stories-C1d4n8cq.js → NxAvatar.stories-oCcYtLwq.js} +1 -1
- package/cli/storybook-dist/assets/{NxButton.stories-gCBoZdMq.js → NxButton.stories-C5jUScIj.js} +1 -1
- package/cli/storybook-dist/assets/{NxIcons.stories-Dg42riT5.js → NxIcons.stories-DeqrVzVs.js} +1 -1
- package/cli/storybook-dist/assets/{NxNumberInput.stories-DB-vLM2J.js → NxNumberInput.stories-gjsEulIN.js} +1 -1
- package/cli/storybook-dist/assets/{NxPagination.stories-CS8iMIak.js → NxPagination.stories-BK6uSWIa.js} +1 -1
- package/cli/storybook-dist/assets/{NxPointerHoldButton.stories-D66BopeS.js → NxPointerHoldButton.stories-BDD5CvSZ.js} +1 -1
- package/cli/storybook-dist/assets/{NxRangeInput.stories-CZxddsJ2.js → NxRangeInput.stories-C_nW26_x.js} +3 -3
- package/cli/storybook-dist/assets/{NxSelectBox.stories-CmezGGrm.js → NxSelectBox.stories-DqLfKU9H.js} +1 -1
- package/cli/storybook-dist/assets/{NxSlideRange.stories-CGAFMITi.js → NxSlideRange.stories-CQjHA8yv.js} +1 -1
- package/cli/storybook-dist/assets/{NxSwitch.stories-B3QInkXX.js → NxSwitch.stories-BMHCiPoL.js} +1 -1
- package/cli/storybook-dist/assets/{NxTextArea.stories-BgJcgZNe.js → NxTextArea.stories-k7r1WVQh.js} +1 -1
- package/cli/storybook-dist/assets/{NxTextInput.stories-CwMQwI9L.js → NxTextInput.stories-Cqfe1I32.js} +1 -1
- package/cli/storybook-dist/assets/{RBAccordion.stories-BuBVNzJT.js → RBAccordion.stories-CJ3Ttuja.js} +1 -1
- package/cli/storybook-dist/assets/{RBAccordionGroup.stories-Crdb6kFF.js → RBAccordionGroup.stories-BThv7tM0.js} +1 -1
- package/cli/storybook-dist/assets/{RBAlert.stories-CqXgUvA2.js → RBAlert.stories-CSMP8Vun.js} +1 -1
- package/cli/storybook-dist/assets/{RBBadge.stories-BCw_ksML.js → RBBadge.stories-cBMA2R6F.js} +1 -1
- package/cli/storybook-dist/assets/{RBBattery.stories-0id4XYoc.js → RBBattery.stories-BKVOskqs.js} +1 -1
- package/cli/storybook-dist/assets/{RBCard.stories-CgpG-7Lk.js → RBCard.stories-BTOfK0HC.js} +1 -1
- package/cli/storybook-dist/assets/{RBCheckBox.stories-BG02BoGX.js → RBCheckBox.stories-DyE912wG.js} +1 -1
- package/cli/storybook-dist/assets/{RBCheckIconButton.stories-vrjD4GQ4.js → RBCheckIconButton.stories-0sLFU0kM.js} +1 -1
- package/cli/storybook-dist/assets/{RBCheckLabelButton.stories-POba8lU-.js → RBCheckLabelButton.stories-c8VecClE.js} +1 -1
- package/cli/storybook-dist/assets/{RBChipTab.stories-XWJbFrHF.js → RBChipTab.stories-CQ9Uav5_.js} +1 -1
- package/cli/storybook-dist/assets/{RBChipsBoxTab.stories-BdU0Dzdp.js → RBChipsBoxTab.stories-Ap4NlhAZ.js} +1 -1
- package/cli/storybook-dist/assets/{RBColorIcons.stories-C_0dCNXG.js → RBColorIcons.stories-BPPXjif2.js} +1 -1
- package/cli/storybook-dist/assets/{RBCommand.stories-Dvrko5No.js → RBCommand.stories-PDpYOKlm.js} +1 -1
- package/cli/storybook-dist/assets/{RBConfirm.stories-CkTn0sHa.js → RBConfirm.stories-BEWvMAYP.js} +1 -1
- package/cli/storybook-dist/assets/{RBDateInput.stories-BH3D08cs.js → RBDateInput.stories-DLdyNnVF.js} +1 -1
- package/cli/storybook-dist/assets/{RBDialog.stories-C2nNX9fl.js → RBDialog.stories-HsawMNOb.js} +1 -1
- package/cli/storybook-dist/assets/{RBDivider.stories-D-GrJ0QZ.js → RBDivider.stories-dlWwOxik.js} +1 -1
- package/cli/storybook-dist/assets/{RBDrawer.stories-XLOnLOnh.js → RBDrawer.stories-C87iqGk0.js} +1 -1
- package/cli/storybook-dist/assets/{RBDropdownMenu.stories-BjBSeoV2.js → RBDropdownMenu.stories-B8EQhA0z.js} +1 -1
- package/cli/storybook-dist/assets/{RBEmptyView.stories-C6U2zKNE.js → RBEmptyView.stories-SzpIhNDw.js} +1 -1
- package/cli/storybook-dist/assets/{RBIconButton.stories-MCJq5Hta.js → RBIconButton.stories-BQ0nM1gb.js} +1 -1
- package/cli/storybook-dist/assets/{RBIcons.stories-DxOCiCe0.js → RBIcons.stories-C19g5M5z.js} +1 -1
- package/cli/storybook-dist/assets/{RBInlineLabelButton.stories-BVZi1aEs.js → RBInlineLabelButton.stories-DbgI4-1P.js} +1 -1
- package/cli/storybook-dist/assets/{RBJoyStick.stories-BDThbEek.js → RBJoyStick.stories-smF-vpGL.js} +1 -1
- package/cli/storybook-dist/assets/{RBLabelButton.stories-CaPEKUS6.js → RBLabelButton.stories-s53klwDv.js} +1 -1
- package/cli/storybook-dist/assets/{RBLight.stories-CihQzyEL.js → RBLight.stories-BnzkoR51.js} +1 -1
- package/cli/storybook-dist/assets/{RBMenubar.stories-J852XVFh.js → RBMenubar.stories-CW014klu.js} +1 -1
- package/cli/storybook-dist/assets/{RBMultiRangeInput.stories-DXyX0cOZ.js → RBMultiRangeInput.stories-qa4tH_4Y.js} +1 -1
- package/cli/storybook-dist/assets/{RBPagination.stories-C9_49ymO.js → RBPagination.stories-YoQNUpQH.js} +1 -1
- package/cli/storybook-dist/assets/{RBPopover.stories-DxLE912-.js → RBPopover.stories-Bhpi0OaH.js} +1 -1
- package/cli/storybook-dist/assets/{RBPrompt.stories-DlsfGqWb.js → RBPrompt.stories-iQM5iAka.js} +1 -1
- package/cli/storybook-dist/assets/{RBRadioBox.stories-hb5cNWW1.js → RBRadioBox.stories-BGyq4rzx.js} +1 -1
- package/cli/storybook-dist/assets/{RBRadioButton.stories-CSuOgeN-.js → RBRadioButton.stories-DF6BcUcJ.js} +1 -1
- package/cli/storybook-dist/assets/RBRangeDateInput.stories-COOQ_HNF.js +15 -0
- package/cli/storybook-dist/assets/{RBResizable.stories-BetjF9vG.js → RBResizable.stories-DSVHvwjp.js} +1 -1
- package/cli/storybook-dist/assets/RBSelectBox.stories-BookV8Sm.js +74 -0
- package/cli/storybook-dist/assets/{RBSheet.stories-D3QbcMKq.js → RBSheet.stories-CAK9hb-p.js} +1 -1
- package/cli/storybook-dist/assets/{RBSideNumberInput.stories-DcTh0IjM.js → RBSideNumberInput.stories-DUymnH-9.js} +1 -1
- package/cli/storybook-dist/assets/{RBSlider.stories-B59We7ei.js → RBSlider.stories-BwlDh9--.js} +1 -1
- package/cli/storybook-dist/assets/{RBSpinner.stories-9u3_ETbi.js → RBSpinner.stories-VOlkuP6t.js} +1 -1
- package/cli/storybook-dist/assets/{RBSwitch.stories-DrVm1WGX.js → RBSwitch.stories-BiW2dQnT.js} +1 -1
- package/cli/storybook-dist/assets/{RBTab.stories-D8Dm7cRW.js → RBTab.stories-DFKVsy0C.js} +1 -1
- package/cli/storybook-dist/assets/{RBTable.stories-dRdZ8g1j.js → RBTable.stories-BWG0ZSYI.js} +1 -1
- package/cli/storybook-dist/assets/{RBTableRow.stories-rZeoGwHZ.js → RBTableRow.stories-JVTD83Mh.js} +1 -1
- package/cli/storybook-dist/assets/{RBTabs.stories-STRV2lgO.js → RBTabs.stories-D-aN385u.js} +1 -1
- package/cli/storybook-dist/assets/{RBTextArea.stories-CfgUbrbJ.js → RBTextArea.stories-Dx5xkmZS.js} +1 -1
- package/cli/storybook-dist/assets/{RBTextField.stories-B5t8-Y97.js → RBTextField.stories-DmVciExd.js} +1 -1
- package/cli/storybook-dist/assets/{RBTextInput.stories-Bf1edN6l.js → RBTextInput.stories-CCf0VGEv.js} +1 -1
- package/cli/storybook-dist/assets/{RBToast.stories-Cnm8JwQ6.js → RBToast.stories-DT0DGn05.js} +1 -1
- package/cli/storybook-dist/assets/{RBToggle.stories-B0OCo6zd.js → RBToggle.stories-Ce_q3EtP.js} +1 -1
- package/cli/storybook-dist/assets/{RBToggleGroup.stories-GdYO2Fxg.js → RBToggleGroup.stories-CN40YHsR.js} +1 -1
- package/cli/storybook-dist/assets/{RBTooltip.stories-DXJTCScO.js → RBTooltip.stories-L7btDEpA.js} +1 -1
- package/cli/storybook-dist/assets/SideNumberInput-B5_xLK8z.js +1109 -0
- package/cli/storybook-dist/assets/browserUtils-CQM9Za7e.js +1 -0
- package/cli/storybook-dist/assets/{entry-preview-DSRwIaVJ.js → entry-preview-raBQdKjS.js} +1 -1
- package/cli/storybook-dist/assets/{iframe-Cj53Wn3d.js → iframe-BmIue__l.js} +2 -2
- package/cli/storybook-dist/assets/{index-CkHtLEe8.js → index-CSIe-YDr.js} +3 -3
- package/cli/storybook-dist/assets/{index-C1OzuK6-.js → index-ClAc6FBW.js} +1 -1
- package/cli/storybook-dist/assets/{preview-CILRoSnB.js → preview-BVJwlGUF.js} +1 -1
- package/cli/storybook-dist/assets/{preview-D_2um8Sd.js → preview-CUgthyJT.js} +1 -1
- package/cli/storybook-dist/assets/{preview-CgOtgtCD.js → preview-DD51RTKv.js} +2 -2
- package/cli/storybook-dist/assets/preview-zuAvMksL.css +1 -0
- package/cli/storybook-dist/assets/react-resizable-panels.browser-CYcsrUgg.js +120 -0
- package/cli/storybook-dist/iframe.html +1 -1
- package/cli/storybook-dist/index.json +1 -1
- package/cli/storybook-dist/project.json +1 -1
- package/cli/templates/common/README.md +211 -7
- package/cli/templates/common/py/main.py +29 -2
- package/cli/templates/cpp/cpp/CMakeLists.txt +6 -7
- package/cli/templates/cpp/cpp/main.cpp +57 -10
- package/cli/templates/readme-cpp/README.md +993 -0
- package/cli/templates/ros2-cpp/ros2_ws/src/__PLUGIN_SLUG__/CMakeLists.txt +4 -0
- package/cli/templates/ros2-cpp/ros2_ws/src/__PLUGIN_SLUG__/src/node.cpp +5 -4
- package/package.json +3 -3
- package/cli/storybook-dist/assets/RBRangeDateInput.stories-KRg2glX8.js +0 -15
- package/cli/storybook-dist/assets/RBSelectBox.stories-ILLQQ8M8.js +0 -6
- package/cli/storybook-dist/assets/SideNumberInput-CrF-a1Nr.js +0 -1114
- package/cli/storybook-dist/assets/browserUtils-CxJ5gea3.js +0 -1
- package/cli/storybook-dist/assets/preview-CEfMaO1t.css +0 -1
- package/cli/storybook-dist/assets/react-resizable-panels.browser-CtORL3tG.js +0 -102
package/cli/src/devBack.mjs
CHANGED
|
@@ -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 {
|
|
13
|
-
import {
|
|
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
|
|
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
|
|
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
|
-
!
|
|
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
|
-
...
|
|
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, ...
|
|
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 (
|
|
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, ...
|
|
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
|
-
|
|
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 &&
|
|
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, ...
|
|
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 = () => {
|
package/cli/src/host_agent.py
CHANGED
|
@@ -1,10 +1,7 @@
|
|
|
1
|
-
"""Host-side
|
|
1
|
+
"""Host-side Zenoh byte-proxy for Docker Desktop containers (Mac/WSL remote dev).
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
)
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
124
|
-
|
|
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
|
-
#
|
|
133
|
-
|
|
134
|
-
|
|
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 =
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
print(f"[host-agent] zenoh peer
|
|
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,
|
|
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
|
-
|
|
218
|
+
session.close()
|
|
159
219
|
|
|
160
220
|
|
|
161
221
|
if __name__ == "__main__":
|
package/cli/src/manifest.mjs
CHANGED
|
@@ -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)
|