@rainbow-robotics/muscat-plugin-kit 0.1.2-dev.5 → 0.1.2-dev.7
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/cli/docker/Dockerfile.dev +0 -2
- package/cli/src/build.mjs +38 -6
- package/cli/src/devBack.mjs +12 -4
- package/cli/src/devUi.mjs +46 -12
- package/cli/src/new.mjs +51 -15
- package/cli/src/util.mjs +5 -2
- package/cli/templates/common/README.md +254 -9
- package/cli/templates/common/main.py +104 -11
- package/cli/templates/common/package.json +0 -7
- package/cli/templates/common/requirements.txt +1 -1
- package/cli/templates/common/src/content.tsx +3 -2
- package/cli/templates/ros2/ros2_ws/src/__PLUGIN_SLUG__/__PLUGIN_SLUG__/node.py +5 -0
- package/package.json +2 -2
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
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
|
|
162
|
-
|
|
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);
|
package/cli/src/devBack.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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(
|
|
16
|
-
} catch {
|
|
17
|
-
|
|
18
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
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(`
|
|
170
|
-
log.info(`
|
|
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:
|
|
86
|
-
warn: (...m) => console.warn(
|
|
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
|
|
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
|
-
|
|
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`
|
|
12
|
-
- `
|
|
13
|
-
- `
|
|
14
|
-
- `
|
|
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
|
-
|
|
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,21 +1,114 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Muscat plugin backend — __PLUGIN_NAME__
|
|
3
|
+
|
|
4
|
+
플러그인 UI(브라우저)와 Socket.IO로 통신한다.
|
|
5
|
+
`muscat-pk dev:back` 또는 `python main.py`로 실행.
|
|
6
|
+
|
|
7
|
+
토픽 자동 네임스페이스:
|
|
8
|
+
"sensor/data" → plugin/__PLUGIN_ID__/sensor/data (자동)
|
|
9
|
+
"/broadcast/x" → broadcast/x (앞 / = 절대경로)
|
|
10
|
+
"""
|
|
11
|
+
|
|
1
12
|
import asyncio
|
|
13
|
+
import socketio
|
|
14
|
+
from aiohttp import web
|
|
15
|
+
|
|
16
|
+
sio = socketio.AsyncServer(async_mode="aiohttp", cors_allowed_origins="*")
|
|
17
|
+
app = web.Application()
|
|
18
|
+
sio.attach(app)
|
|
19
|
+
|
|
20
|
+
PLUGIN_ID = "__PLUGIN_ID__"
|
|
21
|
+
_PREFIX = f"plugin/{PLUGIN_ID}"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _topic(relative: str) -> str:
|
|
25
|
+
"""상대 토픽 → 전체 네임스페이스 토픽."""
|
|
26
|
+
return relative if relative.startswith("/") else f"{_PREFIX}/{relative}"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ── 구독·쿼리어블 핸들러 저장소 ────────────────────────────────────────────────
|
|
30
|
+
_sub_handlers: dict[str, list] = {}
|
|
31
|
+
_queryable_handlers: dict[str, object] = {}
|
|
32
|
+
_serve_pending: dict[str, asyncio.Future] = {}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ── Socket.IO 이벤트 ───────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
@sio.event
|
|
38
|
+
async def sub(sid, data):
|
|
39
|
+
"""브라우저가 토픽을 구독."""
|
|
40
|
+
await sio.enter_room(sid, f"t:{data['topic']}")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@sio.event
|
|
44
|
+
async def unsub(sid, data):
|
|
45
|
+
await sio.leave_room(sid, f"t:{data['topic']}")
|
|
46
|
+
|
|
47
|
+
|
|
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))
|
|
55
|
+
|
|
56
|
+
|
|
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
|
+
|
|
65
|
+
|
|
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"))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ── 헬퍼 데코레이터 ────────────────────────────────────────────────────────────
|
|
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
|
|
82
|
+
|
|
83
|
+
|
|
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}")
|
|
2
96
|
|
|
3
|
-
from rb_sdk import RBManipulateSDK # PyPI: rainbow-rb-sdk
|
|
4
97
|
|
|
5
|
-
|
|
98
|
+
# ── 플러그인 로직 ──────────────────────────────────────────────────────────────
|
|
6
99
|
|
|
100
|
+
@subscribe("sensor/data")
|
|
101
|
+
async def on_sensor_data(payload):
|
|
102
|
+
print(f"[__PLUGIN_NAME__] sensor/data: {payload}")
|
|
7
103
|
|
|
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.
|
|
13
104
|
|
|
105
|
+
@queryable("get-status")
|
|
106
|
+
async def get_status(payload=None):
|
|
107
|
+
return {"ok": True, "plugin": "__PLUGIN_NAME__"}
|
|
14
108
|
|
|
15
|
-
async def main():
|
|
16
|
-
await sdk.subscribe("rby2/plugin/__PLUGIN_ID__/grip", on_action)
|
|
17
|
-
await sdk.spin()
|
|
18
109
|
|
|
110
|
+
# ── 진입점 ─────────────────────────────────────────────────────────────────────
|
|
19
111
|
|
|
20
112
|
if __name__ == "__main__":
|
|
21
|
-
|
|
113
|
+
print("[__PLUGIN_NAME__] Socket.IO server starting on :10000")
|
|
114
|
+
web.run_app(app, host="0.0.0.0", port=10000)
|
|
@@ -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,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
|
|
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.
|
|
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.
|
|
3
|
+
"version": "0.1.2-dev.7",
|
|
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": "
|
|
34
|
+
"@rainbow-robotics/preview-host": "0.1.2-dev.7",
|
|
35
35
|
"esbuild": "^0.25.0"
|
|
36
36
|
},
|
|
37
37
|
"optionalDependencies": {
|