@rainbow-robotics/muscat-plugin-kit 1.3.8-dev.0 → 1.3.8-dev.2

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/README.md CHANGED
@@ -159,7 +159,7 @@ Move images to a machine with no internet:
159
159
 
160
160
  ```bash
161
161
  # on a machine with network
162
- docker save muscat-pk-python-dev muscat-pk-bridge | gzip > muscat-pk-images.tar.gz
162
+ docker save muscat-pk-python-dev muscat-pk-runtime | gzip > muscat-pk-images.tar.gz
163
163
 
164
164
  # on the offline machine
165
165
  gunzip -c muscat-pk-images.tar.gz | docker load
@@ -198,12 +198,12 @@ The runtime monitor is strict on purpose: an undeclared port outside the plugin
198
198
  range is invisible to both the monitor and the robot's conflict check, so it
199
199
  would only surface as a collision after deployment.
200
200
 
201
- `muscat-pk`'s own dev plumbing (the Socket.IO bridge on `10000`, the Zenoh dev
201
+ `muscat-pk`'s own dev plumbing (the plugin gateway on `10000`, the Zenoh dev
202
202
  peer port) is excluded from the check — it isn't the plugin's traffic.
203
203
 
204
- Declared ports are published to `127.0.0.1` on macOS and Windows so you can
205
- reach the backend from the host; native Linux uses host networking, where they
206
- already are.
204
+ Under `muscat-pk dev` declared ports are published to `127.0.0.1` from the local
205
+ Muscat container (the backend shares its network), so you can reach the backend
206
+ from the host.
207
207
 
208
208
  ## Variables (pyfm)
209
209
 
package/cli/README.md CHANGED
@@ -42,12 +42,12 @@ the backend**, whatever its number. Since a port outside `28600-28999` cannot be
42
42
  declared, it cannot be opened either — otherwise it would slip past both this
43
43
  check and the robot's install-time conflict check.
44
44
 
45
- The kit's own dev ports (Socket.IO bridge `10000`, the Zenoh dev peer port) are
45
+ The kit's own dev ports (plugin gateway `10000`, the Zenoh dev peer port) are
46
46
  excluded from that check.
47
47
 
48
- Declared ports are published to `127.0.0.1` so the host can reach the backend on
49
- macOS/Windows; native Linux dev containers use host networking and already have
50
- them. A declared port that is already busy on the host is skipped with a warning.
48
+ Under `muscat-pk dev` the backend shares the local Muscat container's network, so
49
+ declared ports are published to `127.0.0.1` from that container. A declared port
50
+ that is already busy on the host is skipped with a warning.
51
51
 
52
52
  Known robot/industrial/internal ports such as `502`, `4840`, `5170-5179`,
53
53
  `8000-8999`, `10000`, `2222`, `30004`, `34964`, `44818`, `50000-50005`,
@@ -81,7 +81,7 @@ function formatHelp() {
81
81
  commandRow("new [dir]", `Scaffold a new plugin project ${color.dim("(prompts: author, email, description, type, front-package-manager)")}`),
82
82
  commandRow("type change", `Switch backend type ${color.dim("(python | cpp | ros2-python | ros2-cpp)")}`),
83
83
  commandRow("build", `Build + lock deps + vendor wheels + zip ${color.dim(`(${option("--no-zip")}: skip zip, ${option("--no-lock")}: skip python lock/vendor, ${option("--arch")} amd64|arm64|both)`)}`),
84
- commandRow("dev", "Run Socket.IO bridge, dev:ui, and dev:back together"),
84
+ commandRow("dev", "Run a local Muscat runtime, dev:ui, and dev:back together"),
85
85
  commandRow("dev:ui", `Preview the plugin in a mini-Muscat ${color.dim("(preview-host)")}`),
86
86
  commandRow("dev:back", `Run the plugin back end ${color.dim("(python: watchfiles reload / ros2: Docker)")}`),
87
87
  commandRow("storybook", `Browse the bundled rb-components Storybook ${color.dim(`(${option("--port")} <n>, ${option("--rebuild")})`)}`),
@@ -19,8 +19,6 @@ function walk(baseDir, prefix) {
19
19
  }
20
20
  walk(resolve(root, "templates") + "/", "");
21
21
  walk(resolve(root, "docker") + "/", "docker/");
22
- // Dockerfile.bridge 의 COPY 대상
23
- files["src/socketBridge.mjs"] = readFileSync(resolve(root, "src/socketBridge.mjs"), "base64");
24
22
 
25
23
  const { version } = JSON.parse(readFileSync(resolve(root, "../package.json"), "utf8"));
26
24
 
@@ -10,35 +10,18 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
10
10
  cmake \
11
11
  && rm -rf /var/lib/apt/lists/*
12
12
 
13
- # Rainbow Robotics C++ SDK for find_package(rb_sdk). Pin the runtime package too: apt would
14
- # otherwise pick the newest rainbow-rb-sdk, which -dev (= same version) rejects.
15
- # Override with MUSCAT_PK_RB_SDK_VERSION=<ver> (+ MUSCAT_PK_REBUILD_IMAGE=1).
16
- ARG RB_SDK_VERSION=0.10.2
13
+ # Rainbow Robotics C++ SDK for find_package(rb_sdk). muscat-pk passes the version pinned in the
14
+ # project's CMakeLists.txt (find_package(rb_sdk <ver> EXACT)); without a pin (ROS 2 rclpy, `muscat-pk mcp`
15
+ # outside a project) the newest SDK is installed. Runtime and -dev are installed together at one version.
16
+ ARG RB_SDK_VERSION=
17
17
  RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl \
18
18
  && install -d /etc/apt/keyrings \
19
19
  && curl -fsSL https://rainbowrobotics.github.io/rb-sdk/rb-sdk-apt-repo.gpg.key -o /etc/apt/keyrings/rb-sdk.asc \
20
20
  && printf 'deb [signed-by=/etc/apt/keyrings/rb-sdk.asc] https://rainbowrobotics.github.io/rb-sdk %s main\n' stable dev > /etc/apt/sources.list.d/rb-sdk.list \
21
- && apt-get update && apt-get install -y --no-install-recommends "rainbow-rb-sdk=${RB_SDK_VERSION}" "rainbow-rb-sdk-dev=${RB_SDK_VERSION}" \
21
+ && apt-get update \
22
+ && apt-get install -y --no-install-recommends "rainbow-rb-sdk${RB_SDK_VERSION:+=$RB_SDK_VERSION}" "rainbow-rb-sdk-dev${RB_SDK_VERSION:+=$RB_SDK_VERSION}" \
22
23
  && rm -rf /var/lib/apt/lists/*
23
24
 
24
- # MediaMTX — same version/ports as the Muscat runtime (rby2 scripts/backend/runtime/mediamtx.yml),
25
- # plus webrtcAdditionalHosts so a browser on the dev host reaches the WebRTC media port.
26
- ARG MEDIAMTX_VERSION=1.12.3
27
- RUN set -eux; \
28
- apt-get update; apt-get install -y --no-install-recommends ca-certificates curl; rm -rf /var/lib/apt/lists/*; \
29
- case "$(uname -m)" in x86_64) a=amd64 ;; aarch64|arm64) a=arm64 ;; *) exit 1 ;; esac; \
30
- curl -fsSL "https://github.com/bluenviron/mediamtx/releases/download/v${MEDIAMTX_VERSION}/mediamtx_v${MEDIAMTX_VERSION}_linux_${a}.tar.gz" \
31
- | tar -xz -C /usr/local/bin mediamtx; \
32
- mkdir -p /etc/mediamtx; \
33
- printf '%s\n' \
34
- 'logLevel: warn' \
35
- 'rtsp: yes' 'rtspAddress: :8554' \
36
- 'webrtc: yes' 'webrtcAddress: :8889' \
37
- 'webrtcLocalUDPAddress: :8189' 'webrtcLocalTCPAddress: :8189' \
38
- 'webrtcAdditionalHosts: [127.0.0.1]' 'webrtcAllowOrigin: "*"' \
39
- 'hls: no' 'rtmp: no' 'srt: no' 'api: no' \
40
- 'paths:' ' all_others:' ' source: publisher' \
41
- > /etc/mediamtx/mediamtx.yml
42
25
 
43
26
  WORKDIR /plugin
44
27
 
@@ -9,34 +9,18 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
9
9
  python3-colcon-common-extensions \
10
10
  && rm -rf /var/lib/apt/lists/*
11
11
 
12
- # Rainbow Robotics C++ SDK for find_package(rb_sdk).
13
- # Override with MUSCAT_PK_RB_SDK_VERSION=<ver> (+ MUSCAT_PK_REBUILD_IMAGE=1).
14
- ARG RB_SDK_VERSION=0.10.2
12
+ # Rainbow Robotics C++ SDK for find_package(rb_sdk). muscat-pk passes the version pinned in the
13
+ # project's CMakeLists.txt (find_package(rb_sdk <ver> EXACT)); without a pin (ROS 2 rclpy, `muscat-pk mcp`
14
+ # outside a project) the newest SDK is installed. Runtime and -dev are installed together at one version.
15
+ ARG RB_SDK_VERSION=
15
16
  RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl \
16
17
  && install -d /etc/apt/keyrings \
17
18
  && curl -fsSL https://rainbowrobotics.github.io/rb-sdk/rb-sdk-apt-repo.gpg.key -o /etc/apt/keyrings/rb-sdk.asc \
18
19
  && printf 'deb [signed-by=/etc/apt/keyrings/rb-sdk.asc] https://rainbowrobotics.github.io/rb-sdk %s main\n' stable dev > /etc/apt/sources.list.d/rb-sdk.list \
19
- && apt-get update && apt-get install -y --no-install-recommends "rainbow-rb-sdk=${RB_SDK_VERSION}" "rainbow-rb-sdk-dev=${RB_SDK_VERSION}" \
20
+ && apt-get update \
21
+ && apt-get install -y --no-install-recommends "rainbow-rb-sdk${RB_SDK_VERSION:+=$RB_SDK_VERSION}" "rainbow-rb-sdk-dev${RB_SDK_VERSION:+=$RB_SDK_VERSION}" \
20
22
  && rm -rf /var/lib/apt/lists/*
21
23
 
22
- # MediaMTX — same version/ports as the Muscat runtime (rby2 scripts/backend/runtime/mediamtx.yml),
23
- # plus webrtcAdditionalHosts so a browser on the dev host reaches the WebRTC media port.
24
- ARG MEDIAMTX_VERSION=1.12.3
25
- RUN set -eux; \
26
- apt-get update; apt-get install -y --no-install-recommends ca-certificates curl; rm -rf /var/lib/apt/lists/*; \
27
- case "$(uname -m)" in x86_64) a=amd64 ;; aarch64|arm64) a=arm64 ;; *) exit 1 ;; esac; \
28
- curl -fsSL "https://github.com/bluenviron/mediamtx/releases/download/v${MEDIAMTX_VERSION}/mediamtx_v${MEDIAMTX_VERSION}_linux_${a}.tar.gz" \
29
- | tar -xz -C /usr/local/bin mediamtx; \
30
- mkdir -p /etc/mediamtx; \
31
- printf '%s\n' \
32
- 'logLevel: warn' \
33
- 'rtsp: yes' 'rtspAddress: :8554' \
34
- 'webrtc: yes' 'webrtcAddress: :8889' \
35
- 'webrtcLocalUDPAddress: :8189' 'webrtcLocalTCPAddress: :8189' \
36
- 'webrtcAdditionalHosts: [127.0.0.1]' 'webrtcAllowOrigin: "*"' \
37
- 'hls: no' 'rtmp: no' 'srt: no' 'api: no' \
38
- 'paths:' ' all_others:' ' source: publisher' \
39
- > /etc/mediamtx/mediamtx.yml
40
24
 
41
25
  # `docker exec` skips /ros_entrypoint.sh — source ROS for the login shells dev:back runs.
42
26
  RUN echo ". /opt/ros/${ROS_DISTRO}/setup.sh" > /etc/profile.d/ros.sh
@@ -15,24 +15,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
15
15
  # cpp SDK shared lib must be present for the python module to load. Install the
16
16
  # rainbow cpp SDK (apt, pinned to the robot's version) here once it exists.
17
17
 
18
- # MediaMTX — same version/ports as the Muscat runtime (rby2 scripts/backend/runtime/mediamtx.yml),
19
- # plus webrtcAdditionalHosts so a browser on the dev host reaches the WebRTC media port.
20
- ARG MEDIAMTX_VERSION=1.12.3
21
- RUN set -eux; \
22
- apt-get update; apt-get install -y --no-install-recommends ca-certificates curl; rm -rf /var/lib/apt/lists/*; \
23
- case "$(uname -m)" in x86_64) a=amd64 ;; aarch64|arm64) a=arm64 ;; *) exit 1 ;; esac; \
24
- curl -fsSL "https://github.com/bluenviron/mediamtx/releases/download/v${MEDIAMTX_VERSION}/mediamtx_v${MEDIAMTX_VERSION}_linux_${a}.tar.gz" \
25
- | tar -xz -C /usr/local/bin mediamtx; \
26
- mkdir -p /etc/mediamtx; \
27
- printf '%s\n' \
28
- 'logLevel: warn' \
29
- 'rtsp: yes' 'rtspAddress: :8554' \
30
- 'webrtc: yes' 'webrtcAddress: :8889' \
31
- 'webrtcLocalUDPAddress: :8189' 'webrtcLocalTCPAddress: :8189' \
32
- 'webrtcAdditionalHosts: [127.0.0.1]' 'webrtcAllowOrigin: "*"' \
33
- 'hls: no' 'rtmp: no' 'srt: no' 'api: no' \
34
- 'paths:' ' all_others:' ' source: publisher' \
35
- > /etc/mediamtx/mediamtx.yml
36
18
 
37
19
  WORKDIR /plugin
38
20
 
package/cli/src/cpp.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  // Used by dev:back (interactive dev loop) and build (export the binary into the ZIP).
4
4
  import { spawnSync } from "node:child_process";
5
5
  import { dirname } from "node:path";
6
- import { canSkipImageBuild, cppSdkVersion, pluginRootOrNull, imageKey, imageKeyLabel, dockerUserFlags, hasBin, log, pruneMuskatImages, resolveDockerfile } from "./util.mjs";
6
+ import { canSkipImageBuild, cppSdkVersion, pluginRootOrNull, imageKey, imageKeyLabel, dockerUserFlags, hasBin, log, pruneMuskatImages, resolveDockerfile, buildCacheFlags } from "./util.mjs";
7
7
  import { syncCppIde } from "./cppIde.mjs";
8
8
 
9
9
  export const CPP_DEV_IMAGE = "muscat-pk-cpp-dev";
@@ -35,7 +35,7 @@ async function buildImage(image, platform, root) {
35
35
  const buildArgs = [...(sdkVer ? ["--build-arg", `RB_SDK_VERSION=${sdkVer}`] : []), ...imageKeyLabel(key)];
36
36
  const r = spawnSync(
37
37
  "docker",
38
- ["build", ...platformFlags, ...buildArgs, "-t", image, "-f", dockerfile, dirname(dockerfile)],
38
+ ["build", ...buildCacheFlags(), ...platformFlags, ...buildArgs, "-t", image, "-f", dockerfile, dirname(dockerfile)],
39
39
  { stdio: "inherit" },
40
40
  );
41
41
  if (r.status !== 0) throw new Error("docker build failed.");
package/cli/src/dev.mjs CHANGED
@@ -1,4 +1,4 @@
1
- // muscat-pk dev — run the Socket.IO bridge (Docker), dev:ui, and dev:back together.
1
+ // muscat-pk dev — run the local Muscat runtime (gateway, MediaMTX, common), dev:ui, and dev:back together.
2
2
  import { spawn } from "node:child_process";
3
3
  import { existsSync } from "node:fs";
4
4
  import { connect as netConnect } from "node:net";
@@ -6,8 +6,7 @@ import { resolve } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { runDevUi } from "./devUi.mjs";
8
8
  import { runDevBack, stopDevBackContainers } from "./devBack.mjs";
9
- import { localMuscatContainer, startBridge, stopBridge, BRIDGE_CONTAINER, BRIDGE_PORT } from "./bridge.mjs";
10
- import { ensureLocalMuscat } from "./muscatLocal.mjs";
9
+ import { ensureLocalMuscat, GATEWAY_PORT, isPortInUse } from "./muscatLocal.mjs";
11
10
  import { assertManifestPorts, assertManifestVariables } from "./manifest.mjs";
12
11
  import { formatPortRanges } from "./ports.mjs";
13
12
  import {
@@ -19,7 +18,7 @@ import {
19
18
  promptRobotConnection,
20
19
  venvPython,
21
20
  } from "./util.mjs";
22
- import { ensureRemoteZenohEnv, isRemoteRobot, isWsl, usesHostNetwork } from "./zenohDev.mjs";
21
+ import { ensureRemoteZenohEnv, isRemoteRobot, isWsl } from "./zenohDev.mjs";
23
22
 
24
23
  const HOST_AGENT_PATH = fileURLToPath(new URL("./host_agent.py", import.meta.url));
25
24
  const canAutoStartHostAgent = () => process.platform === "darwin" || isWsl();
@@ -100,7 +99,7 @@ async function maybeStartHostAgent(root, { robotIp, robotToken }) {
100
99
  }
101
100
 
102
101
  // advertiseHost (not host.docker.internal): a Docker Desktop container run
103
- // under `dev` may share the bridge container's netns (--network container:),
102
+ // under `dev` shares the local Muscat container's netns (--network container:),
104
103
  // which has no host-gateway alias configured — the host's real LAN IP
105
104
  // always resolves regardless of netns mode.
106
105
  const proxyEndpoint = `${advertiseHost}:${proxyPort}`;
@@ -124,41 +123,29 @@ export async function runDev(args = []) {
124
123
  if (robotToken) process.env.MUSCAT_ROBOT_TOKEN = robotToken;
125
124
 
126
125
  const remoteRobot = isRemoteRobot(robotIp);
127
- // No robot: a local Muscat (common) owns :10000, so the bridge below is skipped and
128
- // dev:back joins the Muscat netns instead — same as against a `muscat-dev` container.
129
- const localMuscat = remoteRobot
130
- ? null
131
- : await ensureLocalMuscat().catch((e) => { log.warn(`local Muscat: ${e.message}`); return null; });
132
126
  const hostAgent = await maybeStartHostAgent(root, { robotIp, robotToken });
133
127
  const proxyEndpoint = process.env.MUSCAT_PROXY_ENDPOINT || "";
134
128
  // No proxy (Linux, disabled, ...): fall back to the container reverse-dialing
135
- // the robot itself — startBridge() then publishes the zenoh port for it.
129
+ // the robot itself — the local Muscat then publishes the zenoh port for it.
136
130
  if (!proxyEndpoint) await ensureRemoteZenohEnv(robotIp, { log });
137
131
 
138
- log.step("starting Socket.IO bridge (Docker) + dev:ui + dev:back");
139
- // A local Muscat already serves :10000 (gateway) and MediaMTX — no bridge needed.
140
- const bridgeStarted = localMuscat ? false : await startBridge({
141
- zenohPort: remoteRobot && !proxyEndpoint && !usesHostNetwork()
142
- ? Number(process.env.MUSCAT_ZENOH_PORT)
143
- : null,
144
- // Linux uses --network host, so the ports are already on the host.
145
- pluginPorts: usesHostNetwork() ? [] : portRanges,
146
- });
147
-
148
- const muscat = bridgeStarted ? null : (localMuscat ?? localMuscatContainer());
132
+ // Robot or not, the local Muscat runtime is the gateway (:10000), MediaMTX and common; the
133
+ // backend joins its netns, so the plugin's declared ports are published from it too.
134
+ log.step("starting local Muscat (Docker) + dev:ui + dev:back");
135
+ const muscat = await ensureLocalMuscat({
136
+ pluginPorts: portRanges,
137
+ zenohPort: remoteRobot && !proxyEndpoint ? Number(process.env.MUSCAT_ZENOH_PORT) || null : null,
138
+ }).catch((e) => { log.warn(`local Muscat: ${e.message}`); return null; });
149
139
  if (muscat) {
150
- log.step(`local Muscat container '${muscat}' owns :${BRIDGE_PORT} — backend joins its network (gateway, MediaMTX)`);
140
+ log.step(`local Muscat container '${muscat}' owns :${GATEWAY_PORT} — backend joins its network (gateway, MediaMTX)`);
151
141
  process.env.MUSCAT_PK_MUSCAT_CONTAINER = muscat;
152
- } else if (!bridgeStarted) {
153
- log.warn(`port ${BRIDGE_PORT} is already in use — can't start Socket.IO bridge.`);
154
- const answer = await prompt(" Proceed without Socket.IO bridge? [y/N]: ");
155
- if (answer.toLowerCase() !== "y") return;
156
- log.info("proceeding without bridge — backend will skip network sharing.");
142
+ } else if (await isPortInUse(GATEWAY_PORT)) {
143
+ // e.g. developing on the robot PC: its Muscat already serves the gateway; the backend reaches it via the host.
144
+ log.step(`:${GATEWAY_PORT} is already served (Muscat on this machine) — using it`);
157
145
  } else {
158
- // Tell dev:back which bridge container to share a netns with, so the backend
159
- // reaches the bridge at localhost:10000 (no host.docker.internal).
160
- process.env.MUSCAT_PK_BRIDGE_CONTAINER = BRIDGE_CONTAINER;
161
- if (bridgeStarted.media) process.env.MUSCAT_PK_MEDIA = "1";
146
+ log.warn(`no local Muscat on :${GATEWAY_PORT} — the plugin UI cannot reach the backend.`);
147
+ const answer = await prompt(" Proceed anyway? [y/N]: ");
148
+ if (answer.toLowerCase() !== "y") return;
162
149
  }
