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

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,37 @@ 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("Cannot find package") ||
34
+ msg.includes("not found") ||
35
+ msg.includes("No such file") ||
36
+ msg.includes("ENOENT") ||
37
+ msg.includes("MODULE_NOT_FOUND")
38
+ );
39
+ }
40
+
23
41
  export async function buildWithFallback(options) {
24
42
  if (typeof Bun !== "undefined") return buildWithBun(options);
25
43
 
26
- const native = await import("esbuild");
44
+ // Try native esbuild (faster, no extra setup). Falls back to wasm when:
45
+ // - esbuild was not installed (removed from hard dependencies to avoid
46
+ // postinstall script blocking npm i -g on Windows)
47
+ // - binary was built for a different platform
27
48
  try {
49
+ const native = await import("esbuild");
28
50
  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);
51
+ } catch (err) {
52
+ if (!isNativeMissing(err)) throw err;
38
53
  }
54
+
55
+ return buildWithWasm(options);
39
56
  }
@@ -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
+ }
@@ -1,52 +1,170 @@
1
- // muscat-pk update — update the JS plugin kit and host-provided UI contract.
2
- import { execSync } from "node:child_process";
3
- import { existsSync, readFileSync } from "node:fs";
4
- import { resolve } from "node:path";
5
- import { findPluginRoot, KIT_PUBLISHED_NAME, log, parseArgs } from "./util.mjs";
1
+ // muscat-pk update — update the CLI itself AND the plugin project's JS deps.
2
+ //
3
+ // Three install modes are handled:
4
+ // binary : Bun self-contained binary downloaded from S3
5
+ // → re-download from S3, replace process.execPath
6
+ // npm -g : globally installed Node.js package
7
+ // → npm install -g @rainbow-robotics/muscat-plugin-kit@<tag>
8
+ // local : plugin project's node_modules (fallback)
9
+ // → <pm> add @rainbow-robotics/muscat-plugin-kit@<tag>
10
+ //
11
+ // After updating the CLI itself, also bumps the plugin project's local deps.
12
+ import { execSync, spawn } from "node:child_process";
13
+ import { chmodSync, copyFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
14
+ import { tmpdir } from "node:os";
15
+ import { join, resolve } from "node:path";
16
+ import { findPluginRoot, KIT_PUBLISHED_NAME, kitVersion, log, parseArgs } from "./util.mjs";
6
17
 
7
18
  const PLUGIN_UI_PACKAGE = "@rainbow-robotics/plugin-ui";
19
+ const S3_BASE = "https://rainbow-deploy.s3.ap-northeast-2.amazonaws.com/muscat-plugin-kit";
20
+
21
+ const isBinaryMode = typeof Bun !== "undefined";
22
+
23
+ // ── helpers ───────────────────────────────────────────────────────────────────
8
24
 
9
25
  function detectPm(root) {
10
26
  try {
11
27
  const pkg = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8"));
12
28
  if (pkg.packageManager) return pkg.packageManager.split("@")[0];
13
- } catch {
14
- /* ignore */
15
- }
29
+ } catch { /* ignore */ }
16
30
  if (existsSync(resolve(root, "pnpm-lock.yaml"))) return "pnpm";
17
31
  if (existsSync(resolve(root, "yarn.lock"))) return "yarn";
18
32
  if (existsSync(resolve(root, "bun.lockb"))) return "bun";
19
33
  return "npm";
20
34
  }
21
35
 
