@rainbow-robotics/muscat-plugin-kit 0.1.2-dev.5 → 0.1.2-dev.6

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.
@@ -10,6 +10,4 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
10
10
 
11
11
  WORKDIR /plugin
12
12
 
13
- # requirements are installed at run time from the mounted volume so version
14
- # bumps don't require an image rebuild.
15
13
  CMD ["bash"]
package/cli/src/build.mjs CHANGED
@@ -10,9 +10,16 @@
10
10
  // map, so plugin ZIPs do not carry the UI runtime.
11
11
  import { cpSync, copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
12
12
  import { resolve } from "node:path";
13
+ import { spawnSync } from "node:child_process";
13
14
  import { buildWithFallback } from "./esbuild.mjs";
14
15
  import { findPluginRoot, log } from "./util.mjs";
15
16
 
17
+ function isMainBranch() {
18
+ const r = spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { encoding: "utf8" });
19
+ const branch = r.stdout?.trim();
20
+ return branch === "main" || branch === "master";
21
+ }
22
+
16
23
  function manifestRuntime(root) {
17
24
  try {
18
25
  return JSON.parse(readFileSync(resolve(root, "manifest.json"), "utf8")).runtime?.type ?? "python";
@@ -38,20 +45,44 @@ export async function exportRos2Build(root) {
38
45
  log.ok("ros2_ws/install ready (will be included in the ZIP)");
39
46
  }
40
47
 
41
- // Resolve the latest released version of a PyPI package.
42
- async function latestPypiVersion(pkg) {
48
+ const PRERELEASE_RE = /\.(dev|a|b|rc|alpha|beta)\d*/i;
49
+
50
+ function isPrerelease(version) {
51
+ return PRERELEASE_RE.test(version);
52
+ }
53
+
54
+ // Resolve the latest version of a PyPI package.
55
+ // stableOnly=true → skips pre-release (dev/a/b/rc) versions.
56
+ async function latestPypiVersion(pkg, { stableOnly = false } = {}) {
43
57
  const res = await fetch(`https://pypi.org/pypi/${pkg}/json`, {
44
58
  signal: AbortSignal.timeout(5000),
45
59
  });
46
60
  if (!res.ok) throw new Error(`pypi ${pkg}: ${res.status}`);
47
61
  const data = await res.json();
62
+
63
+ if (!stableOnly) return data.info.version;
64
+
65
+ // data.info.version is latest stable when stable releases exist.
66
+ // If it's still a pre-release, scan all releases for the newest stable.
67
+ if (!isPrerelease(data.info.version)) return data.info.version;
68
+
69
+ const stable = Object.keys(data.releases)
70
+ .filter((v) => !isPrerelease(v))
71
+ .sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))
72
+ .at(-1);
73
+
74
+ if (stable) return stable;
75
+
76
+ // No stable release exists — fall back with a warning.
77
+ log.warn(` ${pkg}: no stable release found, using ${data.info.version}`);
48
78
  return data.info.version;
49
79
  }
50
80
 
51
81
  // Pin unpinned (no version specifier) entries in requirements.txt to the
52
82
  // latest PyPI release. Runs on explicit `build`/`pack` only — keeps the shipped
53
83
  // plugin reproducible without churning the source file on every dev rebuild.
54
- export async function pinRequirements(root) {
84
+ // stableOnly=true is used on main-branch builds to avoid shipping dev versions.
85
+ export async function pinRequirements(root, { stableOnly = false } = {}) {
55
86
  const reqPath = resolve(root, "requirements.txt");
56
87
  if (!existsSync(reqPath)) return;
57
88
 
@@ -66,7 +97,7 @@ export async function pinRequirements(root) {
66
97
  continue;
67
98
  }
68
99
  try {
69
- const v = await latestPypiVersion(name);
100
+ const v = await latestPypiVersion(name, { stableOnly });
70
101
  out.push(`${name}==${v}`);
71
102
  pinned++;
72
103
  log.info(` pinned ${name}==${v}`);
@@ -158,8 +189,9 @@ export async function runBuild(args) {
158
189
  const minify = args.includes("--minify");
159
190
  const root = findPluginRoot();
160
191
  if (!args.includes("--no-pin")) {
161
- log.step("pinning requirements.txt to latest PyPI versions");
162
- await pinRequirements(root);
192
+ const stableOnly = isMainBranch();
193
+ log.step(`pinning requirements.txt to latest ${stableOnly ? "stable " : ""}PyPI versions`);
194
+ await pinRequirements(root, { stableOnly });
163
195
  }
164
196
  await buildPlugin({ root, minify });
165
197
  await exportRos2Build(root);
@@ -7,7 +7,7 @@
7
7
  import { spawn, spawnSync } from "node:child_process";
8
8
  import { existsSync, readFileSync, watch } from "node:fs";
9
9
  import { resolve } from "node:path";
10
- import { detectPythonCmd, findPluginRoot, log, venvPython } from "./util.mjs";
10
+ import { detectPythonCmd, hasBin, findPluginRoot, log, venvPython } from "./util.mjs";
11
11
  import {
12
12
  ROS2_DEV_IMAGE,
13
13
  assertDockerReady,
@@ -30,10 +30,18 @@ function runPython(root) {
30
30
  const py = venvPython(venv);
31
31
 
32
32
  if (!existsSync(py)) {
33
- const { cmd, is312 } = detectPythonCmd();
34
- if (!is312) log.warn("python3.12 not found; using fallback (robot targets 3.12).");
35
33
  log.step("creating .venv…");
36
- spawnSync(cmd[0], [...cmd.slice(1), "-m", "venv", venv], { cwd: root, stdio: "inherit" });
34
+ if (hasBin("uv")) {
35
+ spawnSync("uv", ["venv", venv, "--python", "3.12", "--seed"], { cwd: root, stdio: "inherit" });
36
+ } else if (hasBin("conda")) {
37
+ spawnSync("conda", ["create", "-p", venv, "python=3.12", "-y", "--quiet"], { cwd: root, stdio: "inherit" });
38
+ } else if (hasBin("python3.12") || hasBin("python3") || hasBin("python")) {
39
+ const { cmd, is312 } = detectPythonCmd();
40
+ if (!is312) log.warn("python3.12 not found; using fallback (robot targets 3.12).");
41
+ spawnSync(cmd[0], [...cmd.slice(1), "-m", "venv", venv], { cwd: root, stdio: "inherit" });
42
+ } else {
43
+ throw new Error("Python not found (uv / conda / python3 none available). Install Python 3.12 to run the backend.");
44
+ }
37
45
  }
38
46
 
39
47
  const reqs = resolve(root, "requirements.txt");
package/cli/src/devUi.mjs CHANGED
@@ -1,22 +1,56 @@
1
1
  // muscat-pk dev:ui — preview the plugin inside a mini-Muscat (preview-host).
2
- import { spawn } from "node:child_process";
2
+ import { spawn, spawnSync } from "node:child_process";
3
3
  import { createRequire } from "node:module";
4
- import { existsSync } from "node:fs";
4
+ import { existsSync, mkdirSync } from "node:fs";
5
+ import { homedir } from "node:os";
5
6
  import { dirname, resolve } from "node:path";
6
7
  import { buildPlugin } from "./build.mjs";
7
- import { checkKitUpdate, findPluginRoot, kitRoot, log } from "./util.mjs";
8
+ import { checkKitUpdate, findPluginRoot, hasBin, kitRoot, log } from "./util.mjs";
8
9
 
9
- /** Resolve @rainbow-robotics/preview-host package root.
10
- * Works in monorepo (yarn workspace hoisting) and when both packages are
11
- * installed as deps of the plugin project. */
10
+ const PREVIEW_PKG = "@rainbow-robotics/preview-host";
11
+ const CACHE_DIR = resolve(homedir(), ".muscat-pk");
12
+ const CACHE_ROOT = resolve(CACHE_DIR, "node_modules", "@rainbow-robotics", "preview-host");
13
+
14
+ /**
15
+ * Resolve preview-host root in order:
16
+ * 1. Standard require (npm/yarn global install)
17
+ * 2. Monorepo sibling (dev workspace)
18
+ * 3. ~/.muscat-pk cache (previous auto-install)
19
+ * 4. Auto-install to ~/.muscat-pk via npm (binary users who have Node.js)
20
+ */
12
21
  function findPreviewHostRoot() {
22
+ // 1. npm/yarn 설치 경로
13
23
  try {
14
24
  const req = createRequire(import.meta.url);
15
- return dirname(req.resolve("@rainbow-robotics/preview-host/package.json"));
16
- } catch {
17
- // fallback: sibling package in muscat-plugin-kit/ workspace
18
- return resolve(kitRoot, "../preview-host");
25
+ return dirname(req.resolve(`${PREVIEW_PKG}/package.json`));
26
+ } catch {}
27
+
28
+ // 2. 모노레포 sibling
29
+ const sibling = resolve(kitRoot, "../preview-host");
30
+ if (existsSync(resolve(sibling, "package.json"))) return sibling;
31
+
32
+ // 3. 캐시
33
+ if (existsSync(resolve(CACHE_ROOT, "package.json"))) return CACHE_ROOT;
34
+
35
+ // 4. npm 자동 설치 → 캐시
36
+ if (!hasBin("npm")) {
37
+ throw new Error(
38
+ `preview-host를 찾을 수 없습니다.\n` +
39
+ `Node.js 20+ 를 설치하면 자동으로 준비됩니다: https://nodejs.org`,
40
+ );
41
+ }
42
+ log.step(`preview-host 설치 중 (~/.muscat-pk) — 첫 실행 시 한 번만 수행됩니다…`);
43
+ mkdirSync(CACHE_DIR, { recursive: true });
44
+ const r = spawnSync(
45
+ "npm",
46
+ ["install", "--prefix", CACHE_DIR, "--save", PREVIEW_PKG],
47
+ { stdio: "inherit" },
48
+ );
49
+ if (r.status !== 0) throw new Error("preview-host 설치 실패.");
50
+ if (!existsSync(resolve(CACHE_ROOT, "package.json"))) {
51
+ throw new Error("preview-host 설치 후에도 패키지를 찾을 수 없습니다.");
19
52
  }
53
+ return CACHE_ROOT;
20
54
  }
21
55
 
22
56
  export async function runDevUi(args = []) {
@@ -31,7 +65,7 @@ export async function runDevUi(args = []) {
31
65
  if (!existsSync(resolve(previewRoot, "dist", "index.html"))) {
32
66
  log.step("building preview-host bundle (first run)…");
33
67
  await new Promise((res, rej) => {
34
- const p = spawn("node", [resolve(previewRoot, "build.mjs")], {
68
+ const p = spawn(process.execPath, [resolve(previewRoot, "build.mjs")], {
35
69
  stdio: "inherit",
36
70
  });
37
71
  p.on("exit", (c) => (c === 0 ? res() : rej(new Error("preview-host build failed"))));
@@ -40,7 +74,7 @@ export async function runDevUi(args = []) {
40
74
 
41
75
  log.step(`starting preview-host on :${port}`);
42
76
  const child = spawn(
43
- "node",
77
+ process.execPath,
44
78
  [serveEntry, "--plugin", distDir, "--port", String(port)],
45
79
  { stdio: "inherit" },
46
80
  );
package/cli/src/new.mjs CHANGED
@@ -12,7 +12,7 @@ import {
12
12
  } from "node:fs";
13
13
  import { createInterface } from "node:readline/promises";
14
14
  import { basename, resolve } from "node:path";
15
- import { detectPythonCmd, parseArgs, templatesDir, log, venvPython } from "./util.mjs";
15
+ import { detectPythonCmd, hasBin, parseArgs, templatesDir, log, venvPython } from "./util.mjs";
16
16
 
17
17
  const TYPES = ["python", "ros2"];
18
18
  const PMS = ["npm", "yarn", "pnpm", "bun"];
@@ -51,6 +51,7 @@ export function deriveNames(target) {
51
51
  const slug = base.toLowerCase().replace(/[^a-z0-9]+/g, "_");
52
52
  const pluginName = base.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
53
53
  return {
54
+ dir: base,
54
55
  slug,
55
56
  pluginName,
56
57
  pluginClass: pluginName.replace(/\s+/g, ""),
@@ -112,7 +113,7 @@ export async function runNew(args) {
112
113
  }
113
114
  pm = PMS.includes(pm) ? pm : "npm";
114
115
 
115
- const { slug, pluginName, pluginClass, pluginId } = deriveNames(target);
116
+ const { dir, slug, pluginName, pluginClass, pluginId } = deriveNames(target);
116
117
 
117
118
  mkdirSync(target, { recursive: true });
118
119
 
@@ -125,6 +126,7 @@ export async function runNew(args) {
125
126
  applyTokens(target, {
126
127
  __PLUGIN_ID__: pluginId,
127
128
  __PLUGIN_NAME__: pluginName,
129
+ __PLUGIN_DIR__: dir,
128
130
  __PLUGIN_SLUG__: slug,
129
131
  __PLUGIN_CLASS__: pluginClass,
130
132
  __PM__: pm,
@@ -144,28 +146,62 @@ export async function runNew(args) {
144
146
  } catch {
145
147
  log.warn(`'${pm} install' failed; run it manually in ${targetName}`);
146
148
  }
149
+ }
150
+
151
+ let noPython = false;
152
+ let venvFailed = false;
147
153
 
154
+ if (!opts["no-install"]) {
148
155
  // Python venv (robot targets 3.12) — cross-platform.
156
+ // uv is preferred: handles uv-managed Pythons that break standard ensurepip.
149
157
  try {
150
- const { cmd, is312 } = detectPythonCmd();
151
- if (!is312) log.warn("python3.12 not found; venv uses fallback python (robot targets 3.12).");
152
- log.step("creating .venv + installing requirements…");
153
158
  const venv = resolve(target, ".venv");
154
- execFileSync(cmd[0], [...cmd.slice(1), "-m", "venv", venv], { cwd: target, stdio: "inherit" });
155
- const py = venvPython(venv);
156
- if (existsSync(resolve(target, "requirements.txt"))) {
157
- execFileSync(py, ["-m", "pip", "install", "-q", "-r", "requirements.txt"], {
158
- cwd: target,
159
- stdio: "inherit",
160
- });
159
+ log.step("creating .venv + installing requirements…");
160
+ if (hasBin("uv")) {
161
+ execFileSync("uv", ["venv", venv, "--python", "3.12", "--seed"], { cwd: target, stdio: "inherit" });
162
+ } else if (hasBin("conda")) {
163
+ execFileSync("conda", ["create", "-p", venv, "python=3.12", "-y", "--quiet"], { cwd: target, stdio: "inherit" });
164
+ } else if (hasBin("python3.12") || hasBin("python3") || hasBin("python")) {
165
+ const { cmd, is312 } = detectPythonCmd();
166
+ if (!is312) log.warn("python3.12 not found; venv uses fallback python (robot targets 3.12).");
167
+ execFileSync(cmd[0], [...cmd.slice(1), "-m", "venv", venv], { cwd: target, stdio: "inherit" });
168
+ } else {
169
+ noPython = true;
170
+ }
171
+ if (!noPython) {
172
+ const py = venvPython(venv);
173
+ if (existsSync(resolve(target, "requirements.txt"))) {
174
+ execFileSync(py, ["-m", "pip", "install", "-q", "-r", "requirements.txt"], { cwd: target, stdio: "inherit" });
175
+ }
161
176
  }
162
177
  } catch {
163
- log.warn("venv setup failed; create it manually: python3.12 -m venv .venv");
178
+ venvFailed = true;
164
179
  }
180
+ } else {
181
+ noPython = !hasBin("uv") && !hasBin("conda") && !hasBin("python3.12") && !hasBin("python3") && !hasBin("python");
165
182
  }
166
183
 
167
184
  log.ok(`created ${targetName} (type: ${type}, pm: ${pm})`);
168
185
  log.info(`\n cd ${targetName}`);
169
- log.info(` ${pm} run dev # preview in mini-Muscat + back end`);
170
- log.info(` ${pm} run pack # build a distributable ZIP\n`);
186
+ log.info(` muscat-pk dev # preview in mini-Muscat + back end`);
187
+ log.info(` muscat-pk pack # build a distributable ZIP\n`);
188
+
189
+ // ── post-scaffold environment warnings ───────────────────────────────────
190
+ const noNode = !hasBin("node");
191
+ if (noNode || noPython || venvFailed) {
192
+ log.info("");
193
+ if (noNode) {
194
+ log.warn("Node.js not found. Install Node.js 20+ to run JS deps and dev:ui.");
195
+ log.warn(" https://nodejs.org");
196
+ }
197
+ if (noPython) {
198
+ log.warn("Python not found. Install Python 3.12 to run the backend.");
199
+ log.warn(" https://www.python.org/downloads/ (or: brew install python@3.12 / uv / conda)");
200
+ log.warn(` Then: cd ${targetName} && python3.12 -m venv .venv && pip install -r requirements.txt`);
201
+ } else if (venvFailed) {
202
+ log.warn("venv setup failed. Create it manually:");
203
+ log.warn(` cd ${targetName} && python3.12 -m venv .venv && pip install -r requirements.txt`);
204
+ }
205
+ log.info("");
206
+ }
171
207
  }
package/cli/src/util.mjs CHANGED
@@ -79,11 +79,14 @@ export function findPluginRoot(start = process.cwd()) {
79
79
  );
80
80
  }
81
81
 
82
+ const ORANGE = "\x1b[33m";
83
+ const RESET = "\x1b[0m";
84
+
82
85
  export const log = {
83
86
  info: (...m) => console.log(...m),
84
87
  step: (...m) => console.log("›", ...m),
85
- ok: (...m) => console.log("✓", ...m),
86
- warn: (...m) => console.warn("⚠", ...m),
88
+ ok: (...m) => console.log("✓", ...m),
89
+ warn: (...m) => console.warn(`${ORANGE}⚠${RESET}`, ...m.map(s => `${ORANGE}${s}${RESET}`)),
87
90
  };
88
91
 
89
92
  export function humanSize(bytes) {
@@ -1,16 +1,261 @@
1
1
  # __PLUGIN_NAME__
2
2
 
3
- A Muscat plugin scaffolded with `muscat-pk`.
3
+ A Muscat plugin project. Build, develop, and ship with `muscat-pk` CLI.
4
+
5
+ ## Quick start
6
+
7
+ ```bash
8
+ muscat-pk dev # UI preview + backend together
9
+ muscat-pk dev:ui # UI preview only
10
+ muscat-pk dev:back # backend only (Python watchfiles / ROS2 Docker)
11
+ muscat-pk pack # build a distributable ZIP
12
+ ```
13
+
14
+ ---
15
+
16
+ ## Project structure
17
+
18
+ ```
19
+ __PLUGIN_DIR__/
20
+ ├─ src/
21
+ │ └─ content.tsx # plugin UI entry point
22
+ ├─ assets/
23
+ │ └─ icon.svg # icon referenced from manifest
24
+ ├─ manifest.json # metadata, contribution points, runtime config
25
+ ├─ main.py # backend (Python or ROS2)
26
+ └─ requirements.txt # Python deps (auto-pinned to latest on pack)
27
+ ```
28
+
29
+ > ROS2 plugins additionally include a `ros2_ws/` directory.
30
+
31
+ ---
32
+
33
+ ## Frontend (`src/content.tsx`)
34
+
35
+ ### useRb() hook
36
+
37
+ Access the communication API between the plugin module and UI from any plugin component.
38
+
39
+ ```tsx
40
+ import { useRb } from "@rainbow-robotics/plugin-ui/sdk";
41
+
42
+ function App() {
43
+ const rb = useRb();
44
+ }
45
+ ```
46
+
47
+ ### rb API
48
+
49
+ | Method | Description |
50
+ |---|---|
51
+ | `rb.pub(topic, payload)` | Publish a message to a topic |
52
+ | `rb.sub(topic, handler)` | Subscribe to a topic — returns `{ undeclare() }` |
53
+ | `rb.action(topic, payload?)` | Send a request and await a reply |
54
+ | `rb.serve(topic, handler)` | Handle incoming requests from the backend |
55
+ | `rb.toast(message, type?)` | Host toast notification (`"info" · "success" · "warning" · "error"`) |
56
+
57
+ All methods support Generic Types:
58
+
59
+ ```tsx
60
+ const status = await rb.action<{ ok: boolean }, { id: number }>(
61
+ "get-status",
62
+ { id: 1 },
63
+ );
64
+
65
+ const sub = await rb.sub<{ value: number }>("sensor/data", (data) => {
66
+ console.log(data.value);
67
+ });
68
+ sub.undeclare(); // cleanup
69
+ ```
70
+
71
+ ### Topic namespace
72
+
73
+ Topics are automatically namespaced by plugin ID to prevent collisions across plugins.
74
+
75
+ ```
76
+ "sensor/data" → plugin/{pluginId}/sensor/data (auto-namespaced)
77
+ "/broadcast/x" → broadcast/x (leading / = absolute)
78
+ ```
79
+
80
+ ### UI components
81
+
82
+ Import Muscat components from `@rainbow-robotics/plugin-ui/react`. The plugin emits
83
+ `rb-*` tags; the host renders the actual components.
84
+
85
+ ```tsx
86
+ import { connect } from "@rainbow-robotics/plugin-ui/connect";
87
+ import { Button, Slider, Text, Switch } from "@rainbow-robotics/plugin-ui/react";
88
+ import { useRb } from "@rainbow-robotics/plugin-ui/sdk";
89
+
90
+ function App() {
91
+ const rb = useRb();
92
+ return <Button label="Run" onPress={() => rb.action("run")} />;
93
+ }
94
+
95
+ connect(App);
96
+ ```
97
+
98
+ ---
99
+
100
+ ## Backend (`main.py`)
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.
104
+
105
+ ### Python plugin
106
+
107
+ ```python
108
+ import asyncio
109
+ from rainbow_rb_zenoh import ZenohRouter, ZenohClient
110
+
111
+ router = ZenohRouter(prefix="plugin/__PLUGIN_ID__")
112
+ client = ZenohClient()
113
+
114
+
115
+ @router.subscribe("sensor/data")
116
+ async def on_sensor(*, dict_payload, **_):
117
+ print("received:", dict_payload)
118
+
119
+
120
+ @router.queryable("get-status")
121
+ async def get_status(req=None, **_):
122
+ return {"ok": True}
123
+
124
+
125
+ async def main():
126
+ await router.startup()
127
+ await asyncio.Event().wait() # keep alive
128
+
129
+
130
+ if __name__ == "__main__":
131
+ asyncio.run(main())
132
+ ```
133
+
134
+ ### ROS2 plugin
135
+
136
+ The node is launched with `--ros-args -r __ns:=/{slug}` automatically by `dev:back`.
137
+ ROS2 topics are bridged to the plugin communication layer — no extra code needed.
138
+
139
+ ```python
140
+ # ros2_ws/src/__PLUGIN_SLUG__/__PLUGIN_SLUG__/node.py
141
+ import rclpy
142
+ from rclpy.node import Node
143
+ from std_msgs.msg import Float32
144
+
145
+ class __PLUGIN_CLASS__Node(Node):
146
+ def __init__(self):
147
+ super().__init__("__PLUGIN_SLUG___node")
148
+ # relative topic name — __ns prefix applied automatically
149
+ self.pub = self.create_publisher(Float32, "sensor_data", 10)
150
+ self.create_timer(1.0, self.timer_cb)
151
+
152
+ def timer_cb(self):
153
+ self.pub.publish(Float32(data=42.0))
154
+ ```
155
+
156
+ ---
157
+
158
+ ## manifest.json
159
+
160
+ ```jsonc
161
+ {
162
+ "id": "com.example.my-plugin", // unique plugin ID (reverse-domain recommended)
163
+ "name": "My Plugin",
164
+ "version": "1.0.0",
165
+ "minAppVersion": "2.0.0",
166
+ "icon": "assets/icon.svg",
167
+
168
+ "contributes": [
169
+ { "point": "program.panel", "label": "My Plugin", "icon": "assets/icon.svg" },
170
+ { "point": "lnb", "label": "My Plugin", "icon": "assets/icon.svg" },
171
+ { "point": "header.toolbar","label": "My Plugin", "icon": "assets/icon.svg", "opens": "popover" }
172
+ ],
173
+
174
+ // "python" | "ros2"
175
+ "runtime": { "type": "python", "entry": "main.py" }
176
+ }
177
+ ```
178
+
179
+ **Contribution points:**
180
+
181
+ | point | Where it appears |
182
+ |---|---|
183
+ | `program.panel` | Program panel tab |
184
+ | `lnb` | Left navigation bar |
185
+ | `header.toolbar` | Header toolbar button |
186
+
187
+ ---
188
+
189
+ ## Installing Python dependencies
190
+
191
+ Add any package with an optional version pin to `requirements.txt`:
192
+
193
+ ```
194
+ rainbow-rb-sdk
195
+ rainbow-rb-flat-buffers
196
+ numpy==1.26.4
197
+ opencv-python
198
+ ```
199
+
200
+ Then just run `muscat-pk dev` or `muscat-pk dev:back` — dependencies are installed automatically on every startup:
201
+
202
+ - **Python plugin**: installed into `.venv` via `pip install -r requirements.txt`
203
+ - **ROS2 plugin**: installed inside the Docker container via `pip install -r requirements.txt`
204
+
205
+ No extra steps needed.
206
+
207
+ > `muscat-pk build` / `muscat-pk pack` only **pins** versions in `requirements.txt` — it does not run `pip install`.
208
+
209
+ ---
210
+
211
+ ## Switching backend type
212
+
213
+ ```bash
214
+ muscat-pk type change # interactive
215
+ muscat-pk type change --to ros2 # python → ros2
216
+ muscat-pk type change --to python # ros2 → python
217
+ ```
218
+
219
+ > `ros2_ws/` is not deleted when switching back to python. Remove it manually if no longer needed.
220
+
221
+ ---
222
+
223
+ ## Shipping
4
224
 
5
225
  ```bash
6
- __PM__ run dev # preview in a mini-Muscat (preview-host) + back end
7
- __PM__ run dev:ui # UI preview only
8
- __PM__ run pack # build a distributable ZIP
226
+ muscat-pk pack
9
227
  ```
10
228
 
11
- - `src/content.tsx` — your plugin UI. Import components from `@rainbow-robotics/plugin-ui/react`.
12
- - `manifest.json` — id, contributions, actions, runtime.
13
- - `assets/` — static assets (icons referenced from the manifest).
14
- - `main.py` — (sdk runtime) back-end handler using `rainbow-rb-sdk`.
229
+ - Builds `src/content.tsx` → `dist/content.js`
230
+ - Pins `requirements.txt` to latest versions (stable-only on `main` branch)
231
+ - ROS2: runs `colcon build` inside the Jazzy container; includes `ros2_ws/install/`
232
+ - Output: `__PLUGIN_DIR__-{version}.zip`
15
233
 
16
- The plugin only emits `rb-*` name tags; Muscat renders the real components.
234
+ ZIP layout:
235
+
236
+ ```
237
+ __PLUGIN_DIR__-1.0.0.zip
238
+ ├─ dist/ # built UI
239
+ ├─ main.py
240
+ ├─ requirements.txt # pinned
241
+ └─ ros2_ws/install/ # ROS2 only
242
+ ```
243
+
244
+ ---
245
+
246
+ ## All commands
247
+
248
+ ```bash
249
+ muscat-pk dev # UI + backend
250
+ muscat-pk dev:ui # UI preview only (preview-host)
251
+ muscat-pk dev:back # backend only (Python: watchfiles / ROS2: Docker)
252
+ muscat-pk build # UI build only → dist/
253
+ muscat-pk pack # build + ZIP
254
+ ```
255
+
256
+ ```bash
257
+ muscat-pk type change # switch backend type
258
+ muscat-pk sdk list # show current Python SDK version
259
+ muscat-pk sdk update # update Python SDK to latest
260
+ muscat-pk update # update muscat-plugin-kit itself
261
+ ```
@@ -1,20 +1,23 @@
1
1
  import asyncio
2
2
 
3
- from rb_sdk import RBManipulateSDK # PyPI: rainbow-rb-sdk
3
+ from rainbow_rb_zenoh import ZenohRouter
4
4
 
5
- sdk = RBManipulateSDK()
5
+ router = ZenohRouter(prefix="plugin/__PLUGIN_ID__")
6
6
 
7
7
 
8
- async def on_action(payload: dict):
9
- # Matches manifest.actions[].topic — handle the "grip" action here.
10
- width = payload.get("width")
11
- print(f"[__PLUGIN_NAME__] grip width={width}")
12
- # TODO: drive the robot via the SDK.
8
+ @router.subscribe("sensor/data")
9
+ async def on_sensor(*, dict_payload, **_):
10
+ print(f"[__PLUGIN_NAME__] received: {dict_payload}")
11
+
12
+
13
+ @router.queryable("get-status")
14
+ async def get_status(req=None, **_):
15
+ return {"ok": True}
13
16
 
14
17
 
15
18
  async def main():
16
- await sdk.subscribe("rby2/plugin/__PLUGIN_ID__/grip", on_action)
17
- await sdk.spin()
19
+ await router.startup()
20
+ await asyncio.Event().wait()
18
21
 
19
22
 
20
23
  if __name__ == "__main__":
@@ -3,13 +3,6 @@
3
3
  "version": "1.0.0",
4
4
  "private": true,
5
5
  "type": "module",
6
- "scripts": {
7
- "dev": "muscat-pk dev",
8
- "dev:ui": "muscat-pk dev:ui",
9
- "dev:back": "muscat-pk dev:back",
10
- "build": "muscat-pk build",
11
- "pack": "muscat-pk pack"
12
- },
13
6
  "dependencies": {
14
7
  "@rainbow-robotics/plugin-ui": "^0.1.0"
15
8
  },
@@ -1,3 +1,2 @@
1
1
  rainbow-rb-sdk
2
2
  rainbow-rb-flat-buffers
3
- rainbow-rb-zenoh
@@ -1,6 +1,7 @@
1
1
  import { useState } from "react";
2
2
  import { connect } from "@rainbow-robotics/plugin-ui/connect";
3
- import { Slider, Button, Text, Switch, useRb } from "@rainbow-robotics/plugin-ui/react";
3
+ import { Slider, Button, Text, Switch } from "@rainbow-robotics/plugin-ui/react";
4
+ import { useRb } from "@rainbow-robotics/plugin-ui/sdk";
4
5
 
5
6
  function App() {
6
7
  const rb = useRb();
@@ -22,7 +23,7 @@ function App() {
22
23
  onChange={(event: { detail?: boolean }) => {
23
24
  const next = event.detail ?? false;
24
25
  setOpen(next);
25
- rb.notify(next ? "opened" : "closed", "info");
26
+ rb.toast(next ? "opened" : "closed", "info");
26
27
  }}
27
28
  />
28
29
  </>
@@ -1,11 +1,16 @@
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)
4
+
5
+
6
+ sdk = RBManipulateSDK()
3
7
 
4
8
 
5
9
  class __PLUGIN_CLASS__Node(Node):
6
10
  def __init__(self):
7
11
  super().__init__("__PLUGIN_SLUG___node")
8
12
  self.get_logger().info("__PLUGIN_NAME__ node started")
13
+ # sdk is available here — use it alongside ROS2 topics as needed
9
14
 
10
15
 
11
16
  def main(args=None):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rainbow-robotics/muscat-plugin-kit",
3
- "version": "0.1.2-dev.5",
3
+ "version": "0.1.2-dev.6",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=20"