163
150
 
164
151
  process.env.MUSCAT_PK_MANAGED_DEV = "1";
@@ -166,7 +153,6 @@ export async function runDev(args = []) {
166
153
  const cleanup = () => {
167
154
  if (hostAgent && hostAgent.exitCode === null) hostAgent.kill();
168
155
  stopDevBackContainers();
169
- if (bridgeStarted) stopBridge();
170
156
  };
171
157
  process.once("SIGINT", cleanup);
172
158
  process.once("SIGTERM", cleanup);
@@ -2,7 +2,7 @@
2
2
  // Every backend runs in a warm Docker container that matches the robot
3
3
  // (Ubuntu 24.04), so dev behaves like production.
4
4
  // python : Python 3.12 container, pip install, run main.py with watchfiles
5
- // hot-reload. Shares the bridge container's netns (localhost:10000).
5
+ // hot-reload. Shares the local Muscat container's netns (localhost:10000).
6
6
  // cpp : Ubuntu + cmake container, build cpp/, run the binary, rebuild +
7
7
  // restart on source changes.
8
8
  // ros2 : Jazzy + Python 3.12 container, colcon build, run the node, restart
@@ -13,7 +13,7 @@ import { isIP } from "node:net";
13
13
  import { dirname, resolve } from "node:path";
14
14
  import { dockerUserFlags, ensureDocker, ensureIdeVenv, findPluginRoot, log, promptRobotConnection, realtimeFlags } from "./util.mjs";
15
15
  import { ensureRemoteZenohEnv, isRemoteRobot, usesHostNetwork } from "./zenohDev.mjs";
16
- import { localMuscatContainer } from "./bridge.mjs";
16
+ import { localMuscatContainer } from "./muscatLocal.mjs";
17
17
  import { PLUGIN_ID_RE, assertManifestPorts, assertManifestVariables, manifestRuntime, readManifest, rosNamespace, validateManifest } from "./manifest.mjs";
18
18
  import {
19
19
  formatPortRanges,
@@ -54,9 +54,9 @@ export function stopDevBackContainers() {
54
54
  }
55
55
  }
56
56
 
57
- // Where the backend's 127.0.0.1 points. A local Muscat container (muscat-dev): join its
58
- // netns so the gateway and MediaMTX resolve like on the robot. Under `dev`: the bridge's
59
- // netns. Standalone dev:back: its own netns, gateway via the host.
57
+ // Where the backend's 127.0.0.1 points. A local Muscat container (muscat-pk-muscat, muscat-dev):
58
+ // join its netns so the gateway and MediaMTX resolve like on the robot. Otherwise its own
59
+ // netns, gateway via the host.
60
60
  // IS_DEV Muscat (muscat-dev): common is a zenoh-router client and opens no 7447 listener,
61
61
  // so the plugin must join the router too or `execute` queries never reach it.
62
62
  function muscatZenohEnv(container) {
@@ -70,8 +70,6 @@ function backendNetwork(portRanges) {
70
70
  const muscat = process.env.MUSCAT_PK_MUSCAT_CONTAINER;
71
71
  if (muscat) return { run: ["--network", `container:${muscat}`], env: muscatZenohEnv(muscat), shared: true };
72
72
  if (usesHostNetwork()) return { run: ["--network", "host"], env: [], shared: true };
73
- const bridge = process.env.MUSCAT_PK_BRIDGE_CONTAINER;
74
- if (bridge) return { run: ["--network", `container:${bridge}`], env: [], shared: true };
75
73
  return {
76
74
  // host.docker.internal is automatic on Docker Desktop; this makes it resolve on Linux too.
77
75
  run: [...publishPortFlags(portRanges), "--add-host", "host.docker.internal:host-gateway"],
@@ -117,13 +115,6 @@ async function serveRosPeers(root, pluginId, restart) {
117
115
  });
118
116
  }
119
117
 
120
- // Our own MediaMTX, only when `dev` found its ports free (a local Muscat brings its own).
121
- function startMediaServer(container) {
122
- if (process.env.MUSCAT_PK_MEDIA !== "1") return;
123
- spawnSync("docker", ["exec", "-d", container, "mediamtx", "/etc/mediamtx/mediamtx.yml"], { stdio: "ignore" });
124
- log.step("MediaMTX: publish rtsp://127.0.0.1:8554/<path> · play http://localhost:8889/<path>");
125
- }
126
-
127
118
  // ── IDE tooling venv (best-effort, non-blocking) ────────────────────────────
128
119
  // `muscat-pk new` already creates this up front (see new.mjs); this just
129
120
  // refreshes it on every `dev`/`dev:back` in case requirements.txt changed.
@@ -144,8 +135,8 @@ function syncIdeVenv(root) {
144
135
 
145
136
  // ── python ────────────────────────────────────────────────────────────────────
146
137
  // Runs in a warm Ubuntu 24.04 + Python 3.12 container (matches the robot).
147
- // Under `muscat-pk dev`, it shares the bridge container's netns, so the SDK
148
- // reaches the Socket.IO bridge at the default localhost:10000.
138
+ // Under `muscat-pk dev`, it shares the local Muscat container's netns, so the SDK
139
+ // reaches the plugin gateway at the default localhost:10000.
149
140
  async function runPython(root, portRanges) {
150
141
  // Independent of Docker readiness — must still happen if Docker isn't
151
142
  // installed/running, since that's exactly when local IDE tooling matters most.
@@ -164,7 +155,7 @@ async function runPython(root, portRanges) {
164
155
  // Zenoh mesh, so it speaks a byte protocol to the host-side proxy instead.
165
156
  const proxyEndpoint = process.env.MUSCAT_PROXY_ENDPOINT ?? "";
166
157
  // Native Linux (not WSL, not Docker Desktop) has real host networking: the
167
- // container shares the host's network stack, so it reaches the bridge at
158
+ // container shares the host's network stack, so it reaches the gateway at
168
159
  // localhost:10000 and the robot's Zenoh router / reverse-dial path directly
169
160
  // — no netns sharing, port publishing, host.docker.internal, or byte-proxy.
170
161
  const net = backendNetwork(portRanges);
@@ -205,7 +196,6 @@ async function runPython(root, portRanges) {
205
196
  { stdio: "inherit" },
206
197
  );
207
198
  if (up.status !== 0) throw new Error("failed to start dev container.");
208
- startMediaServer(container);
209
199
  const stopPortMonitor = startPortContractMonitor({ container, ranges: portRanges, log, allow: [zenohPort] });
210
200
 
211
201
  log.step("pip install requirements + watchfiles (in container)…");
@@ -423,7 +413,7 @@ runpy.run_path("/plugin/py/main.py", run_name="__main__")
423
413
  PY`]);
424
414
 
425
415
  if (pluginId) log.step(`RB_PLUGIN_ID=${pluginId}`);
426
- if (usesHostNetwork()) log.step("network: host (native Linux — direct robot/bridge access)");
416
+ if (usesHostNetwork()) log.step("network: host (native Linux — direct robot/gateway access)");
427
417
  if (proxyEndpoint) log.step(`Zenoh via host byte-proxy → ${proxyEndpoint}`);
428
418
  else if (zenohAdvertiseHost && zenohPort) log.step(`Zenoh advertise → ${zenohAdvertiseHost}:${zenohPort}`);
429
419
  log.step("watchfiles → py/main.py (reload on any .py change in py/)");
@@ -503,7 +493,6 @@ async function runRos2(root, portRanges) {
503
493
  { stdio: "inherit" },
504
494
  );
505
495
  if (up.status !== 0) throw new Error("failed to start dev container.");
506
- startMediaServer(container);
507
496
  const stopPortMonitor = startPortContractMonitor({ container, ranges: portRanges, log, allow: [process.env.MUSCAT_ZENOH_PORT] });
508
497
 
509
498
  // one-time deps + symlink build (edits to .py reflect without rebuilding)
@@ -596,7 +585,6 @@ async function runCpp(root, portRanges) {
596
585
  { stdio: "inherit" },
597
586
  );
598
587
  if (up.status !== 0) throw new Error("failed to start dev container.");
599
- startMediaServer(container);
600
588
  const stopPortMonitor = startPortContractMonitor({ container, ranges: portRanges, log, allow: [process.env.MUSCAT_ZENOH_PORT] });
601
589
 
602
590
  const build = () => {
@@ -1,22 +1,25 @@
1
- // Local Muscat back end for `dev` / `dev:ui` without a robot.
2
- // preview-host hits http://localhost:3000/common/* (baked into the nexus bundle), so a
3
- // Muscat on :3000 makes program preview work with no front-end change. We reuse any
4
- // Muscat already publishing :10000 (e.g. a `muscat` / `muscat-dev` container) and only
5
- // otherwise start our own: the S3 runtime image with just the `common` service.
1
+ // Local Muscat runtime for `dev` / `dev:ui`, with or without a robot. It is the plugin gateway
2
+ // (:10000, plugin-socketio — same Socket.IO protocol as on the robot), MediaMTX, and `common`:
3
+ // preview-host hits http://localhost:3000/common/* (baked into the nexus bundle), so program
4
+ // preview works with no front-end change. The backend container joins its netns. We reuse any
5
+ // Muscat already publishing :10000 (e.g. a `muscat` / `muscat-dev` container) and only otherwise
6
+ // start our own: the S3 runtime image with just the `common` service.
6
7
  import { spawnSync } from "node:child_process";
8
+ import { createServer } from "node:net";
7
9
  import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
8
10
  import { homedir } from "node:os";
9
11
  import { resolve } from "node:path";
10
- import { isPortInUse, localMuscatContainer } from "./bridge.mjs";
11
- import { mediaPortFlags, MEDIA_PORTS } from "./ports.mjs";
12
- import { canSkipImageBuild, hasBin, log } from "./util.mjs";
12
+ import { mediaPortFlags, MEDIA_PORTS, publishPortFlags } from "./ports.mjs";
13
+ import { canSkipImageBuild, hasBin, log, buildCacheFlags } from "./util.mjs";
13
14
 
14
15
  export const MUSCAT_CONTAINER = "muscat-pk-muscat";
16
+ // Ports published for the backend (it shares this container's netns); a different set recreates it.
17
+ const PORTS_LABEL = "com.rainbow-robotics.muscat-pk.ports";
15
18
  const IMAGE = "muscat-pk-runtime";
16
19
  const S3 = process.env.MUSCAT_PK_MUSCAT_S3 ?? "https://rainbow-deploy.s3.ap-northeast-2.amazonaws.com/muscat/main";
17
20
  const CACHE = resolve(homedir(), ".muscat-pk", "muscat");
18
21
  const API_PORT = 3000;
19
- const GATEWAY_PORT = 10000;
22
+ export const GATEWAY_PORT = 10000;
20
23
  // The bin runs inside Docker, so follow the Docker engine's arch, not this Node's
21
24
  // (Rosetta/x64 Node on an Apple Silicon host would otherwise pick amd64).
22
25
  const dockerArch = () => {
@@ -72,7 +75,7 @@ async function ensureRuntimeImage(total) {
72
75
  log.step(`docker build -t ${image} (first run downloads MongoDB/InfluxDB/ROS base — slow, then cached)…`);
73
76
  const r = spawnSync(
74
77
  "docker",
75
- ["build", "-t", image, "-f", resolve(dir, "Dockerfile.runtime"), "--label", "com.rainbow-robotics.muscat-pk=true", dir],
78
+ ["build", ...buildCacheFlags(), "-t", image, "-f", resolve(dir, "Dockerfile.runtime"), "--label", "com.rainbow-robotics.muscat-pk=true", dir],
76
79
  { stdio: "inherit" },
77
80
  );
78
81
  if (r.status !== 0) throw new Error("docker build failed (Muscat runtime image).");
@@ -106,16 +109,41 @@ async function waitForApi(timeoutMs = 180_000) {
106
109
  }
107
110
  }
108
111
 
112
+ // Is a host TCP port already bound?
113
+ export function isPortInUse(port) {
114
+ return new Promise((res) => {
115
+ const srv = createServer();
116
+ srv.once("error", (err) => res(err.code === "EADDRINUSE"));
117
+ srv.once("listening", () => srv.close(() => res(false)));
118
+ srv.listen(port, "0.0.0.0");
119
+ });
120
+ }
121
+
122
+ /** A running Muscat container (ours or e.g. muscat-dev) that owns host port 10000, or null. */
123
+ export function localMuscatContainer() {
124
+ const r = spawnSync("docker", ["ps", "--filter", `publish=${GATEWAY_PORT}`, "--format", "{{.Names}}"], { encoding: "utf8" });
125
+ return (r.stdout || "").split("\n").map((n) => n.trim()).find(Boolean) ?? null;
126
+ }
127
+
128
+ const containerLabel = (name, label) =>
129
+ spawnSync("docker", ["inspect", "-f", `{{index .Config.Labels "${label}"}}`, name], { encoding: "utf8" }).stdout?.trim() ?? "";
130
+
109
131
  /**
110
132
  * Make sure a Muscat answers on localhost:3000 / :10000. Returns the container name,
111
133
  * or null when one can't be started (no Docker, foreign process on the ports).
112
134
  * The container is left running across runs — stop it with `docker rm -f muscat-pk-muscat`.
113
135
  */
114
- export async function ensureLocalMuscat() {
136
+ export async function ensureLocalMuscat({ pluginPorts = [], zenohPort = null } = {}) {
115
137
  if (process.env.MUSCAT_PK_NO_LOCAL_MUSCAT === "1") return null;
138
+ const portsKey = [...pluginPorts, ...(zenohPort ? [`zenoh:${zenohPort}`] : [])].join(",");
116
139
  const existing = hasBin("docker") ? localMuscatContainer() : null;
117
- if (existing) {
140
+ if (existing === MUSCAT_CONTAINER && containerLabel(existing, PORTS_LABEL) !== portsKey) {
141
+ log.step(`recreating local Muscat to publish ${portsKey || "no extra"} port(s)`);
142
+ spawnSync("docker", ["rm", "-f", MUSCAT_CONTAINER], { stdio: "ignore" });
143
+ } else if (existing) {
118
144
  log.step(`local Muscat '${existing}' already on :${GATEWAY_PORT} — reusing it`);
145
+ if (existing !== MUSCAT_CONTAINER && portsKey)
146
+ log.warn(`'${existing}' is not managed by muscat-pk — port(s) ${portsKey} are not published from it.`);
119
147
  return existing;
120
148
  }
121
149
  if (!hasBin("docker")) {
@@ -139,11 +167,16 @@ export async function ensureLocalMuscat() {
139
167
 
140
168
  let media = true;
141
169
  for (const port of MEDIA_PORTS) if (await isPortInUse(port)) media = false;
170
+ // A busy plugin port would make `docker run` fail with a raw daemon error — skip it and say so.
171
+ const busy = [];
172
+ for (const port of pluginPorts) if (await isPortInUse(port)) busy.push(port);
173
+ if (busy.length) log.warn(`plugin port(s) already in use on the host: ${busy.join(", ")} — not published (the backend still listens inside).`);
174
+ const published = pluginPorts.filter((port) => !busy.includes(port));
142
175
 
143
176
  spawnSync("docker", ["rm", "-f", MUSCAT_CONTAINER], { stdio: "ignore" });
144
177
  log.step(`starting local Muscat (common ${commonVersion}) → http://localhost:${API_PORT}/common/docs`);
145
178
  const r = spawnSync("docker", [
146
- "run", "-d", "--name", MUSCAT_CONTAINER, "--label", "com.rainbow-robotics.muscat-pk=true",
179
+ "run", "-d", "--name", MUSCAT_CONTAINER, "--label", "com.rainbow-robotics.muscat-pk=true", "--label", `${PORTS_LABEL}=${portsKey}`,
147
180
  "-e", "RB_ENABLED_SERVICES=common",
148
181
  "-e", "RB_RUNTIME_ENABLE_ZENOH_BRIDGE=0",
149
182
  "-v", `${commonDir}:/app/services/common`,
@@ -151,10 +184,15 @@ export async function ensureLocalMuscat() {
151
184
  "-p", `127.0.0.1:${API_PORT}:${API_PORT}`,
152
185
  "-p", `127.0.0.1:${GATEWAY_PORT}:${GATEWAY_PORT}`,
153
186
  ...(media ? mediaPortFlags() : []),
187
+ ...publishPortFlags(published),
188
+ // The robot reverse-dials the backend's Zenoh session from the LAN, so not loopback-only.
189
+ ...(zenohPort ? ["-p", `${zenohPort}:${zenohPort}`] : []),
154
190
  image,
155
191
  ], { stdio: ["ignore", "ignore", "inherit"] });
156
192
  if (r.status !== 0) throw new Error("failed to start the local Muscat container.");
157
193
  await waitForApi();
158
194
  log.step(`local Muscat ready — keeps running between runs; stop with: docker rm -f ${MUSCAT_CONTAINER}`);
195
+ if (published.length) log.step(`plugin backend ports published on 127.0.0.1: ${published.join(", ")}`);
196
+ if (zenohPort) log.step(`Zenoh dev peer port published on :${zenohPort}`);
159
197
  return MUSCAT_CONTAINER;
160
198
  }
package/cli/src/ports.mjs CHANGED
@@ -16,7 +16,7 @@ export function mediaPortFlags() {
16
16
  export const RESERVED_BACKEND_PORTS = new Map([
17
17
  [502, "Modbus TCP"],
18
18
  [4840, "OPC-UA"],
19
- [10000, "muscat-pk Socket.IO bridge"],
19
+ [10000, "Muscat plugin gateway"],
20
20
  [2222, "EtherNet/IP Implicit"],
21
21
  [30004, "RTDE"],
22
22
  [34964, "ProfiNET"],
@@ -3,7 +3,7 @@
3
3
  import { spawnSync } from "node:child_process";
4
4
  import { existsSync, readFileSync } from "node:fs";
5
5
  import { dirname, resolve } from "node:path";
6
- import { canSkipImageBuild, imageKey, imageKeyLabel, dockerUserFlags, hasBin, log, pruneMuskatImages, resolveDockerfile } from "./util.mjs";
6
+ import { canSkipImageBuild, imageKey, imageKeyLabel, dockerUserFlags, hasBin, log, pruneMuskatImages, resolveDockerfile, buildCacheFlags } from "./util.mjs";
7
7
 
8
8
  export const PYTHON_DEV_IMAGE = "muscat-pk-python-dev";
9
9
 
@@ -25,7 +25,7 @@ export async function ensureImage(image = PYTHON_DEV_IMAGE, { platform = null }
25
25
  const platformFlags = platform ? ["--platform", platform] : [];
26
26
  const r = spawnSync(
27
27
  "docker",
28
- ["build", ...platformFlags, ...imageKeyLabel(key), "-t", image, "-f", dockerfile, dirname(dockerfile)],
28
+ ["build", ...buildCacheFlags(), ...platformFlags, ...imageKeyLabel(key), "-t", image, "-f", dockerfile, dirname(dockerfile)],
29
29
  { stdio: "inherit" },
30
30
  );
31
31
  if (r.status !== 0) throw new Error("docker build failed.");
package/cli/src/ros2.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  import { spawnSync } from "node:child_process";
4
4
  import { existsSync, readdirSync } from "node:fs";
5
5
  import { dirname, resolve } from "node:path";
6
- import { canSkipImageBuild, cppSdkVersion, pluginRootOrNull, imageKey, imageKeyLabel, dockerUserFlags, hasBin, log, pruneMuskatImages, resolveDockerfile } from "./util.mjs";
6
+ import { canSkipImageBuild, cppSdkVersion, pluginRootOrNull, imageKey, imageKeyLabel, dockerUserFlags, hasBin, log, pruneMuskatImages, resolveDockerfile, buildCacheFlags } from "./util.mjs";
7
7
  import { syncCppIde } from "./cppIde.mjs";
8
8
 
9
9
  export const ROS2_DEV_IMAGE = "muscat-pk-ros2-dev";
@@ -50,7 +50,7 @@ async function buildImage(image, platform, root) {
50
50
  const buildArgs = [...(sdkVer ? ["--build-arg", `RB_SDK_VERSION=${sdkVer}`] : []), ...imageKeyLabel(key)];
51
51
  const r = spawnSync(
52
52
  "docker",
53
- ["build", ...platformFlags, ...buildArgs, "-t", image, "-f", dockerfile, dirname(dockerfile)],
53
+ ["build", ...buildCacheFlags(), ...platformFlags, ...buildArgs, "-t", image, "-f", dockerfile, dirname(dockerfile)],
54
54
  { stdio: "inherit" },
55
55
  );
56
56
  if (r.status !== 0) throw new Error("docker build failed.");
package/cli/src/util.mjs CHANGED
@@ -269,12 +269,12 @@ export const log = {
269
269
  error: (...m) => console.error(color.red("✖"), ...m.map(s => color.red(s))),
270
270
  };
271
271
 
272
- // dev / dev:back / build run the plugin back end, the dev bridge, and cpp/ros2
272
+ // dev / dev:back / build run the plugin back end, the local Muscat runtime, and cpp/ros2
273
273
  // compilation in Docker. Show a clear install hint when Docker is missing.
274
274
  export function ensureDocker() {
275
275
  if (hasBin("docker")) return;
276
276
  log.warn("Docker not found.");
277
- log.warn(" muscat-pk runs the plugin back end, the dev bridge, and cpp/ros2 builds in Docker.");
277
+ log.warn(" muscat-pk runs the plugin back end, the local Muscat runtime, and cpp/ros2 builds in Docker.");
278
278
  log.warn(" Install Docker, then retry: https://docs.docker.com/get-docker/");
279
279
  throw new Error("Docker is required for dev / dev:back / build.");
280
280
  }
@@ -323,6 +323,10 @@ export function imageKey(dockerfile, extra = "") {
323
323
  return createHash("sha256").update(readFileSync(dockerfile)).update(extra).digest("hex").slice(0, 16);
324
324
  }
325
325
 
326
+ // MUSCAT_PK_NO_BUILD_CACHE=1 (CI): build images without Docker's layer cache, so an apt/PyPI/GitHub
327
+ // change that breaks a fresh build fails the test too instead of hiding behind a cached layer.
328
+ export const buildCacheFlags = () => (process.env.MUSCAT_PK_NO_BUILD_CACHE === "1" ? ["--no-cache"] : []);
329
+
326
330
  export function imageKeyLabel(key) {
327
331
  return ["--label", `${IMAGE_KEY_LABEL}=${key}`];
328
332
  }
@@ -1 +1 @@
1
- {"generatedAt":1792585817698,"userSince":1750932757926,"hasCustomBabel":false,"hasCustomWebpack":false,"hasStaticDirs":false,"hasStorybookEslint":true,"refCount":0,"testPackages":{"@vitest/browser":"3.2.4","@vitest/coverage-v8":"3.2.4","playwright":"1.59.1","vitest":"3.2.4"},"hasRouterPackage":false,"monorepo":"Turborepo","packageManager":{"type":"yarn","version":"berry","agent":"yarn@berry"},"preview":{"usesGlobals":false},"framework":{"name":"@storybook/react-vite","options":{}},"builder":"@storybook/builder-vite","renderer":"@storybook/react","portableStoriesFileCount":0,"applicationFileCount":0,"storybookVersion":"8.6.18","storybookVersionSpecifier":"^8.6.4","language":"typescript","storybookPackages":{"@chromatic-com/storybook":{"version":"3.2.7"},"@storybook/addon-essentials":{"version":"8.6.18"},"@storybook/addon-links":{"version":"8.6.18"},"@storybook/addon-onboarding":{"version":"8.6.18"},"@storybook/addon-themes":{"version":"8.6.18"},"@storybook/blocks":{"version":"8.6.18"},"@storybook/experimental-addon-test":{"version":"8.6.18"},"@storybook/react":{"version":"8.6.18"},"@storybook/react-vite":{"version":"8.6.18"},"@storybook/test":{"version":"8.6.18"},"@storybook/theming":{"version":"8.6.18"},"eslint-plugin-storybook":{"version":"0.11.6"},"storybook":{"version":"8.6.18"},"storybook-addon-react-docgen":{"version":"1.2.44"}},"addons":{"$SNIP/node_modules/@storybook/addon-links":{"version":null},"$SNIP/node_modules/@storybook/addon-essentials":{"version":null},"$SNIP/node_modules/@chromatic-com/storybook":{"version":null},"$SNIP/node_modules/@storybook/addon-themes":{"version":null},"$SNIP/node_modules/@storybook/experimental-addon-test":{"version":null}}}
1
+ {"generatedAt":1792588886374,"userSince":1750932757926,"hasCustomBabel":false,"hasCustomWebpack":false,"hasStaticDirs":false,"hasStorybookEslint":true,"refCount":0,"testPackages":{"@vitest/browser":"3.2.4","@vitest/coverage-v8":"3.2.4","playwright":"1.59.1","vitest":"3.2.4"},"hasRouterPackage":false,"monorepo":"Turborepo","packageManager":{"type":"yarn","version":"berry","agent":"yarn@berry"},"preview":{"usesGlobals":false},"framework":{"name":"@storybook/react-vite","options":{}},"builder":"@storybook/builder-vite","renderer":"@storybook/react","portableStoriesFileCount":0,"applicationFileCount":0,"storybookVersion":"8.6.18","storybookVersionSpecifier":"^8.6.4","language":"typescript","storybookPackages":{"@chromatic-com/storybook":{"version":"3.2.7"},"@storybook/addon-essentials":{"version":"8.6.18"},"@storybook/addon-links":{"version":"8.6.18"},"@storybook/addon-onboarding":{"version":"8.6.18"},"@storybook/addon-themes":{"version":"8.6.18"},"@storybook/blocks":{"version":"8.6.18"},"@storybook/experimental-addon-test":{"version":"8.6.18"},"@storybook/react":{"version":"8.6.18"},"@storybook/react-vite":{"version":"8.6.18"},"@storybook/test":{"version":"8.6.18"},"@storybook/theming":{"version":"8.6.18"},"eslint-plugin-storybook":{"version":"0.11.6"},"storybook":{"version":"8.6.18"},"storybook-addon-react-docgen":{"version":"1.2.44"}},"addons":{"$SNIP/node_modules/@storybook/addon-links":{"version":null},"$SNIP/node_modules/@storybook/addon-essentials":{"version":null},"$SNIP/node_modules/@chromatic-com/storybook":{"version":null},"$SNIP/node_modules/@storybook/addon-themes":{"version":null},"$SNIP/node_modules/@storybook/experimental-addon-test":{"version":null}}}
@@ -307,21 +307,10 @@ export default function App() {
307
307
 
308
308
  ## Developing directly on the robot PC
309
309
 
310
- If you're running `muscat-pk dev` **on the robot PC itself**, Muscat is likely already
311
- running its own Socket.IO bridge on port 10000. In that case the CLI detects the port
312
- conflict and asks:
313
-
314
- ```
315
- ⚠ port 10000 is already in use — can't start Socket.IO bridge.
316
- Proceed without Socket.IO bridge? [y/N]:
317
- ```
318
-
319
- Answer **y**. Your plugin backend will connect to the Muscat bridge that's already
320
- running instead of starting a new one — exactly what you want when developing on the
321
- robot.
322
-
323
- > If you answer **n** (or just press Enter), the CLI exits so you can free the port
324
- > manually and retry.
310
+ If you're running `muscat-pk dev` **on the robot PC itself**, Muscat already serves the
311
+ plugin gateway on port 10000. The CLI sees that, does not start its own local Muscat, and
312
+ your plugin backend connects to the robot's gateway — exactly what you want when
313
+ developing on the robot.
325
314
 
326
315
  ---
327
316
 
@@ -329,8 +318,7 @@ robot.
329
318
 
330
319
  `manifest.json` chooses one backend runtime. `muscat-pk dev` runs backend code in
331
320
  a Docker container matching the robot environment. Backend code should contain
332
- plugin logic only; it should not start the Muscat bridge or its own copy of the
333
- plugin gateway.
321
+ plugin logic only; it should not start its own copy of the plugin gateway.
334
322
 
335
323
  ### Which backend pattern? (depends on the contribution point)
336
324
 
@@ -488,10 +476,10 @@ Known limits:
488
476
  - The MQTT broker echoes to the sender: a plugin that `emit`s and `subscribe`s the same topic receives its own push over `mqtt`. Drop `mqtt` from `explicit_events` or use separate topics. (TCP is filtered by `sender_id`.)
489
477
  - `on` and `subscribe` may share a name — they live in separate tables.
490
478
 
491
- During `muscat-pk dev` / `dev:back` the local bridge (port `10000`) accepts the
492
- `router.*` registrations, but **external calls need a running `common`** — it is
493
- `common` that routes `common/plugin/...` to the gateway. Use a robot (or a local
494
- `common`) to exercise the external addresses. After restarting the plugin process,
479
+ During `muscat-pk dev` the local Muscat runtime takes the `router.*` registrations
480
+ on its gateway (port `10000`), and its `common` (port `3000`) routes
481
+ `common/plugin/...` to them like on the robot — try
482
+ `curl http://localhost:3000/common/plugin/<plugin-id>/<path>`. After restarting the plugin process,
495
483
  external calls may return 504 for up to about a minute (until the gateway notices
496
484
  the old socket is gone), then 404 — that is expected.
497
485
 
@@ -773,7 +761,7 @@ sudo apt update && sudo apt install rainbow-rb-sdk-dev
773
761
  - CMake: `find_package(rb_sdk REQUIRED)` + `target_link_libraries(<target> rb_sdk::rb_sdk)` (C++20). pkg-config: `rb_sdk`.
774
762
  - Headers: `<rainbow/rb_sdk/manipulate/rb_manipulate_sdk.hpp>` (`RBManipulateSDK`),
775
763
  `<rainbow/rb_sdk/program/plugin_program_context.hpp>` (`rb_plugin::PluginProgramContext`, for `program.panel` commands — the header's top comment has a full `main()` example).
776
- - The binary is linked **dynamically**; `muscat-pk build` copies the matching `librb_sdk.so` into `dist/<arch>/lib/` (the robot has no SDK installed), so each plugin keeps the SDK version it was built with. Change the version with `MUSCAT_PK_RB_SDK_VERSION=<version> MUSCAT_PK_REBUILD_IMAGE=1 muscat-pk dev`.
764
+ - The binary is linked **dynamically**; `muscat-pk build` copies the matching `librb_sdk.so` into `dist/<arch>/lib/` (the robot has no SDK installed), so each plugin keeps the SDK version it was built with. The version is the one pinned in `CMakeLists.txt` (`find_package(rb_sdk <version> EXACT REQUIRED)`); change it with `muscat-pk sdk update <version>`.
777
765
  - UI ↔ backend messaging works from C++ too: `rb_plugin::RBPluginCall` in `<rainbow/rb_sdk/plugin/rb_plugin.hpp>` (see the scaffolded `cpp/main.cpp`).
778
766
 
779
767
  ### ROS2 (rclpy)
@@ -7,7 +7,7 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON)
7
7
  # Rainbow Robotics C++ SDK — provided by the apt package rainbow-rb-sdk-dev
8
8
  # (pre-installed in the muscat-pk dev container). The version here is the one the
9
9
  # dev container installs; change it with `muscat-pk sdk update <version>`.
10
- find_package(rb_sdk 0.1.6 EXACT REQUIRED)
10
+ find_package(rb_sdk 0.10.2 EXACT REQUIRED)
11
11
 
12
12
  # The executable name must match runtime.entry in manifest.json.
13
13
  add_executable(__PLUGIN_SLUG__ main.cpp)
@@ -307,21 +307,10 @@ export default function App() {
307
307
 
308
308
  ## Developing directly on the robot PC
309
309
 
310
- If you're running `muscat-pk dev` **on the robot PC itself**, Muscat is likely already
311
- running its own Socket.IO bridge on port 10000. In that case the CLI detects the port
312
- conflict and asks:
313
-
314
- ```
315
- ⚠ port 10000 is already in use — can't start Socket.IO bridge.
316
- Proceed without Socket.IO bridge? [y/N]:
317
- ```
318
-
319
- Answer **y**. Your plugin backend will connect to the Muscat bridge that's already
320
- running instead of starting a new one — exactly what you want when developing on the
321
- robot.
322
-
323
- > If you answer **n** (or just press Enter), the CLI exits so you can free the port
324
- > manually and retry.
310
+ If you're running `muscat-pk dev` **on the robot PC itself**, Muscat already serves the
311
+ plugin gateway on port 10000. The CLI sees that, does not start its own local Muscat, and
312
+ your plugin backend connects to the robot's gateway — exactly what you want when
313
+ developing on the robot.
325
314
 
326
315
  ---
327
316
 
@@ -329,8 +318,7 @@ robot.
329
318
 
330
319
  `manifest.json` chooses one backend runtime. `muscat-pk dev` runs backend code in
331
320
  a Docker container matching the robot environment. Backend code should contain
332
- plugin logic only; it should not start the Muscat bridge or its own copy of the
333
- plugin gateway.
321
+ plugin logic only; it should not start its own copy of the plugin gateway.
334
322
 
335
323
  ### Which backend pattern? (depends on the contribution point)
336
324
 
@@ -367,7 +355,7 @@ don't install anything locally.
367
355
 
368
356
  ```cmake
369
357
  # cpp/CMakeLists.txt — the version is the SDK the dev container installs (muscat-pk sdk update <version>)
370
- find_package(rb_sdk 0.1.6 EXACT REQUIRED)
358
+ find_package(rb_sdk 0.10.2 EXACT REQUIRED)
371
359
  add_executable(__PLUGIN_SLUG__ main.cpp) # name must equal runtime.entry
372
360
  target_link_libraries(__PLUGIN_SLUG__ PRIVATE rb_sdk::rb_sdk)
373
361
  ```
@@ -476,8 +464,9 @@ curl -s -X POST http://<robot>/common/plugin/<plugin_id>/hello \
476
464
  | Plugin did not answer within 10 s | 504 |
477
465
  | `common` ↔ gateway not connected | 503 |
478
466
 
479
- During `muscat-pk dev` the local bridge accepts `router()` registrations, but
480
- **external calls need a running `common`** (a robot or a local `common`). After
467
+ During `muscat-pk dev` the local Muscat runtime takes `router()` registrations on its
468
+ gateway and its `common` routes external calls to them like on the robot
469
+ (`curl http://localhost:3000/common/plugin/<plugin-id>/<path>`). After
481
470
  restarting the plugin, external calls may return 504 for about a minute, then 404.
482
471
 
483
472
  #### Program command backend (`program.panel`)
@@ -831,10 +820,10 @@ falling back to `README.md` if none matches.
831
820
  ## C++ SDK & dependencies
832
821
 
833
822
  The dev container has the C++ SDK (`rainbow-rb-sdk-dev`) pre-installed — no local
834
- install needed. Pick a different SDK version with:
823
+ install needed. The SDK version is the one pinned in `CMakeLists.txt` (`find_package(rb_sdk <version> EXACT REQUIRED)`). Pick a different one with:
835
824
 
836
825
  ```bash
837
- MUSCAT_PK_RB_SDK_VERSION=<version> MUSCAT_PK_REBUILD_IMAGE=1 muscat-pk dev
826
+ muscat-pk sdk update <version>
838
827
  ```
839
828
 
840
829
  `muscat-pk build` copies the matching `librb_sdk.so` next to your binary
@@ -10,7 +10,7 @@ find_package(rclcpp REQUIRED)
10
10
  # Rainbow Robotics C++ SDK (apt: rainbow-rb-sdk-dev, pre-installed in the dev
11
11
  # container). The version here is the one the dev container installs; change it
12
12
  # with `muscat-pk sdk update <version>`.
13
- find_package(rb_sdk 0.1.6 EXACT REQUIRED)
13
+ find_package(rb_sdk 0.10.2 EXACT REQUIRED)
14
14
 
15
15
  add_executable(__PLUGIN_SLUG___node src/node.cpp)
16
16
  ament_target_dependencies(__PLUGIN_SLUG___node rclcpp)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rainbow-robotics/muscat-plugin-kit",
3
- "version": "1.3.8-dev.0",
3
+ "version": "1.3.8-dev.2",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=20"
@@ -53,10 +53,9 @@
53
53
  },
54
54
  "dependencies": {
55
55
  "@modelcontextprotocol/sdk": "^1.29.0",
56
- "@rainbow-robotics/plugin-ui": "1.3.8-dev.0",
57
- "@rainbow-robotics/preview-host": "1.3.8-dev.0",
56
+ "@rainbow-robotics/plugin-ui": "1.3.8-dev.2",
57
+ "@rainbow-robotics/preview-host": "1.3.8-dev.2",
58
58
  "postcss": "^8.5.8",
59
- "socket.io": "^4.8.1",
60
59
  "typescript": "^5.8.0",
61
60
  "vite-plugin-css-injected-by-js": "^3.5.2",
62
61
  "zod": "^3.25.0"
@@ -1,17 +0,0 @@
1
- # Socket.IO bridge container — the frontend (preview-host) and the backend
2
- # container rendezvous here during `muscat-pk dev`.
3
- # - frontend (host) → published port localhost:10000
4
- # - backend container → shares this container's netns (--network container:),
5
- # so it reaches the bridge at localhost:10000 too
6
- # Build context is the `cli/` dir so it can copy src/socketBridge.mjs.
7
- FROM node:20-slim
8
- LABEL com.rainbow-robotics.muscat-pk="true"
9
-
10
- WORKDIR /bridge
11
- COPY docker/bridge-package.json package.json
12
- RUN npm install --omit=dev --no-audit --no-fund
13
-
14
- COPY src/socketBridge.mjs ./socketBridge.mjs
15
-
16
- EXPOSE 10000
17
- CMD ["node", "socketBridge.mjs"]
@@ -1,8 +0,0 @@
1
- {
2
- "name": "muscat-pk-bridge",
3
- "private": true,
4
- "type": "module",
5
- "dependencies": {
6
- "socket.io": "^4.8.1"
7
- }
8
- }
@@ -1,152 +0,0 @@
1
- // Socket.IO bridge container lifecycle for `muscat-pk dev`.
2
- // The bridge runs in Docker and publishes port 10000 to the host:
3
- // - frontend (preview-host, on the host) connects to localhost:10000
4
- // - the backend container joins via `--network container:<bridge>` and reaches
5
- // it at localhost:10000 too — no host.docker.internal needed (robust cross-OS).
6
- import { spawnSync } from "node:child_process";
7
- import { createServer, connect } from "node:net";
8
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
9
- import { tmpdir } from "node:os";
10
- import { dirname, resolve } from "node:path";
11
- import { canSkipImageBuild, hasBin, kitRoot, log, pruneMuskatImages } from "./util.mjs";
12
- import { MEDIA_PORTS, mediaPortFlags } from "./ports.mjs";
13
- import { usesHostNetwork } from "./zenohDev.mjs";
14
-
15
- export const BRIDGE_IMAGE = "muscat-pk-bridge";
16
- export const BRIDGE_CONTAINER = "muscat-pk-bridge-run";
17
- export const BRIDGE_PORT = 10000;
18
-
19
- // 컴파일된 바이너리에는 cli/ 트리가 없어 임베드 스냅샷에서 build context 를 tmp 에 복원한다.
20
- const BRIDGE_CONTEXT_FILES = ["docker/Dockerfile.bridge", "docker/bridge-package.json", "src/socketBridge.mjs"];
21
-
22
- async function resolveBridgeContext() {
23
- const context = resolve(kitRoot, "cli");
24
- const dockerfile = resolve(context, "docker/Dockerfile.bridge");
25
- if (existsSync(dockerfile)) return { dockerfile, context };
26
-
27
- const { TEMPLATES } = await import("./templates-embedded.mjs");
28
- const dir = resolve(tmpdir(), "muscat-pk-bridge-context");
29
- for (const key of BRIDGE_CONTEXT_FILES) {
30
- const b64 = TEMPLATES[key];
31
- if (!b64) throw new Error(`Embedded file missing: ${key}. Reinstall muscat-plugin-kit.`);
32
- const file = resolve(dir, key);
33
- mkdirSync(dirname(file), { recursive: true });
34
- writeFileSync(file, Buffer.from(b64, "base64"));
35
- }
36
- return { dockerfile: resolve(dir, "docker/Dockerfile.bridge"), context: dir };
37
- }
38
-
39
- function assertDockerReady() {
40
- if (!hasBin("docker")) {
41
- throw new Error("Docker not found. `muscat-pk dev` runs the Socket.IO bridge in a container.");
42
- }
43
- }
44
-
45
- // Is a host TCP port already bound? (Catches another bridge or a stray process.)
46
- export function isPortInUse(port) {
47
- return new Promise((res) => {
48
- const srv = createServer();
49
- srv.once("error", (err) => res(err.code === "EADDRINUSE"));
50
- srv.once("listening", () => srv.close(() => res(false)));
51
- srv.listen(port, "0.0.0.0");
52
- });
53
- }
54
-
55
- // Wait until the published port accepts a TCP connection (bridge is ready).
56
- function waitForPort(port, timeoutMs = 15_000) {
57
- const start = Date.now();
58
- return new Promise((res, rej) => {
59
- const tryOnce = () => {
60
- const sock = connect(port, "127.0.0.1");
61
- sock.once("connect", () => { sock.destroy(); res(); });
62
- sock.once("error", () => {
63
- sock.destroy();
64
- if (Date.now() - start > timeoutMs) rej(new Error("bridge container did not become ready in time"));
65
- else setTimeout(tryOnce, 300);
66
- });
67
- };
68
- tryOnce();
69
- });
70
- }
71
-
72
- async function ensureImage() {
73
- if (canSkipImageBuild(BRIDGE_IMAGE)) {
74
- log.step(`${BRIDGE_IMAGE} already built — skipping docker build`);
75
- return;
76
- }
77
- const { dockerfile, context } = await resolveBridgeContext();
78
- log.step(`docker build -t ${BRIDGE_IMAGE} (first run is slow, then cached)…`);
79
- const r = spawnSync("docker", ["build", "-t", BRIDGE_IMAGE, "-f", dockerfile, context], { stdio: "inherit" });
80
- if (r.status !== 0) throw new Error("docker build failed (bridge image).");
81
- pruneMuskatImages();
82
- }
83
-
84
- /** A running Muscat container (e.g. muscat-dev) that owns host port 10000, or null. */
85
- export function localMuscatContainer() {
86
- const r = spawnSync("docker", ["ps", "--filter", `publish=${BRIDGE_PORT}`, "--format", "{{.Names}}"], { encoding: "utf8" });
87
- return (r.stdout || "").split("\n").map((n) => n.trim()).find((n) => n && n !== BRIDGE_CONTAINER) ?? null;
88
- }
89
-
90
- /**
91
- * Build the image, preflight the port, and start the bridge container.
92
- * Returns false if port 10000 was already in use (caller decides), else { media }:
93
- * whether the MediaMTX ports are free for the backend's own MediaMTX.
94
- */
95
- export async function startBridge({ zenohPort = null, pluginPorts = [] } = {}) {
96
- assertDockerReady();
97
-
98
- // remove our own stale container first, then check the host port is free
99
- spawnSync("docker", ["rm", "-f", BRIDGE_CONTAINER], { stdio: "ignore" });
100
- if (await isPortInUse(BRIDGE_PORT)) return false;
101
-
102
- // Publishing a busy port makes `docker run` fail with a raw daemon error —
103
- // say which port and keep the bridge up instead.
104
- const busy = [];
105
- for (const port of pluginPorts) {
106
- if (await isPortInUse(port)) busy.push(port);
107
- }
108
- if (busy.length > 0) {
109
- log.warn(
110
- `declared plugin port(s) already in use on the host: ${busy.join(", ")} — ` +
111
- `not published (the backend still listens inside the container).`,
112
- );
113
- }
114
- const publishable = pluginPorts.filter((port) => !busy.includes(port));
115
-
116
- const mediaBusy = [];
117
- for (const port of MEDIA_PORTS) {
118
- if (await isPortInUse(port)) mediaBusy.push(port);
119
- }
120
- const media = mediaBusy.length === 0;
121
- if (!media) log.warn(`MediaMTX port(s) already in use: ${mediaBusy.join(", ")} — video streaming disabled in dev.`);
122
-
123
- await ensureImage();
124
-
125
- log.step("starting Socket.IO bridge container…");
126
- // The backend joins THIS container's netns (--network container:), and a
127
- // netns can only publish ports when its owner starts — so the plugin's
128
- // declared ports have to be published here, not on the backend container.
129
- const portFlags = [
130
- "-p", `127.0.0.1:${BRIDGE_PORT}:${BRIDGE_PORT}`,
131
- ...(zenohPort ? ["-p", `${zenohPort}:${zenohPort}`] : []),
132
- ...publishable.flatMap((port) => ["-p", `127.0.0.1:${port}:${port}`]),
133
- ...(media && !usesHostNetwork() ? mediaPortFlags() : []),
134
- ];
135
- const up = spawnSync(
136
- "docker",
137
- ["run", "-d", "--name", BRIDGE_CONTAINER, ...portFlags, BRIDGE_IMAGE],
138
- { stdio: "inherit" },
139
- );
140
- if (up.status !== 0) throw new Error("failed to start bridge container.");
141
-
142
- await waitForPort(BRIDGE_PORT);
143
- log.step(`Socket.IO bridge listening on ws://127.0.0.1:${BRIDGE_PORT}`);
144
- if (zenohPort) log.step(`Zenoh dev peer port published on :${zenohPort}`);
145
- if (publishable.length > 0)
146
- log.step(`plugin backend ports published on 127.0.0.1: ${publishable.join(", ")}`);
147
- return { media };
148
- }
149
-
150
- export function stopBridge() {
151
- spawnSync("docker", ["rm", "-f", BRIDGE_CONTAINER], { stdio: "ignore" });
152
- }
@@ -1,133 +0,0 @@
1
- import { createServer } from "node:http";
2
- import { randomUUID } from "node:crypto";
3
- import { fileURLToPath } from "node:url";
4
- import { Server } from "socket.io";
5
-
6
- // Self-contained logger — this module also runs standalone inside the bridge
7
- // Docker container, where ./util.mjs is not available.
8
- const log = {
9
- step: (...m) => console.log("›", ...m),
10
- warn: (...m) => console.warn("⚠", ...m),
11
- };
12
-
13
- const DEFAULT_ACTION_TIMEOUT_MS = 10_000;
14
-
15
- export function startSocketBridge({ port = 10000, host = "0.0.0.0" } = {}) {
16
- const httpServer = createServer();
17
- const io = new Server(httpServer, { cors: { origin: "*" } });
18
-
19
- // topic → socketId (serve_register로 등록된 queryable)
20
- const queryables = new Map();
21
- // call_id → { timer, resolve } (action → serve_call → serve_reply 대기)
22
- const pending = new Map();
23
-
24
- function cleanupSocket(socket) {
25
- for (const [topic, sid] of queryables) {
26
- if (sid === socket.id) queryables.delete(topic);
27
- }
28
- for (const [id, p] of pending) {
29
- if (p.sid !== socket.id) continue;
30
- clearTimeout(p.timer);
31
- p.resolve(undefined);
32
- pending.delete(id);
33
- }
34
- }
35
-
36
- io.on("connection", (socket) => {
37
- socket.on("sub", async (data = {}) => {
38
- if (!data.topic) return;
39
- await socket.join(`t:${data.topic}`);
40
- });
41
-
42
- socket.on("unsub", async (data = {}) => {
43
- if (!data.topic) return;
44
- await socket.leave(`t:${data.topic}`);
45
- });
46
-
47
- socket.on("pub", (data = {}) => {
48
- const { topic, payload } = data;
49
- if (!topic) return;
50
- io.to(`t:${topic}`).emit("msg", { topic, payload });
51
- });
52
-
53
- socket.on("serve_register", (data = {}) => {
54
- if (!data.topic) return;
55
- queryables.set(data.topic, socket.id);
56
- log.step(`serve registered: ${data.topic}`);
57
- });
58
-
59
- socket.on("serve_unregister", (data = {}) => {
60
- if (queryables.get(data.topic) === socket.id) queryables.delete(data.topic);
61
- if (data.topic) log.step(`serve unregistered: ${data.topic}`);
62
- });
63
-
64
- socket.on("serve_reply", (data = {}) => {
65
- const p = pending.get(data.id);
66
- if (!p) return;
67
- clearTimeout(p.timer);
68
- p.resolve(data.payload);
69
- pending.delete(data.id);
70
- });
71
-
72
- socket.on("action", (data = {}, ack) => {
73
- const { topic, payload } = data;
74
- const targetSid = queryables.get(topic);
75
- const target = targetSid && io.sockets.sockets.get(targetSid);
76
- if (!target) {
77
- log.warn(`action has no handler: ${topic}`);
78
- if (typeof ack === "function") ack({ error: `No handler registered for ${topic}` });
79
- return;
80
- }
81
- log.step(`action: ${topic}`);
82
- const id = randomUUID();
83
- const timer = setTimeout(() => {
84
- pending.delete(id);
85
- if (typeof ack === "function") ack(undefined);
86
- }, DEFAULT_ACTION_TIMEOUT_MS);
87
- pending.set(id, {
88
- sid: targetSid,
89
- timer,
90
- resolve: (v) => { if (typeof ack === "function") ack(v); },
91
- });
92
- target.emit("serve_call", { id, topic, payload });
93
- });
94
-
95
- socket.on("disconnect", () => cleanupSocket(socket));
96
- });
97
-
98
- return new Promise((resolve) => {
99
- httpServer.once("error", (err) => {
100
- if (err.code === "EADDRINUSE") {
101
- log.warn(`Socket.IO bridge already running on :${port} — skipping`);
102
- io.close();
103
- httpServer.close();
104
- resolve({ close: () => {} });
105
- } else {
106
- throw err;
107
- }
108
- });
109
-
110
- httpServer.listen(port, host, () => {
111
- log.step(`Socket.IO bridge listening on ws://127.0.0.1:${port}`);
112
-
113
- const close = () => {
114
- for (const p of pending.values()) {
115
- clearTimeout(p.timer);
116
- p.resolve(undefined);
117
- }
118
- pending.clear();
119
- queryables.clear();
120
- io.close();
121
- httpServer.close();
122
- };
123
-
124
- resolve({ close });
125
- });
126
- });
127
- }
128
-
129
- // When executed directly (inside the bridge Docker container), start the server.
130
- if (process.argv[1] && process.argv[1] === fileURLToPath(import.meta.url)) {
131
- const port = Number(process.env.PORT) || 10000;
132
- startSocketBridge({ port });
133
- }