22
- export async function runUpdate(args = []) {
23
- const root = findPluginRoot();
24
- const pm = detectPm(root);
25
- const opts = parseArgs(args);
36
+ async function fetchText(url) {
37
+ const res = await fetch(url);
38
+ if (!res.ok) throw new Error(`HTTP ${res.status}: ${url}`);
39
+ return (await res.text()).trim();
40
+ }
41
+
42
+ async function fetchBinary(url) {
43
+ const res = await fetch(url);
44
+ if (!res.ok) throw new Error(`HTTP ${res.status}: ${url}`);
45
+ return Buffer.from(await res.arrayBuffer());
46
+ }
47
+
48
+ function s3BinaryName() {
49
+ const platform = process.platform;
50
+ const arch = process.arch;
51
+ const plat = platform === "win32" ? "win" : platform === "darwin" ? "darwin" : "linux";
52
+ const cpu = arch === "arm64" ? "arm64" : "x64";
53
+ const ext = platform === "win32" ? ".exe" : "";
54
+ return `muscat-pk-${plat}-${cpu}${ext}`;
55
+ }
56
+
57
+ async function resolveS3Version(tag) {
58
+ const pointer = tag === "dev" ? "dev-version.txt" : "latest-version.txt";
59
+ return fetchText(`${S3_BASE}/${pointer}`);
60
+ }
61
+
62
+ // ── self-update: binary ───────────────────────────────────────────────────────
63
+
64
+ async function updateBinary(tag) {
65
+ const version = await resolveS3Version(tag);
66
+ const current = kitVersion();
67
+ if (version === current) {
68
+ log.ok(`이미 최신 버전입니다 (v${current})`);
69
+ return;
70
+ }
71
+
72
+ const binaryName = s3BinaryName();
73
+ const url = `${S3_BASE}/v${version}/${binaryName}`;
74
+ log.step(`바이너리 다운로드: v${current} → v${version}`);
75
+
76
+ const data = await fetchBinary(url);
77
+ const execPath = process.execPath;
78
+
79
+ if (process.platform === "win32") {
80
+ // Windows: cannot overwrite a running .exe — write .new then schedule replacement
81
+ const newPath = execPath + ".new";
82
+ writeFileSync(newPath, data);
26
83
 
27
- const range =
28
- opts._[0] ||
29
- opts.version ||
30
- opts["muscat-plugin-kit"] ||
31
- opts["plugin-ui"] ||
32
- "latest";
33
- const list = [
34
- `${KIT_PUBLISHED_NAME}@${range}`,
35
- `${PLUGIN_UI_PACKAGE}@${range}`,
36
- ];
84
+ // PowerShell snippet that waits for this process to exit, then renames
85
+ const ps = `Start-Sleep -Milliseconds 500; Move-Item -Force '${newPath}' '${execPath}'`;
86
+ spawn("powershell", ["-NoProfile", "-Command", ps], {
87
+ detached: true,
88
+ stdio: "ignore",
89
+ }).unref();
37
90
 
91
+ log.ok(`v${version} 다운로드 완료. 다음 실행 시 자동 적용됩니다.`);
92
+ } else {
93
+ // macOS / Linux: can replace the file while it's mapped into memory
94
+ const tmp = join(tmpdir(), `muscat-pk-${version}`);
95
+ writeFileSync(tmp, data);
96
+ chmodSync(tmp, 0o755);
97
+ copyFileSync(tmp, execPath);
98
+ log.ok(`v${version} 업데이트 완료.`);
99
+ }
100
+ }
101
+
102
+ // ── self-update: npm global ───────────────────────────────────────────────────
103
+
104
+ function updateNpmGlobal(tag) {
105
+ log.step(`npm install -g ${KIT_PUBLISHED_NAME}@${tag}`);
106
+ execSync(`npm install -g ${KIT_PUBLISHED_NAME}@${tag}`, { stdio: "inherit" });
107
+ log.ok("글로벌 CLI 업데이트 완료.");
108
+ }
109
+
110
+ // ── plugin project local deps ─────────────────────────────────────────────────
111
+
112
+ function updateLocalDeps(root, tag) {
113
+ const pm = detectPm(root);
114
+ const list = [`${KIT_PUBLISHED_NAME}@${tag}`, `${PLUGIN_UI_PACKAGE}@${tag}`];
38
115
  const cmd =
39
- pm === "npm"
40
- ? `npm install ${list.join(" ")}`
41
- : pm === "yarn"
42
- ? `yarn add ${list.join(" ")}`
43
- : `${pm} add ${list.join(" ")}`;
116
+ pm === "npm" ? `npm install ${list.join(" ")}`
117
+ : pm === "yarn" ? `yarn add ${list.join(" ")}`
118
+ : `${pm} add ${list.join(" ")}`;
44
119
 
45
- log.step(`${pm}: ${list.join(", ")}`);
120
+ log.step(`플러그인 로컬 deps 업데이트 (${pm}): ${list.join(", ")}`);
46
121
  try {
47
122
  execSync(cmd, { cwd: root, stdio: "inherit" });
48
- log.ok("kit packages updated");
123
+ log.ok("로컬 deps 업데이트 완료.");
124
+ } catch {
125
+ log.warn("로컬 deps 업데이트 실패 (오프라인?). 수동 실행:\n " + cmd);
126
+ }
127
+ }
128
+
129
+ // ── main ─────────────────────────────────────────────────────────────────────
130
+
131
+ export async function runUpdate(args = []) {
132
+ const opts = parseArgs(args);
133
+ const tag = opts._[0] || opts.version || opts.tag || "latest";
134
+
135
+ // 1. Update the CLI itself
136
+ if (isBinaryMode) {
137
+ log.step("바이너리 자기 업데이트 중…");
138
+ try {
139
+ await updateBinary(tag);
140
+ } catch (err) {
141
+ log.warn(`바이너리 업데이트 실패: ${err.message}`);
142
+ }
143
+ } else {
144
+ // Check if globally installed via npm (execPath's npm prefix contains this package)
145
+ const isGlobal = (() => {
146
+ try {
147
+ const prefix = execSync("npm prefix -g", { encoding: "utf8" }).trim();
148
+ const globalBin = join(prefix, "bin", "muscat-pk");
149
+ const globalBinCmd = join(prefix, "bin", "muscat-pk.cmd");
150
+ return existsSync(globalBin) || existsSync(globalBinCmd);
151
+ } catch { return false; }
152
+ })();
153
+
154
+ if (isGlobal) {
155
+ try {
156
+ updateNpmGlobal(tag);
157
+ } catch (err) {
158
+ log.warn(`글로벌 CLI 업데이트 실패: ${err.message}`);
159
+ }
160
+ }
161
+ }
162
+
163
+ // 2. Update the plugin project's local deps (best-effort)
164
+ try {
165
+ const root = findPluginRoot();
166
+ updateLocalDeps(root, tag);
49
167
  } catch {
50
- log.warn("update failed (offline?). Run manually:\n " + cmd);
168
+ // Not inside a plugin project — skip local update silently
51
169
  }
52
170
  }
@@ -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.14",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=20"
@@ -31,10 +31,10 @@
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": "workspace:*",
35
+ "socket.io": "^4.8.1"
36
36
  },
37
37
  "optionalDependencies": {
38
38
  "esbuild-wasm": "^0.25.0"
39
39
  }
40
- }
40
+ }