@rainbow-robotics/muscat-plugin-kit 0.1.2-dev.42 → 0.1.2-dev.44

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.
@@ -11,7 +11,6 @@ import { runUpdate } from "../src/update.mjs";
11
11
  import { runUninstall } from "../src/uninstall.mjs";
12
12
  import { runSdk } from "../src/sdk.mjs";
13
13
  import { runType } from "../src/type.mjs";
14
- import { runPreview } from "../src/preview.mjs";
15
14
 
16
15
  const [, , cmd, ...args] = process.argv;
17
16
 
@@ -26,7 +25,6 @@ Commands:
26
25
  dev Run Socket.IO bridge, dev:ui, and dev:back together
27
26
  dev:ui Preview the plugin in a mini-Muscat (preview-host)
28
27
  dev:back Run the plugin back end (python: watchfiles reload / ros2: Docker)
29
- preview Preview against a real Muscat server (prompts for robot IP)
30
28
  update Update @rainbow-robotics/muscat-plugin-kit and @rainbow-robotics/plugin-ui
31
29
  uninstall Remove the installed muscat-pk CLI (npm global or S3 binary)
32
30
  sdk <list|update [version]> Manage the python rainbow-rb-sdk version
@@ -48,8 +46,6 @@ async function main() {
48
46
  return await runDevUi(args);
49
47
  case "dev:back":
50
48
  return await runDevBack(args);
51
- case "preview":
52
- return await runPreview(args);
53
49
  case "update":
54
50
  return await runUpdate(args);
55
51
  case "uninstall":
@@ -59,19 +59,16 @@ function ensureImage() {
59
59
  pruneMuskatImages();
60
60
  }
61
61
 
62
- /** Build the image, preflight the port, and start the bridge container. */
62
+ /**
63
+ * Build the image, preflight the port, and start the bridge container.
64
+ * Returns true if bridge started, false if port was already in use (caller decides).
65
+ */
63
66
  export async function startBridge() {
64
67
  assertDockerReady();
65
68
 
66
69
  // remove our own stale container first, then check the host port is free
67
70
  spawnSync("docker", ["rm", "-f", BRIDGE_CONTAINER], { stdio: "ignore" });
68
- if (await isPortInUse(BRIDGE_PORT)) {
69
- throw new Error(
70
- `port ${BRIDGE_PORT} is already in use.\n` +
71
- ` Another 'muscat-pk dev' may be running, or a process is holding the port.\n` +
72
- ` Stop it, or free the port, then retry.`,
73
- );
74
- }
71
+ if (await isPortInUse(BRIDGE_PORT)) return false;
75
72
 
76
73
  ensureImage();
77
74
 
@@ -85,6 +82,7 @@ export async function startBridge() {
85
82
 
86
83
  await waitForPort(BRIDGE_PORT);
87
84
  log.step(`Socket.IO bridge listening on ws://127.0.0.1:${BRIDGE_PORT}`);
85
+ return true;
88
86
  }
89
87
 
90
88
  export function stopBridge() {
package/cli/src/dev.mjs CHANGED
@@ -1,19 +1,29 @@
1
1
  // muscat-pk dev — run the Socket.IO bridge (Docker), dev:ui, and dev:back together.
2
2
  import { runDevUi } from "./devUi.mjs";
3
3
  import { runDevBack } from "./devBack.mjs";
4
- import { startBridge, stopBridge, BRIDGE_CONTAINER } from "./bridge.mjs";
5
- import { ensureDocker, log } from "./util.mjs";
4
+ import { startBridge, stopBridge, BRIDGE_CONTAINER, BRIDGE_PORT } from "./bridge.mjs";
5
+ import { ensureDocker, log, prompt, promptRobotConnection } from "./util.mjs";
6
6
 
7
7
  export async function runDev(args = []) {
8
8
  ensureDocker();
9
+ const { robotIp, robotToken } = await promptRobotConnection(args);
10
+ if (robotIp) process.env.MUSCAT_ROBOT_HOST = robotIp;
11
+ if (robotToken) process.env.MUSCAT_ROBOT_TOKEN = robotToken;
9
12
  log.step("starting Socket.IO bridge (Docker) + dev:ui + dev:back");
10
- await startBridge();
13
+ const bridgeStarted = await startBridge();
11
14
 
12
- // Tell dev:back which bridge container to share a netns with, so the backend
13
- // reaches the bridge at localhost:10000 (no host.docker.internal).
14
- process.env.MUSCAT_PK_BRIDGE_CONTAINER = BRIDGE_CONTAINER;
15
+ if (!bridgeStarted) {
16
+ log.warn(`port ${BRIDGE_PORT} is already in use — can't start Socket.IO bridge.`);
17
+ const answer = await prompt(" Proceed without Socket.IO bridge? [y/N]: ");
18
+ if (answer.toLowerCase() !== "y") return;
19
+ log.info("proceeding without bridge — backend will skip network sharing.");
20
+ } else {
21
+ // Tell dev:back which bridge container to share a netns with, so the backend
22
+ // reaches the bridge at localhost:10000 (no host.docker.internal).
23
+ process.env.MUSCAT_PK_BRIDGE_CONTAINER = BRIDGE_CONTAINER;
24
+ }
15
25
 
16
- const cleanup = () => stopBridge();
26
+ const cleanup = () => { if (bridgeStarted) stopBridge(); };
17
27
  process.once("SIGINT", cleanup);
18
28
  process.once("SIGTERM", cleanup);
19
29
  process.once("exit", cleanup);
@@ -10,7 +10,7 @@
10
10
  import { spawn, spawnSync } from "node:child_process";
11
11
  import { existsSync, watch } from "node:fs";
12
12
  import { resolve } from "node:path";
13
- import { ensureDocker, findPluginRoot, log } from "./util.mjs";
13
+ import { ensureDocker, findPluginRoot, log, promptRobotConnection } from "./util.mjs";
14
14
  import { manifestRuntime, readManifest, validateManifest } from "./manifest.mjs";
15
15
  import {
16
16
  PYTHON_DEV_IMAGE,
@@ -40,7 +40,13 @@ async function runPython(root) {
40
40
  const manifest = readManifest(root);
41
41
  const pluginId = manifest.id ?? "";
42
42
  const container = `${PYTHON_DEV_IMAGE}-run`;
43
- const envFlags = pluginId ? ["-e", `RB_PLUGIN_ID=${pluginId}`] : [];
43
+ const robotIp = process.env.MUSCAT_ROBOT_HOST ?? "";
44
+ const robotToken = process.env.MUSCAT_ROBOT_TOKEN ?? "";
45
+ const envFlags = [
46
+ ...(pluginId ? ["-e", `RB_PLUGIN_ID=${pluginId}`] : []),
47
+ ...(robotIp ? ["-e", `MUSCAT_ROBOT_HOST=${robotIp}`] : []),
48
+ ...(robotToken ? ["-e", `MUSCAT_ROBOT_TOKEN=${robotToken}`] : []),
49
+ ];
44
50
  // Share the bridge container's network namespace so localhost:10000 is the
45
51
  // bridge (set by `muscat-pk dev`). Standalone dev:back has no bridge to join.
46
52
  const bridgeContainer = process.env.MUSCAT_PK_BRIDGE_CONTAINER;
@@ -105,7 +111,13 @@ async function runRos2(root) {
105
111
  const pluginId = manifest.id ?? "";
106
112
  const isRclcpp = manifest.runtime?.rosClient === "rclcpp";
107
113
  const container = `${ROS2_DEV_IMAGE}-run`;
108
- const envFlags = pluginId ? ["-e", `RB_PLUGIN_ID=${pluginId}`] : [];
114
+ const robotIp = process.env.MUSCAT_ROBOT_HOST ?? "";
115
+ const robotToken = process.env.MUSCAT_ROBOT_TOKEN ?? "";
116
+ const envFlags = [
117
+ ...(pluginId ? ["-e", `RB_PLUGIN_ID=${pluginId}`] : []),
118
+ ...(robotIp ? ["-e", `MUSCAT_ROBOT_HOST=${robotIp}`] : []),
119
+ ...(robotToken ? ["-e", `MUSCAT_ROBOT_TOKEN=${robotToken}`] : []),
120
+ ];
109
121
  const docker = (args, opts = {}) =>
110
122
  spawnSync("docker", args, { cwd: root, stdio: "inherit", ...opts });
111
123
 
@@ -177,7 +189,13 @@ async function runCpp(root) {
177
189
 
178
190
  const pluginId = manifest.id ?? "";
179
191
  const container = `${CPP_DEV_IMAGE}-run`;
180
- const envFlags = pluginId ? ["-e", `RB_PLUGIN_ID=${pluginId}`] : [];
192
+ const robotIp = process.env.MUSCAT_ROBOT_HOST ?? "";
193
+ const robotToken = process.env.MUSCAT_ROBOT_TOKEN ?? "";
194
+ const envFlags = [
195
+ ...(pluginId ? ["-e", `RB_PLUGIN_ID=${pluginId}`] : []),
196
+ ...(robotIp ? ["-e", `MUSCAT_ROBOT_HOST=${robotIp}`] : []),
197
+ ...(robotToken ? ["-e", `MUSCAT_ROBOT_TOKEN=${robotToken}`] : []),
198
+ ];
181
199
  const docker = (args, opts = {}) =>
182
200
  spawnSync("docker", args, { cwd: root, stdio: "inherit", ...opts });
183
201
 
@@ -240,10 +258,13 @@ function warnManifest(root) {
240
258
  for (const e of errors) log.warn(`manifest.json: ${e}`);
241
259
  }
242
260
 
243
- export async function runDevBack() {
261
+ export async function runDevBack(args = []) {
244
262
  ensureDocker();
245
263
  const root = findPluginRoot();
246
264
  warnManifest(root);
265
+ const { robotIp, robotToken } = await promptRobotConnection(args);
266
+ if (robotIp) process.env.MUSCAT_ROBOT_HOST = robotIp;
267
+ if (robotToken) process.env.MUSCAT_ROBOT_TOKEN = robotToken;
247
268
  const type = manifestRuntime(root);
248
269
  if (type === "ros2") return await runRos2(root);
249
270
  if (type === "cpp") return await runCpp(root);
package/cli/src/devUi.mjs CHANGED
@@ -87,7 +87,7 @@ function spawnViteWatch(pluginRoot, node, { robotIp = "" } = {}) {
87
87
  return spawn(node, [viteBin, "build", "--watch", "--mode", "development", "--outDir", DEV_OUT], {
88
88
  cwd: pluginRoot,
89
89
  stdio: "inherit",
90
- env: { ...process.env, ...(robotIp ? { VITE_RRS_PY_HOST: robotIp } : {}) },
90
+ env: { ...process.env, VITE_PLUGIN_PREVIEW: "true", ...(robotIp ? { VITE_RRS_PY_HOST: robotIp } : {}) },
91
91
  });
92
92
  }
93
93
 
@@ -108,7 +108,7 @@ function waitForInitialBuild(distDir, timeoutMs = 30_000) {
108
108
  export async function runDevUi(args = []) {
109
109
  const parsed = parseArgs(args);
110
110
  const port = parsed.port ?? "4180";
111
- const robotIp = parsed["robot-ip"] ?? await prompt(" Robot IP (Enter to skip): ");
111
+ const robotIp = parsed["robot-ip"] ?? process.env.MUSCAT_ROBOT_HOST ?? await prompt(" Robot IP (skip if no robot): ");
112
112
 
113
113
  await checkKitUpdate();
114
114
  const pluginRoot = findPluginRoot();
package/cli/src/util.mjs CHANGED
@@ -76,6 +76,31 @@ export async function prompt(question, fallback = "") {
76
76
  return answer || fallback;
77
77
  }
78
78
 
79
+ const LOCAL_HOSTS = new Set(["", "localhost", "127.0.0.1", "::1"]);
80
+
81
+ /**
82
+ * Ask for robot IP (and token if remote). Returns { robotIp, robotToken }.
83
+ * Skips prompts if already set in process.env (from a parent command like `dev`).
84
+ * Supports --robot-ip / --robot-token flags for non-interactive use.
85
+ */
86
+ export async function promptRobotConnection(args = []) {
87
+ const parsed = parseArgs(args);
88
+ const robotIp =
89
+ parsed["robot-ip"] ??
90
+ process.env.MUSCAT_ROBOT_HOST ??
91
+ (await prompt(" Robot IP (skip if no robot): "));
92
+
93
+ if (LOCAL_HOSTS.has(robotIp)) return { robotIp, robotToken: "" };
94
+
95
+ const robotToken =
96
+ parsed["robot-token"] ??
97
+ process.env.MUSCAT_ROBOT_TOKEN ??
98
+ (await prompt(" SDK Token (required for remote): "));
99
+
100
+ if (!robotToken) throw new Error("SDK token is required for remote robot connection.");
101
+ return { robotIp, robotToken };
102
+ }
103
+
79
104
  /** Walk up from cwd to find the plugin project root (has manifest.json). */
80
105
  export function findPluginRoot(start = process.cwd()) {
81
106
  let dir = resolve(start);
@@ -99,6 +99,26 @@ connect(App);
99
99
 
100
100
  ---
101
101
 
102
+ ## Developing directly on the robot PC
103
+
104
+ If you're running `muscat-pk dev` **on the robot PC itself**, Muscat is likely already
105
+ running its own Socket.IO bridge on port 10000. In that case the CLI detects the port
106
+ conflict and asks:
107
+
108
+ ```
109
+ ⚠ port 10000 is already in use — can't start Socket.IO bridge.
110
+ Proceed without Socket.IO bridge? [y/N]:
111
+ ```
112
+
113
+ Answer **y**. Your plugin backend will connect to the Muscat bridge that's already
114
+ running instead of starting a new one — exactly what you want when developing on the
115
+ robot.
116
+
117
+ > If you answer **n** (or just press Enter), the CLI exits so you can free the port
118
+ > manually and retry.
119
+
120
+ ---
121
+
102
122
  ## Backend (`py/main.py`)
103
123
 
104
124
  The backend communicates with the frontend using `rainbow.rb_sdk.RBPluginCall`.
@@ -1,15 +1,19 @@
1
1
  """
2
2
  Muscat plugin backend — __PLUGIN_NAME__
3
3
 
4
- Run with:
4
+ On-robot (default):
5
5
  muscat-pk dev:back
6
6
  python main.py
7
7
 
8
- This template uses the high-level Rainbow Python SDK instead of wiring the
9
- transport layer directly.
8
+ Remote dev (from your laptop):
9
+ Set env vars before running:
10
+ MUSCAT_ROBOT_HOST=192.168.1.100
11
+ MUSCAT_ROBOT_TOKEN=<service token>
12
+ Then: muscat-pk dev:back
10
13
  """
11
14
 
12
- import time
15
+ import asyncio
16
+ import os
13
17
  from typing import Any
14
18
 
15
19
  from rainbow.rb_sdk import RBManipulateSDK, RBPluginCall
@@ -18,40 +22,30 @@ from rainbow.rb_sdk import RBManipulateSDK, RBPluginCall
18
22
  PLUGIN_NAME = "__PLUGIN_NAME__"
19
23
 
20
24
  plugin = RBPluginCall()
25
+ manipulate = RBManipulateSDK()
21
26
 
22
27
 
23
- @plugin.subscribe("event")
24
- def on_ui_event(payload: dict[str, Any] | None = None):
25
- """Handle messages published from the plugin UI with rb.pub("event", ...)."""
26
- print(f"[{PLUGIN_NAME}] event:", payload)
28
+ def register_handlers():
29
+ # Register handlers here — called after connect() so subscriptions survive
30
+ # session reconnection in remote-dev mode.
27
31
 
32
+ @plugin.subscribe("event")
33
+ def on_ui_event(payload: dict[str, Any] | None = None):
34
+ """Handle messages published from the plugin UI with rb.pub("event", ...)."""
35
+ print(f"[{PLUGIN_NAME}] event:", payload)
28
36
 
29
- @plugin.serve("grip")
30
- def grip(payload: dict[str, Any] | None = None):
31
- """Handle the default UI button: rb.action("grip", { width })."""
32
- width = (payload or {}).get("width", 20)
33
- print(f"[{PLUGIN_NAME}] grip requested: width={width}")
37
+ @plugin.serve("grip")
38
+ def grip(payload: dict[str, Any] | None = None):
39
+ """Handle the default UI button: rb.action("grip", { width })."""
40
+ width = (payload or {}).get("width", 20)
41
+ print(f"[{PLUGIN_NAME}] grip requested: width={width}")
42
+ plugin.publish("state", {"ready": True, "last_grip_width": width})
43
+ return {"ok": True, "width": width}
34
44
 
35
- plugin.publish("state", {"ready": True, "last_grip_width": width})
36
- return {"ok": True, "width": width}
37
-
38
-
39
- @plugin.serve("tool/echo")
40
- def echo(payload: dict[str, Any] | None = None):
41
- """Small query example callable from the UI with rb.action("tool/echo", ...)."""
42
- return {"echo": payload, "plugin": PLUGIN_NAME}
43
-
44
-
45
- def ping_ui_example():
46
- """
47
- Example: call a queryable served by the UI.
48
-
49
- In the frontend, register:
50
- rb.serve("tool/ping", async (payload) => ({ pong: payload }))
51
- """
52
- res = plugin.call("tool/ping", payload={"hello": "plugin"})
53
- print(f"[{PLUGIN_NAME}] ping response:", res)
54
- return res
45
+ @plugin.serve("tool/echo")
46
+ def echo(payload: dict[str, Any] | None = None):
47
+ """Small query example callable from the UI with rb.action("tool/echo", ...)."""
48
+ return {"echo": payload, "plugin": PLUGIN_NAME}
55
49
 
56
50
 
57
51
  def move_j_example():
@@ -61,8 +55,6 @@ def move_j_example():
61
55
  This function is not called automatically. Add safety checks and call it
62
56
  from your own handler when the robot is ready.
63
57
  """
64
- manipulate = RBManipulateSDK()
65
-
66
58
  return manipulate.move.call_move_j(
67
59
  robot_model="C500920",
68
60
  target={
@@ -73,16 +65,34 @@ def move_j_example():
73
65
  )
74
66
 
75
67
 
76
- def main():
68
+ async def main():
69
+ robot_host = os.getenv("MUSCAT_ROBOT_HOST")
70
+ robot_token = os.getenv("MUSCAT_ROBOT_TOKEN")
71
+
72
+ if robot_host and robot_token:
73
+ # Remote dev: connect to the robot before registering any handlers.
74
+ # connect() closes and reopens the Zenoh session, so handlers registered
75
+ # before this call would be lost.
76
+ await manipulate.connect(token=robot_token, host=robot_host)
77
+
78
+ register_handlers()
79
+
77
80
  plugin.publish("state", {"ready": True, "plugin": PLUGIN_NAME})
78
81
  print(f"[{PLUGIN_NAME}] backend ready")
79
82
 
80
83
  try:
81
84
  while True:
82
- time.sleep(1)
83
- except KeyboardInterrupt:
85
+ await asyncio.sleep(1)
86
+ except (KeyboardInterrupt, asyncio.CancelledError):
87
+ pass
88
+ finally:
89
+ if robot_host and robot_token:
90
+ await manipulate.disconnect()
84
91
  print(f"[{PLUGIN_NAME}] stopped")
85
92
 
86
93
 
87
94
  if __name__ == "__main__":
88
- main()
95
+ try:
96
+ asyncio.run(main())
97
+ except KeyboardInterrupt:
98
+ pass
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rainbow-robotics/muscat-plugin-kit",
3
- "version": "0.1.2-dev.42",
3
+ "version": "0.1.2-dev.44",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=20"
@@ -31,7 +31,7 @@
31
31
  "@types/react-dom": "^19.0.4"
32
32
  },
33
33
  "dependencies": {
34
- "@rainbow-robotics/preview-host": "0.1.2-dev.42",
34
+ "@rainbow-robotics/preview-host": "0.1.2-dev.44",
35
35
  "socket.io": "^4.8.1"
36
36
  },
37
37
  "optionalDependencies": {
@@ -1,91 +0,0 @@
1
- // muscat-pk preview — build the plugin and open preview-host connected to a
2
- // real Muscat server (robot or local). No local Socket.IO server is started.
3
- import { createInterface } from "node:readline";
4
- import { spawn } from "node:child_process";
5
- import { existsSync } from "node:fs";
6
- import { resolve } from "node:path";
7
- import { buildPlugin } from "./build.mjs";
8
- import { findPreviewHostRoot } from "./devUi.mjs";
9
- import { checkKitUpdate, log, parseArgs } from "./util.mjs";
10
-
11
- function prompt(question, defaultValue) {
12
- const rl = createInterface({ input: process.stdin, output: process.stdout });
13
- return new Promise((res) => {
14
- rl.question(question, (answer) => {
15
- rl.close();
16
- res(answer.trim() || defaultValue);
17
- });
18
- });
19
- }
20
-
21
- export async function runPreview(args = []) {
22
- const opts = parseArgs(args, { booleans: ["help"] });
23
-
24
- if (opts.help) {
25
- console.log(
26
- [
27
- "",
28
- "Usage: muscat-pk preview [options]",
29
- "",
30
- " 빌드된 플러그인을 실제 Muscat 서버에 연결해 미리봅니다.",
31
- " Socket.IO 서버를 직접 띄우지 않습니다.",
32
- "",
33
- "Options:",
34
- " --host <ip> Muscat 서버 IP 또는 hostname (default: localhost)",
35
- " --port <n> preview-host 로컬 포트 (default: 4180)",
36
- " --ws-port <n> Muscat Socket.IO 포트 (default: 10000)",
37
- "",
38
- ].join("\n"),
39
- );
40
- return;
41
- }
42
-
43
- await checkKitUpdate();
44
-
45
- // ── 로봇 IP 결정 ────────────────────────────────────────────────────────────
46
- let robotHost = opts.host ?? "";
47
- if (!robotHost) {
48
- robotHost = await prompt(
49
- "Muscat 서버 IP 또는 hostname [localhost]: ",
50
- "localhost",
51
- );
52
- }
53
- const wsPort = opts["ws-port"] ?? "10000";
54
- const socketUrl = `ws://${robotHost}:${wsPort}`;
55
-
56
- const previewPort = opts.port ?? "4180";
57
-
58
- log.step(`Muscat 서버: ${socketUrl}`);
59
-
60
- // ── 플러그인 빌드 ────────────────────────────────────────────────────────────
61
- const { distDir } = await buildPlugin();
62
-
63
- // ── preview-host 실행 ────────────────────────────────────────────────────────
64
- const previewRoot = findPreviewHostRoot();
65
- const serveEntry = resolve(previewRoot, "serve.mjs");
66
-
67
- if (!existsSync(resolve(previewRoot, "dist", "index.html"))) {
68
- log.step("preview-host 번들 빌드 중 (최초 1회)…");
69
- await new Promise((res, rej) => {
70
- const p = spawn(process.execPath, [resolve(previewRoot, "build.mjs")], {
71
- stdio: "inherit",
72
- });
73
- p.on("exit", (c) => (c === 0 ? res() : rej(new Error("preview-host build failed"))));
74
- });
75
- }
76
-
77
- log.step(`preview-host 시작 → http://localhost:${previewPort}`);
78
- log.step(`Socket.IO URL: ${socketUrl}`);
79
-
80
- const child = spawn(
81
- process.execPath,
82
- [
83
- serveEntry,
84
- "--plugin", distDir,
85
- "--port", String(previewPort),
86
- "--socket-url", socketUrl,
87
- ],
88
- { stdio: "inherit" },
89
- );
90
- child.on("exit", (code) => process.exit(code ?? 0));
91
- }