@rainbow-robotics/muscat-plugin-kit 0.1.2-dev.11 → 0.1.2-dev.13

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.
@@ -23,7 +23,7 @@ Commands:
23
23
  type change Switch backend type (python <-> ros2)
24
24
  build Build src/content.tsx -> dist/content.js (+ manifest, assets)
25
25
  pack Build then create a distributable ZIP (with size report)
26
- dev Run dev:ui and dev:back together
26
+ dev Run Socket.IO bridge, dev:ui, and dev:back together
27
27
  dev:ui Preview the plugin in a mini-Muscat (preview-host)
28
28
  dev:back Run the plugin back end (python: watchfiles reload / ros2: Docker)
29
29
  preview Preview against a real Muscat server (prompts for robot IP)
@@ -1,5 +1,5 @@
1
1
  import { execFileSync } from "node:child_process";
2
- import { readdirSync, statSync } from "node:fs";
2
+ import { existsSync, readdirSync, statSync } from "node:fs";
3
3
  import { dirname, resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
 
@@ -22,6 +22,7 @@ function* mjsFiles(dir) {
22
22
  }
23
23
 
24
24
  for (const dir of roots) {
25
+ if (!existsSync(dir)) continue;
25
26
  for (const file of mjsFiles(dir)) {
26
27
  execFileSync(process.execPath, ["--check", file], { stdio: "inherit" });
27
28
  }
package/cli/src/dev.mjs CHANGED
@@ -1,10 +1,17 @@
1
- // muscat-pk dev — run dev:ui and dev:back together.
1
+ // muscat-pk dev — run Socket.IO bridge, dev:ui, and dev:back together.
2
2
  import { runDevUi } from "./devUi.mjs";
3
3
  import { runDevBack } from "./devBack.mjs";
4
+ import { startSocketBridge } from "./socketBridge.mjs";
4
5
  import { log } from "./util.mjs";
5
6
 
6
7
  export async function runDev(args = []) {
7
- log.step("starting dev:ui + dev:back");
8
+ log.step("starting Socket.IO bridge + dev:ui + dev:back");
9
+ const bridge = startSocketBridge();
10
+ const cleanup = () => bridge.close();
11
+ process.once("SIGINT", cleanup);
12
+ process.once("SIGTERM", cleanup);
13
+ process.once("exit", cleanup);
14
+
8
15
  // dev:back runs to completion / detaches on its own; dev:ui owns the process.
9
16
  runDevBack().catch((e) => log.warn(`dev:back: ${e.message}`));
10
17
  await runDevUi(args);
@@ -15,15 +15,18 @@ import {
15
15
  ros2PackageName,
16
16
  } from "./ros2.mjs";
17
17
 
18
- function manifestRuntime(root) {
18
+ function readManifest(root) {
19
19
  try {
20
- const m = JSON.parse(readFileSync(resolve(root, "manifest.json"), "utf8"));
21
- return m.runtime?.type ?? "python";
20
+ return JSON.parse(readFileSync(resolve(root, "manifest.json"), "utf8"));
22
21
  } catch {
23
- return "python";
22
+ return {};
24
23
  }
25
24
  }
26
25
 
26
+ function manifestRuntime(root) {
27
+ return readManifest(root).runtime?.type ?? "python";
28
+ }
29
+
27
30
  // ── python ────────────────────────────────────────────────────────────────────
28
31
  function runPython(root) {
29
32
  const venv = resolve(root, ".venv");
@@ -56,13 +59,20 @@ function runPython(root) {
56
59
  log.step("pip install -q watchfiles (dev reloader)…");
57
60
  spawnSync(py, ["-m", "pip", "install", "-q", "watchfiles"], { cwd: root, stdio: "inherit" });
58
61
 
62
+ const pluginId = readManifest(root).id ?? "";
63
+ const backEnv = { ...process.env, ...(pluginId ? { RB_PLUGIN_ID: pluginId } : {}) };
64
+ if (pluginId) log.step(`RB_PLUGIN_ID=${pluginId}`);
65
+
59
66
  log.step("watchfiles → python main.py (reload on any .py change)");
60
67
  const child = spawn(
61
68
  py,
62
69
  ["-m", "watchfiles", "--filter", "python", "--target-type", "command", "python main.py", "."],
63
- { cwd: root, stdio: "inherit" },
70
+ { cwd: root, stdio: "inherit", env: backEnv },
64
71
  );
72
+ const cleanup = () => { if (!child.killed) child.kill(); };
65
73
  child.on("exit", (code) => process.exit(code ?? 0));
74
+ process.once("SIGINT", cleanup);
75
+ process.once("SIGTERM", cleanup);
66
76
  }
67
77
 
68
78
  // ── ros2 ───────────────────────────────────────────────────────────────────────
@@ -73,7 +83,9 @@ function runRos2(root) {
73
83
 
74
84
  ensureImage();
75
85
 
86
+ const pluginId = readManifest(root).id ?? "";
76
87
  const container = `${ROS2_DEV_IMAGE}-run`;
88
+ const envFlags = pluginId ? ["-e", `RB_PLUGIN_ID=${pluginId}`] : [];
77
89
  const docker = (args, opts = {}) =>
78
90
  spawnSync("docker", args, { cwd: root, stdio: "inherit", ...opts });
79
91
 
@@ -97,7 +109,7 @@ function runRos2(root) {
97
109
  log.step(`ros2 run ${pkg} ${pkg}_node`);
98
110
  child = spawn(
99
111
  "docker",
100
- ["exec", container, "bash", "-lc",
112
+ ["exec", ...envFlags, container, "bash", "-lc",
101
113
  `cd ros2_ws && source install/setup.bash && ros2 run ${pkg} ${pkg}_node`],
102
114
  { cwd: root, stdio: "inherit" },
103
115
  );
@@ -20,20 +20,36 @@ async function buildWithBun(options) {
20
20
  }
21
21
  }
22
22
 
23
+ async function buildWithWasm(options) {
24
+ const wasm = await import("esbuild-wasm");
25
+ return wasm.build(options);
26
+ }
27
+
28
+ function isNativeMissing(err) {
29
+ const msg = String(err?.message || err?.code || err || "");
30
+ return (
31
+ msg.includes("another platform") ||
32
+ msg.includes("Cannot find module") ||
33
+ msg.includes("not found") ||
34
+ msg.includes("No such file") ||
35
+ msg.includes("ENOENT") ||
36
+ msg.includes("MODULE_NOT_FOUND")
37
+ );
38
+ }
39
+
23
40
  export async function buildWithFallback(options) {
24
41
  if (typeof Bun !== "undefined") return buildWithBun(options);
25
42
 
26
- const native = await import("esbuild");
43
+ // Try native esbuild (faster, no extra setup). Falls back to wasm when:
44
+ // - esbuild was not installed (removed from hard dependencies to avoid
45
+ // postinstall script blocking npm i -g on Windows)
46
+ // - binary was built for a different platform
27
47
  try {
48
+ const native = await import("esbuild");
28
49
  return await native.build(options);
29
- } catch (error) {
30
- const message = String(error?.message || error);
31
- if (!message.includes("another platform")) {
32
- throw error;
33
- }
34
-
35
- const wasm = await import("esbuild-wasm");
36
- console.warn("[muscat-pk] native esbuild binary mismatch; falling back to esbuild-wasm");
37
- return wasm.build(options);
50
+ } catch (err) {
51
+ if (!isNativeMissing(err)) throw err;
38
52
  }
53
+
54
+ return buildWithWasm(options);
39
55
  }
@@ -0,0 +1,122 @@
1
+ import { createServer } from "node:http";
2
+ import { randomUUID } from "node:crypto";
3
+ import { Server } from "socket.io";
4
+ import { log } from "./util.mjs";
5
+
6
+ const DEFAULT_ACTION_TIMEOUT_MS = 10_000;
7
+
8
+ export function startSocketBridge({ port = 10000, host = "0.0.0.0" } = {}) {
9
+ const httpServer = createServer();
10
+ const io = new Server(httpServer, {
11
+ cors: { origin: "*" },
12
+ });
13
+ const queryables = new Map();
14
+ const pendingReplies = new Map();
15
+
16
+ function cleanupSocket(socket) {
17
+ for (const [topic, sid] of queryables) {
18
+ if (sid === socket.id) queryables.delete(topic);
19
+ }
20
+
21
+ for (const [id, pending] of pendingReplies) {
22
+ if (pending.sid !== socket.id) continue;
23
+ clearTimeout(pending.timer);
24
+ pending.resolve(undefined);
25
+ pendingReplies.delete(id);
26
+ }
27
+ }
28
+
29
+ io.on("connection", (socket) => {
30
+ socket.on("sub", async (data = {}) => {
31
+ if (!data.topic) return;
32
+ await socket.join(`t:${data.topic}`);
33
+ });
34
+
35
+ socket.on("unsub", async (data = {}) => {
36
+ if (!data.topic) return;
37
+ await socket.leave(`t:${data.topic}`);
38
+ });
39
+
40
+ socket.on("pub", (data = {}) => {
41
+ const topic = data.topic;
42
+ if (!topic) return;
43
+ io.to(`t:${topic}`).emit("msg", {
44
+ topic,
45
+ payload: data.payload,
46
+ });
47
+ });
48
+
49
+ socket.on("serve_register", (data = {}) => {
50
+ if (!data.topic) return;
51
+ queryables.set(data.topic, socket.id);
52
+ });
53
+
54
+ socket.on("serve_unregister", (data = {}) => {
55
+ const topic = data.topic;
56
+ if (!topic) return;
57
+ if (queryables.get(topic) === socket.id) queryables.delete(topic);
58
+ });
59
+
60
+ socket.on("serve_reply", (data = {}) => {
61
+ const pending = pendingReplies.get(data.id);
62
+ if (!pending) return;
63
+ clearTimeout(pending.timer);
64
+ pending.resolve(data.payload);
65
+ pendingReplies.delete(data.id);
66
+ });
67
+
68
+ socket.on("action", (data = {}, ack) => {
69
+ const topic = data.topic;
70
+ const sid = topic ? queryables.get(topic) : undefined;
71
+ const target = sid ? io.sockets.sockets.get(sid) : undefined;
72
+
73
+ if (!target) {
74
+ if (typeof ack === "function") ack(undefined);
75
+ return;
76
+ }
77
+
78
+ const id = randomUUID();
79
+ const result = new Promise((resolve) => {
80
+ const timer = setTimeout(() => {
81
+ pendingReplies.delete(id);
82
+ resolve(undefined);
83
+ }, DEFAULT_ACTION_TIMEOUT_MS);
84
+
85
+ pendingReplies.set(id, {
86
+ sid,
87
+ timer,
88
+ resolve,
89
+ });
90
+ });
91
+
92
+ target.emit("serve_call", {
93
+ id,
94
+ topic,
95
+ payload: data.payload,
96
+ });
97
+
98
+ result.then((payload) => {
99
+ if (typeof ack === "function") ack(payload);
100
+ });
101
+ });
102
+
103
+ socket.on("disconnect", () => cleanupSocket(socket));
104
+ });
105
+
106
+ httpServer.listen(port, host, () => {
107
+ log.step(`Socket.IO bridge listening on ws://127.0.0.1:${port}`);
108
+ });
109
+
110
+ const close = () => {
111
+ for (const pending of pendingReplies.values()) {
112
+ clearTimeout(pending.timer);
113
+ pending.resolve(undefined);
114
+ }
115
+ pendingReplies.clear();
116
+ queryables.clear();
117
+ io.close();
118
+ httpServer.close();
119
+ };
120
+
121
+ return { close };
122
+ }
@@ -99,36 +99,50 @@ connect(App);
99
99
 
100
100
  ## Backend (`main.py`)
101
101
 
102
- The backend communicates with the frontend using `rainbow_rb_zenoh`.
103
- Set the `prefix` to `plugin/{manifest.id}` to match the frontend's auto-namespace.
102
+ The backend communicates with the frontend using `rainbow.rb_sdk.RBPluginCall`.
103
+ Relative topics and plugin context are handled by the SDK.
104
+ `muscat-pk dev` starts the local Socket.IO bridge separately; `main.py`
105
+ should contain plugin logic only and should not start its own server.
104
106
 
105
107
  ### Python plugin
106
108
 
107
109
  ```python
108
- import asyncio
109
- from rainbow_rb_zenoh import ZenohRouter, ZenohClient
110
+ import time
111
+ from rainbow.rb_sdk import RBAmrSDK, RBManipulateSDK, RBPluginCall
110
112
 
111
- router = ZenohRouter(prefix="plugin/__PLUGIN_ID__")
112
- client = ZenohClient()
113
+ plugin = RBPluginCall()
113
114
 
114
115
 
115
- @router.subscribe("sensor/data")
116
- async def on_sensor(*, dict_payload, **_):
117
- print("received:", dict_payload)
116
+ plugin.publish("state", {"ready": True})
118
117
 
119
118
 
120
- @router.queryable("get-status")
121
- async def get_status(req=None, **_):
122
- return {"ok": True}
119
+ @plugin.subscribe("event")
120
+ def on_ui_event(payload=None):
121
+ print(payload)
123
122
 
124
123
 
125
- async def main():
126
- await router.startup()
127
- await asyncio.Event().wait() # keep alive
124
+ @plugin.serve("tool/echo")
125
+ def echo(payload=None):
126
+ return {"echo": payload}
127
+
128
+
129
+ def call_frontend():
130
+ res = plugin.call("tool/ping", payload={"hello": "plugin"})
131
+ print(res)
132
+
133
+
134
+ def move_j_example():
135
+ amr = RBAmrSDK()
136
+ manipulate = RBManipulateSDK()
137
+ return manipulate.move.call_move_j(
138
+ robot_model="C500920",
139
+ target={"tar_values": [0, 0, 0, 0, 0, 0], "tar_frame": 0, "tar_unit": 0},
140
+ )
128
141
 
129
142
 
130
143
  if __name__ == "__main__":
131
- asyncio.run(main())
144
+ while True:
145
+ time.sleep(1)
132
146
  ```
133
147
 
134
148
  ### ROS2 plugin
@@ -1,114 +1,88 @@
1
1
  """
2
2
  Muscat plugin backend — __PLUGIN_NAME__
3
3
 
4
- 플러그인 UI(브라우저)와 Socket.IO로 통신한다.
5
- `muscat-pk dev:back` 또는 `python main.py`로 실행.
4
+ Run with:
5
+ muscat-pk dev:back
6
+ python main.py
6
7
 
7
- 토픽 자동 네임스페이스:
8
- "sensor/data" → plugin/__PLUGIN_ID__/sensor/data (자동)
9
- "/broadcast/x" → broadcast/x (앞 / = 절대경로)
8
+ This template uses the high-level Rainbow Python SDK instead of wiring the
9
+ transport layer directly.
10
10
  """
11
11
 
12
- import asyncio
13
- import socketio
14
- from aiohttp import web
12
+ import time
13
+ from typing import Any
15
14
 
16
- sio = socketio.AsyncServer(async_mode="aiohttp", cors_allowed_origins="*")
17
- app = web.Application()
18
- sio.attach(app)
15
+ from rainbow.rb_sdk import RBManipulateSDK, RBPluginCall
19
16
 
20
- PLUGIN_ID = "__PLUGIN_ID__"
21
- _PREFIX = f"plugin/{PLUGIN_ID}"
22
17
 
18
+ PLUGIN_NAME = "__PLUGIN_NAME__"
23
19
 
24
- def _topic(relative: str) -> str:
25
- """상대 토픽 → 전체 네임스페이스 토픽."""
26
- return relative if relative.startswith("/") else f"{_PREFIX}/{relative}"
20
+ plugin = RBPluginCall()
27
21
 
28
22
 
29
- # ── 구독·쿼리어블 핸들러 저장소 ────────────────────────────────────────────────
30
- _sub_handlers: dict[str, list] = {}
31
- _queryable_handlers: dict[str, object] = {}
32
- _serve_pending: dict[str, asyncio.Future] = {}
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)
33
27
 
34
28
 
35
- # ── Socket.IO 이벤트 ───────────────────────────────────────────────────────────
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}")
36
34
 
37
- @sio.event
38
- async def sub(sid, data):
39
- """브라우저가 토픽을 구독."""
40
- await sio.enter_room(sid, f"t:{data['topic']}")
35
+ plugin.publish("state", {"ready": True, "last_grip_width": width})
36
+ return {"ok": True, "width": width}
41
37
 
42
38
 
43
- @sio.event
44
- async def unsub(sid, data):
45
- await sio.leave_room(sid, f"t:{data['topic']}")
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}
46
43
 
47
44
 
48
- @sio.event
49
- async def pub(sid, data):
50
- """브라우저가 발행 → Python 구독 핸들러로 전달."""
51
- topic = data.get("topic", "")
52
- payload = data.get("payload")
53
- for handler in _sub_handlers.get(topic, []):
54
- asyncio.create_task(handler(payload))
45
+ def ping_ui_example():
46
+ """
47
+ Example: call a queryable served by the UI.
55
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
56
55
 
57
- @sio.event
58
- async def action(sid, data):
59
- """브라우저 rb.action() 호출 → Python queryable 핸들러 → ack 반환."""
60
- topic = data.get("topic", "")
61
- handler = _queryable_handlers.get(topic)
62
- if handler:
63
- return await handler(data.get("payload"))
64
56
 
57
+ def move_j_example():
58
+ """
59
+ Example: use other robot SDKs from the same backend.
65
60
 
66
- @sio.event
67
- async def serve_reply(sid, data):
68
- """브라우저가 serve_call에 응답."""
69
- fut = _serve_pending.pop(data.get("id", ""), None)
70
- if fut and not fut.done():
71
- fut.set_result(data.get("payload"))
61
+ This function is not called automatically. Add safety checks and call it
62
+ from your own handler when the robot is ready.
63
+ """
64
+ manipulate = RBManipulateSDK()
72
65
 
66
+ return manipulate.move.call_move_j(
67
+ robot_model="C500920",
68
+ target={
69
+ "tar_values": [0, 0, 0, 0, 0, 0],
70
+ "tar_frame": 0,
71
+ "tar_unit": 0,
72
+ },
73
+ )
73
74
 
74
- # ── 헬퍼 데코레이터 ────────────────────────────────────────────────────────────
75
75
 
76
- def subscribe(relative_topic: str):
77
- """브라우저 → Python 메시지 구독 핸들러 등록."""
78
- def decorator(fn):
79
- _sub_handlers.setdefault(_topic(relative_topic), []).append(fn)
80
- return fn
81
- return decorator
76
+ def main():
77
+ plugin.publish("state", {"ready": True, "plugin": PLUGIN_NAME})
78
+ print(f"[{PLUGIN_NAME}] backend ready")
82
79
 
80
+ try:
81
+ while True:
82
+ time.sleep(1)
83
+ except KeyboardInterrupt:
84
+ print(f"[{PLUGIN_NAME}] stopped")
83
85
 
84
- def queryable(relative_topic: str):
85
- """브라우저 rb.action() 호출을 처리할 핸들러 등록."""
86
- def decorator(fn):
87
- _queryable_handlers[_topic(relative_topic)] = fn
88
- return fn
89
- return decorator
90
-
91
-
92
- async def publish(relative_topic: str, payload) -> None:
93
- """Python → 브라우저 구독자에게 메시지 발행."""
94
- full = _topic(relative_topic)
95
- await sio.emit("msg", {"topic": full, "payload": payload}, room=f"t:{full}")
96
-
97
-
98
- # ── 플러그인 로직 ──────────────────────────────────────────────────────────────
99
-
100
- @subscribe("sensor/data")
101
- async def on_sensor_data(payload):
102
- print(f"[__PLUGIN_NAME__] sensor/data: {payload}")
103
-
104
-
105
- @queryable("get-status")
106
- async def get_status(payload=None):
107
- return {"ok": True, "plugin": "__PLUGIN_NAME__"}
108
-
109
-
110
- # ── 진입점 ─────────────────────────────────────────────────────────────────────
111
86
 
112
87
  if __name__ == "__main__":
113
- print("[__PLUGIN_NAME__] Socket.IO server starting on :10000")
114
- web.run_app(app, host="0.0.0.0", port=10000)
88
+ main()
@@ -1,6 +1,6 @@
1
1
  import rclpy
2
2
  from rclpy.node import Node
3
- from rb_sdk import RBManipulateSDK # PyPI: rainbow-rb-sdk (installed from requirements.txt)
3
+ from rainbow.rb_sdk import RBManipulateSDK # PyPI: rainbow-rb-sdk
4
4
 
5
5
 
6
6
  sdk = RBManipulateSDK()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rainbow-robotics/muscat-plugin-kit",
3
- "version": "0.1.2-dev.11",
3
+ "version": "0.1.2-dev.13",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=20"
@@ -31,8 +31,8 @@
31
31
  "@types/react-dom": "^19.0.4"
32
32
  },
33
33
  "dependencies": {
34
- "@rainbow-robotics/preview-host": "0.1.2-dev.11",
35
- "esbuild": "^0.25.0"
34
+ "@rainbow-robotics/preview-host": "0.1.2-dev.13",
35
+ "socket.io": "^4.8.1"
36
36
  },
37
37
  "optionalDependencies": {
38
38
  "esbuild-wasm": "^0.25.0"