@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.
- package/cli/bin/muscat-pk.mjs +1 -1
- package/cli/src/check-package.mjs +2 -1
- package/cli/src/dev.mjs +9 -2
- package/cli/src/devBack.mjs +18 -6
- package/cli/src/esbuild.mjs +26 -10
- package/cli/src/socketBridge.mjs +122 -0
- package/cli/templates/common/README.md +30 -16
- package/cli/templates/common/main.py +58 -84
- package/cli/templates/ros2/ros2_ws/src/__PLUGIN_SLUG__/__PLUGIN_SLUG__/node.py +1 -1
- package/package.json +3 -3
package/cli/bin/muscat-pk.mjs
CHANGED
|
@@ -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);
|
package/cli/src/devBack.mjs
CHANGED
|
@@ -15,15 +15,18 @@ import {
|
|
|
15
15
|
ros2PackageName,
|
|
16
16
|
} from "./ros2.mjs";
|
|
17
17
|
|
|
18
|
-
function
|
|
18
|
+
function readManifest(root) {
|
|
19
19
|
try {
|
|
20
|
-
|
|
21
|
-
return m.runtime?.type ?? "python";
|
|
20
|
+
return JSON.parse(readFileSync(resolve(root, "manifest.json"), "utf8"));
|
|
22
21
|
} catch {
|
|
23
|
-
return
|
|
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
|
);
|
package/cli/src/esbuild.mjs
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
30
|
-
|
|
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 `
|
|
103
|
-
|
|
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
|
|
109
|
-
from
|
|
110
|
+
import time
|
|
111
|
+
from rainbow.rb_sdk import RBAmrSDK, RBManipulateSDK, RBPluginCall
|
|
110
112
|
|
|
111
|
-
|
|
112
|
-
client = ZenohClient()
|
|
113
|
+
plugin = RBPluginCall()
|
|
113
114
|
|
|
114
115
|
|
|
115
|
-
|
|
116
|
-
async def on_sensor(*, dict_payload, **_):
|
|
117
|
-
print("received:", dict_payload)
|
|
116
|
+
plugin.publish("state", {"ready": True})
|
|
118
117
|
|
|
119
118
|
|
|
120
|
-
@
|
|
121
|
-
|
|
122
|
-
|
|
119
|
+
@plugin.subscribe("event")
|
|
120
|
+
def on_ui_event(payload=None):
|
|
121
|
+
print(payload)
|
|
123
122
|
|
|
124
123
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5
|
-
|
|
4
|
+
Run with:
|
|
5
|
+
muscat-pk dev:back
|
|
6
|
+
python main.py
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
|
|
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
|
|
13
|
-
import
|
|
14
|
-
from aiohttp import web
|
|
12
|
+
import time
|
|
13
|
+
from typing import Any
|
|
15
14
|
|
|
16
|
-
|
|
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
|
-
|
|
25
|
-
"""상대 토픽 → 전체 네임스페이스 토픽."""
|
|
26
|
-
return relative if relative.startswith("/") else f"{_PREFIX}/{relative}"
|
|
20
|
+
plugin = RBPluginCall()
|
|
27
21
|
|
|
28
22
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
@
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
"""
|
|
69
|
-
|
|
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
|
|
77
|
-
"""
|
|
78
|
-
|
|
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
|
-
|
|
114
|
-
web.run_app(app, host="0.0.0.0", port=10000)
|
|
88
|
+
main()
|
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.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.
|
|
35
|
-
"
|
|
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"
|