@rainbow-robotics/muscat-plugin-kit 1.3.2 → 1.3.3-dev.0
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.cpp +22 -5
- package/cli/docker/Dockerfile.dev +25 -5
- package/cli/docker/Dockerfile.python +19 -0
- package/cli/src/bridge.mjs +19 -2
- package/cli/src/build.mjs +31 -79
- package/cli/src/cpp.mjs +10 -6
- package/cli/src/dev.mjs +7 -2
- package/cli/src/devBack.mjs +39 -32
- package/cli/src/host_agent.py +143 -83
- package/cli/src/mcp.mjs +75 -16
- package/cli/src/new.mjs +5 -2
- package/cli/src/ports.mjs +21 -7
- package/cli/src/python.mjs +9 -17
- package/cli/src/ros2.mjs +13 -8
- package/cli/src/sdk.mjs +23 -21
- package/cli/src/templates-embedded.mjs +1 -1
- package/cli/src/type.mjs +42 -8
- package/cli/src/util.mjs +53 -5
- package/cli/storybook-dist/assets/{Color-YHDXOIA2-CRNvRzJD.js → Color-YHDXOIA2-DNIJVGRw.js} +1 -1
- package/cli/storybook-dist/assets/{Docs-80l_9VTs.js → Docs-CIT3omBi.js} +1 -1
- package/cli/storybook-dist/assets/{Docs-BgDx9SSn.js → Docs-CdX_ToEv.js} +1 -1
- package/cli/storybook-dist/assets/{Docs-BV0FgAF_.js → Docs-D9eMx0Cp.js} +1 -1
- package/cli/storybook-dist/assets/{Docs-C4S_Kfa9.js → Docs-RJox-ekp.js} +1 -1
- package/cli/storybook-dist/assets/{Docs-axk94PIl.js → Docs-wnrZBY76.js} +1 -1
- package/cli/storybook-dist/assets/{Docs-DurNQvJo.js → Docs-x99QNSvG.js} +1 -1
- package/cli/storybook-dist/assets/{DocsRenderer-CFRXHY34-CEdKXElc.js → DocsRenderer-CFRXHY34-CPuvjoGb.js} +1 -1
- package/cli/storybook-dist/assets/{NxAvatar.stories-CyZ4AE2b.js → NxAvatar.stories-B3OxXBgc.js} +1 -1
- package/cli/storybook-dist/assets/{NxButton.stories-Bx8UtGNd.js → NxButton.stories-UTqX8ZkA.js} +1 -1
- package/cli/storybook-dist/assets/{NxNumberInput.stories-skizAdd5.js → NxNumberInput.stories-GoLEe-uJ.js} +1 -1
- package/cli/storybook-dist/assets/{NxPagination.stories-gtIxt2u0.js → NxPagination.stories-Jj6dt6eG.js} +1 -1
- package/cli/storybook-dist/assets/{NxPointerHoldButton.stories-DSHMk3N5.js → NxPointerHoldButton.stories-tLN9u4nC.js} +1 -1
- package/cli/storybook-dist/assets/{NxRangeInput.stories-C1YtrFZ1.js → NxRangeInput.stories-C-MK73xb.js} +1 -1
- package/cli/storybook-dist/assets/{NxSelectBox.stories-DCySAtBp.js → NxSelectBox.stories-CBcFcOIX.js} +1 -1
- package/cli/storybook-dist/assets/{NxSlideRange.stories-gSaMOWo7.js → NxSlideRange.stories-oNQkoW66.js} +1 -1
- package/cli/storybook-dist/assets/{NxSwitch.stories-DF0DBiyG.js → NxSwitch.stories-CqBkO5GC.js} +1 -1
- package/cli/storybook-dist/assets/{NxTextArea.stories-Dill0M6z.js → NxTextArea.stories-DtysSyl4.js} +1 -1
- package/cli/storybook-dist/assets/{NxTextInput.stories-Bax14r48.js → NxTextInput.stories-BOLDbSgF.js} +1 -1
- package/cli/storybook-dist/assets/{RBAccordion.stories-CCwz_pIV.js → RBAccordion.stories-D9-wu9LY.js} +1 -1
- package/cli/storybook-dist/assets/{RBAccordionGroup.stories-BjDxxdPV.js → RBAccordionGroup.stories-BPfnIKm6.js} +1 -1
- package/cli/storybook-dist/assets/{RBAlert.stories-5z6GasgP.js → RBAlert.stories-CGn-c2ac.js} +1 -1
- package/cli/storybook-dist/assets/{RBBadge.stories-BwcM7i7w.js → RBBadge.stories-CBLmeoq1.js} +1 -1
- package/cli/storybook-dist/assets/{RBBattery.stories-DOeQH3rK.js → RBBattery.stories-oo47rbDh.js} +1 -1
- package/cli/storybook-dist/assets/{RBCard.stories-DcmiOXAV.js → RBCard.stories-SiD2VfQ4.js} +1 -1
- package/cli/storybook-dist/assets/{RBCheckBox.stories-BYru87fX.js → RBCheckBox.stories-sEEVrjSg.js} +1 -1
- package/cli/storybook-dist/assets/{RBCheckIconButton.stories-CXUjtR1a.js → RBCheckIconButton.stories-C5GQUjZf.js} +1 -1
- package/cli/storybook-dist/assets/{RBCheckLabelButton.stories-BSOQV7Du.js → RBCheckLabelButton.stories-DTaTqBvJ.js} +1 -1
- package/cli/storybook-dist/assets/{RBChipTab.stories-ByBmeukU.js → RBChipTab.stories-3AZr4kOz.js} +1 -1
- package/cli/storybook-dist/assets/{RBChipsBoxTab.stories-GtJ01RTr.js → RBChipsBoxTab.stories-hCJnBYQl.js} +1 -1
- package/cli/storybook-dist/assets/{RBColorIcons.stories-C9saET1u.js → RBColorIcons.stories-BX4RJygu.js} +1 -1
- package/cli/storybook-dist/assets/{RBCommand.stories-CcvnWlWZ.js → RBCommand.stories-BidZ4H8f.js} +1 -1
- package/cli/storybook-dist/assets/{RBConfirm.stories-hdBVSdBS.js → RBConfirm.stories-D3Txw1kT.js} +1 -1
- package/cli/storybook-dist/assets/{RBDateInput.stories-BE9JQl-E.js → RBDateInput.stories-CLxRaN6T.js} +1 -1
- package/cli/storybook-dist/assets/{RBDialog.stories-BaWuDgl_.js → RBDialog.stories-CEhmUGp-.js} +1 -1
- package/cli/storybook-dist/assets/{RBDivider.stories-D3INKm_Z.js → RBDivider.stories-peXP2KG9.js} +1 -1
- package/cli/storybook-dist/assets/{RBDrawer.stories-BZ8V0J6-.js → RBDrawer.stories-38iDf6uC.js} +1 -1
- package/cli/storybook-dist/assets/{RBDropdownMenu.stories-DxLi8HRw.js → RBDropdownMenu.stories-Dsi8tqSy.js} +1 -1
- package/cli/storybook-dist/assets/{RBEmptyView.stories-DQcvDt2S.js → RBEmptyView.stories-VMxWhzLP.js} +1 -1
- package/cli/storybook-dist/assets/{RBIconButton.stories-DJfuTpQ0.js → RBIconButton.stories-BPLhceGO.js} +1 -1
- package/cli/storybook-dist/assets/{RBIcons.stories-BSsyb0BK.js → RBIcons.stories-DuYdO951.js} +1 -1
- package/cli/storybook-dist/assets/{RBInlineLabelButton.stories-Di3th--x.js → RBInlineLabelButton.stories-CGNYhQyl.js} +1 -1
- package/cli/storybook-dist/assets/{RBJoyStick.stories-DfGazm4n.js → RBJoyStick.stories-CCmn-LMy.js} +1 -1
- package/cli/storybook-dist/assets/{RBLabelButton.stories-CLbxzW56.js → RBLabelButton.stories-IkKjthGk.js} +1 -1
- package/cli/storybook-dist/assets/{RBLight.stories-DeiuDm_P.js → RBLight.stories-eJFQOjPH.js} +1 -1
- package/cli/storybook-dist/assets/{RBMenubar.stories-BZQTV9e5.js → RBMenubar.stories-kg-xXGrW.js} +1 -1
- package/cli/storybook-dist/assets/{RBMultiRangeInput.stories-DDNmdi_n.js → RBMultiRangeInput.stories-Czr1r3gd.js} +1 -1
- package/cli/storybook-dist/assets/{RBPagination.stories-CLBCEyCU.js → RBPagination.stories-Dyu6Khnl.js} +1 -1
- package/cli/storybook-dist/assets/{RBPopover.stories-DRI5Kfa-.js → RBPopover.stories-CAM6hpf2.js} +1 -1
- package/cli/storybook-dist/assets/{RBPrompt.stories-DIAmypoJ.js → RBPrompt.stories-B4M-veaf.js} +1 -1
- package/cli/storybook-dist/assets/{RBRadioBox.stories-BS_N4aLf.js → RBRadioBox.stories-DeAOKvil.js} +1 -1
- package/cli/storybook-dist/assets/{RBRadioButton.stories-BTm8cYvm.js → RBRadioButton.stories-DjXwYXFy.js} +1 -1
- package/cli/storybook-dist/assets/{RBRangeDateInput.stories-D5hBsSyB.js → RBRangeDateInput.stories-9pzKhXGf.js} +1 -1
- package/cli/storybook-dist/assets/{RBResizable.stories-CxPEkbXQ.js → RBResizable.stories-B4Hi4DUD.js} +1 -1
- package/cli/storybook-dist/assets/{RBSelectBox.stories-FUQ7AcUi.js → RBSelectBox.stories-tVVv4c6z.js} +1 -1
- package/cli/storybook-dist/assets/{RBSheet.stories-mhVHH_71.js → RBSheet.stories-BwOCmaNN.js} +1 -1
- package/cli/storybook-dist/assets/{RBSideNumberInput.stories-5FMkLUim.js → RBSideNumberInput.stories-BbGOIitU.js} +1 -1
- package/cli/storybook-dist/assets/{RBSlider.stories-Bnhcp9pz.js → RBSlider.stories-yIDdawYQ.js} +1 -1
- package/cli/storybook-dist/assets/{RBSpinner.stories-Bf4416mP.js → RBSpinner.stories-CIJvpbgK.js} +1 -1
- package/cli/storybook-dist/assets/{RBSwitch.stories-BNhFjRaV.js → RBSwitch.stories-Bo8hrSiq.js} +1 -1
- package/cli/storybook-dist/assets/{RBTab.stories-Bsr7jlfb.js → RBTab.stories-Cq3N4Dgb.js} +1 -1
- package/cli/storybook-dist/assets/{RBTable.stories-BkA6pKzs.js → RBTable.stories-DDeZf-Wb.js} +1 -1
- package/cli/storybook-dist/assets/{RBTableRow.stories-D07Z7D5-.js → RBTableRow.stories-BeKVMSo3.js} +1 -1
- package/cli/storybook-dist/assets/{RBTabs.stories-DHnZiV21.js → RBTabs.stories-DovB7PPB.js} +1 -1
- package/cli/storybook-dist/assets/{RBTextArea.stories-CtLMLuV_.js → RBTextArea.stories-D7H4RvP8.js} +1 -1
- package/cli/storybook-dist/assets/{RBTextField.stories-Chz0i1-u.js → RBTextField.stories-CmkNXeEk.js} +1 -1
- package/cli/storybook-dist/assets/{RBTextInput.stories-Dk5cZfUF.js → RBTextInput.stories-hnxbQJsb.js} +1 -1
- package/cli/storybook-dist/assets/{RBToast.stories-tZxbjmr8.js → RBToast.stories-BklGGkNh.js} +1 -1
- package/cli/storybook-dist/assets/{RBToggle.stories-VTe7a4xZ.js → RBToggle.stories-C56AXyxq.js} +1 -1
- package/cli/storybook-dist/assets/{RBToggleGroup.stories-WiFnGGYx.js → RBToggleGroup.stories-CpLPdAF0.js} +1 -1
- package/cli/storybook-dist/assets/{RBTooltip.stories-B_L4W7Mn.js → RBTooltip.stories-BdOnCwGk.js} +1 -1
- package/cli/storybook-dist/assets/{SideNumberInput-os5wKKf7.js → SideNumberInput-DLmM2_Dy.js} +80 -80
- package/cli/storybook-dist/assets/{entry-preview-COJgWzHb.js → entry-preview-DqMcNOGI.js} +1 -1
- package/cli/storybook-dist/assets/{iframe-DQEjJuXA.js → iframe-C1kyDB-R.js} +2 -2
- package/cli/storybook-dist/assets/{index-DEgQqQ_-.js → index-CdVSDe2m.js} +3 -3
- package/cli/storybook-dist/assets/{preview-BZ5PpEp_.js → preview-C_JfiDjl.js} +2 -2
- package/cli/storybook-dist/assets/preview-DvwEjyb4.js +3 -0
- package/cli/storybook-dist/iframe.html +1 -1
- package/cli/storybook-dist/project.json +1 -1
- package/cli/templates/common/README.md +65 -4
- package/cli/templates/common/py/main.py +5 -1
- package/cli/templates/common/requirements.txt +3 -3
- package/cli/templates/cpp/cpp/CMakeLists.txt +1 -2
- package/cli/templates/cpp/cpp/main.cpp +48 -8
- package/cli/templates/readme-cpp/README.md +969 -0
- package/cli/templates/ros2-cpp/ros2_ws/src/__PLUGIN_SLUG__/CMakeLists.txt +1 -1
- package/package.json +3 -3
- package/cli/storybook-dist/assets/preview-CmCb8Xea.js +0 -3
package/cli/src/host_agent.py
CHANGED
|
@@ -1,10 +1,7 @@
|
|
|
1
|
-
"""Host-side
|
|
1
|
+
"""Host-side Zenoh byte-proxy for Docker Desktop containers (Mac/WSL remote dev).
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
— {query, put, subscribe} by keyexpr + bytes only — so a Docker Desktop
|
|
6
|
-
container, which cannot join the robot's Zenoh mesh itself, can talk to a
|
|
7
|
-
process that can.
|
|
3
|
+
Joins the robot's Zenoh mesh with eclipse-zenoh (no rainbow SDK) and relays
|
|
4
|
+
{query, put, subscribe} bytes over TCP to the dev container.
|
|
8
5
|
|
|
9
6
|
Env: MUSCAT_ROBOT_HOST, MUSCAT_ROBOT_TOKEN, MUSCAT_ZENOH_ADVERTISE_HOST,
|
|
10
7
|
MUSCAT_ZENOH_PORT (robot-facing), MUSCAT_PROXY_PORT (container-facing).
|
|
@@ -12,13 +9,21 @@ MUSCAT_ZENOH_PORT (robot-facing), MUSCAT_PROXY_PORT (container-facing).
|
|
|
12
9
|
|
|
13
10
|
from __future__ import annotations
|
|
14
11
|
|
|
15
|
-
import asyncio
|
|
16
12
|
import contextlib
|
|
17
13
|
import json
|
|
18
14
|
import os
|
|
15
|
+
import signal
|
|
19
16
|
import socket
|
|
20
17
|
import struct
|
|
18
|
+
import sys
|
|
21
19
|
import threading
|
|
20
|
+
import urllib.error
|
|
21
|
+
import urllib.request
|
|
22
|
+
|
|
23
|
+
import zenoh
|
|
24
|
+
|
|
25
|
+
PEER_TTL = 30
|
|
26
|
+
QUERY_TARGETS = {0: zenoh.QueryTarget.BEST_MATCHING, 1: zenoh.QueryTarget.ALL, 2: zenoh.QueryTarget.ALL_COMPLETE}
|
|
22
27
|
|
|
23
28
|
|
|
24
29
|
def _send_frame(conn: socket.socket, lock: threading.Lock, obj: dict) -> None:
|
|
@@ -44,102 +49,156 @@ def _recv_frame(conn: socket.socket) -> dict | None:
|
|
|
44
49
|
return json.loads(buf.decode())
|
|
45
50
|
|
|
46
51
|
|
|
47
|
-
def
|
|
52
|
+
def _att(sample) -> str:
|
|
53
|
+
return sample.attachment.to_string() if sample.attachment is not None else ""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _query(session: zenoh.Session, req: dict) -> list[dict]:
|
|
57
|
+
payload = bytes.fromhex(req["payload_hex"]) if req.get("payload_hex") else None
|
|
58
|
+
replies = session.get(
|
|
59
|
+
req["keyexpr"],
|
|
60
|
+
payload=payload,
|
|
61
|
+
timeout=req.get("timeout_ms", 2000) / 1000,
|
|
62
|
+
target=QUERY_TARGETS.get(req.get("target", 0), zenoh.QueryTarget.BEST_MATCHING),
|
|
63
|
+
)
|
|
64
|
+
out = []
|
|
65
|
+
for reply in replies:
|
|
66
|
+
if reply.ok is not None:
|
|
67
|
+
s = reply.ok
|
|
68
|
+
out.append({"key": str(s.key_expr), "payload_hex": s.payload.to_bytes().hex(),
|
|
69
|
+
"att": _att(s), "encoding": str(s.encoding), "is_error": False})
|
|
70
|
+
else:
|
|
71
|
+
e = reply.err
|
|
72
|
+
out.append({"key": req["keyexpr"], "payload_hex": e.payload.to_bytes().hex(),
|
|
73
|
+
"att": "", "encoding": str(e.encoding), "is_error": True})
|
|
74
|
+
return out
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _handle_conn(conn: socket.socket, session: zenoh.Session) -> None:
|
|
48
78
|
send_lock = threading.Lock()
|
|
49
|
-
|
|
79
|
+
subs: dict[str, zenoh.Subscriber] = {}
|
|
50
80
|
try:
|
|
51
81
|
while True:
|
|
52
82
|
req = _recv_frame(conn)
|
|
53
83
|
if req is None:
|
|
54
84
|
return
|
|
55
85
|
op = req.get("type")
|
|
86
|
+
rid = req.get("id")
|
|
56
87
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
)
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
sub_id = zenoh_client._client.declare_subscriber(keyexpr, on_sample)
|
|
94
|
-
sub_ids[keyexpr] = sub_id
|
|
95
|
-
out = {"type": "subscribe_resp", "id": req["id"], "ok": True}
|
|
96
|
-
except Exception as exc:
|
|
97
|
-
out = {"type": "subscribe_resp", "id": req["id"], "ok": False, "error": f"{type(exc).__name__}: {exc}"}
|
|
98
|
-
_send_frame(conn, send_lock, out)
|
|
99
|
-
|
|
100
|
-
elif op == "unsubscribe":
|
|
101
|
-
keyexpr = req["keyexpr"]
|
|
102
|
-
sub_id = sub_ids.pop(keyexpr, None)
|
|
103
|
-
if sub_id is not None:
|
|
104
|
-
try:
|
|
105
|
-
zenoh_client._client.undeclare_subscriber(sub_id)
|
|
106
|
-
except Exception:
|
|
107
|
-
pass
|
|
108
|
-
_send_frame(conn, send_lock, {"type": "unsubscribe_resp", "id": req["id"], "ok": True})
|
|
109
|
-
|
|
110
|
-
else:
|
|
111
|
-
_send_frame(conn, send_lock, {"type": "error", "error": f"unknown op {op!r}"})
|
|
88
|
+
try:
|
|
89
|
+
if op == "query":
|
|
90
|
+
out = {"type": "query_resp", "id": rid, "ok": True, "replies": _query(session, req)}
|
|
91
|
+
|
|
92
|
+
elif op == "put":
|
|
93
|
+
payload = bytes.fromhex(req["payload_hex"]) if req.get("payload_hex") else b""
|
|
94
|
+
session.put(req["keyexpr"], payload)
|
|
95
|
+
out = {"type": "put_resp", "id": rid, "ok": True}
|
|
96
|
+
|
|
97
|
+
elif op == "subscribe":
|
|
98
|
+
keyexpr = req["keyexpr"]
|
|
99
|
+
|
|
100
|
+
def on_sample(s, _ke: str = keyexpr) -> None:
|
|
101
|
+
_send_frame(conn, send_lock, {
|
|
102
|
+
"type": "sample", "keyexpr": _ke, "key": str(s.key_expr),
|
|
103
|
+
"payload_hex": s.payload.to_bytes().hex(), "att": _att(s),
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
old = subs.pop(keyexpr, None)
|
|
107
|
+
if old is not None:
|
|
108
|
+
old.undeclare()
|
|
109
|
+
subs[keyexpr] = session.declare_subscriber(keyexpr, on_sample)
|
|
110
|
+
out = {"type": "subscribe_resp", "id": rid, "ok": True}
|
|
111
|
+
|
|
112
|
+
elif op == "unsubscribe":
|
|
113
|
+
sub = subs.pop(req["keyexpr"], None)
|
|
114
|
+
if sub is not None:
|
|
115
|
+
with contextlib.suppress(Exception):
|
|
116
|
+
sub.undeclare()
|
|
117
|
+
out = {"type": "unsubscribe_resp", "id": rid, "ok": True}
|
|
118
|
+
|
|
119
|
+
else:
|
|
120
|
+
out = {"type": "error", "error": f"unknown op {op!r}"}
|
|
121
|
+
except Exception as exc:
|
|
122
|
+
out = {"type": f"{op}_resp", "id": rid, "ok": False, "error": f"{type(exc).__name__}: {exc}"}
|
|
123
|
+
_send_frame(conn, send_lock, out)
|
|
112
124
|
except (ConnectionResetError, BrokenPipeError, OSError):
|
|
113
125
|
pass
|
|
114
126
|
finally:
|
|
115
|
-
for
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
except Exception:
|
|
119
|
-
pass
|
|
127
|
+
for sub in subs.values():
|
|
128
|
+
with contextlib.suppress(Exception):
|
|
129
|
+
sub.undeclare()
|
|
120
130
|
conn.close()
|
|
121
131
|
|
|
122
132
|
|
|
123
|
-
|
|
124
|
-
|
|
133
|
+
class Peer:
|
|
134
|
+
"""Registers this host with the robot's common: POST /zenoh/peer, heartbeat, DELETE."""
|
|
135
|
+
|
|
136
|
+
def __init__(self, host: str, token: str, ip: str, port: int) -> None:
|
|
137
|
+
self.base = f"http://{host}:8000"
|
|
138
|
+
self.headers = {"Content-Type": "application/json"}
|
|
139
|
+
if token:
|
|
140
|
+
self.headers["Authorization"] = f"Bearer {token}"
|
|
141
|
+
self.body = {"ip": ip, "port": port, "ttl": PEER_TTL}
|
|
142
|
+
self.peer_id: str | None = None
|
|
143
|
+
self.stop = threading.Event()
|
|
144
|
+
|
|
145
|
+
def _http(self, method: str, path: str, body: dict | None = None) -> dict:
|
|
146
|
+
data = json.dumps(body).encode() if body is not None else None
|
|
147
|
+
req = urllib.request.Request(self.base + path, data=data, headers=self.headers, method=method)
|
|
148
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
149
|
+
raw = resp.read()
|
|
150
|
+
return json.loads(raw) if raw else {}
|
|
151
|
+
|
|
152
|
+
def register(self) -> None:
|
|
153
|
+
self.peer_id = self._http("POST", "/zenoh/peer", self.body)["peer_id"]
|
|
154
|
+
print(f"[host-agent] registered with {self.base} as zenoh peer {self.peer_id}", flush=True)
|
|
125
155
|
|
|
156
|
+
def maintain(self) -> None:
|
|
157
|
+
while True:
|
|
158
|
+
wait = PEER_TTL / 2 if self.peer_id else 3
|
|
159
|
+
if self.stop.wait(wait):
|
|
160
|
+
return
|
|
161
|
+
try:
|
|
162
|
+
if self.peer_id is None:
|
|
163
|
+
self.register()
|
|
164
|
+
elif not self._http("POST", f"/zenoh/peer/{self.peer_id}/heartbeat", {"ttl": PEER_TTL}).get("ok"):
|
|
165
|
+
print(f"[host-agent] heartbeat rejected for {self.peer_id}, re-registering", flush=True)
|
|
166
|
+
self.peer_id = None
|
|
167
|
+
except Exception as exc:
|
|
168
|
+
print(f"[host-agent] peer maintenance failed ({exc}); retrying", flush=True)
|
|
169
|
+
if self.peer_id is not None and isinstance(exc, urllib.error.HTTPError) and exc.code == 404:
|
|
170
|
+
self.peer_id = None
|
|
171
|
+
|
|
172
|
+
def close(self) -> None:
|
|
173
|
+
self.stop.set()
|
|
174
|
+
if self.peer_id:
|
|
175
|
+
with contextlib.suppress(Exception):
|
|
176
|
+
self._http("DELETE", f"/zenoh/peer/{self.peer_id}")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def main() -> None:
|
|
180
|
+
# dev stops us with SIGTERM; exit via `finally` to deregister.
|
|
181
|
+
signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
|
|
126
182
|
host = os.environ["MUSCAT_ROBOT_HOST"]
|
|
127
183
|
token = os.environ.get("MUSCAT_ROBOT_TOKEN", "")
|
|
128
184
|
advertise = os.environ["MUSCAT_ZENOH_ADVERTISE_HOST"]
|
|
129
185
|
zenoh_port = int(os.environ["MUSCAT_ZENOH_PORT"])
|
|
130
186
|
proxy_port = int(os.environ["MUSCAT_PROXY_PORT"])
|
|
131
187
|
|
|
132
|
-
#
|
|
133
|
-
|
|
134
|
-
|
|
188
|
+
# Listen only; the robot dials in after registration.
|
|
189
|
+
conf = zenoh.Config()
|
|
190
|
+
conf.insert_json5("mode", '"peer"')
|
|
191
|
+
conf.insert_json5("listen/endpoints", json.dumps([f"tcp/0.0.0.0:{zenoh_port}"]))
|
|
192
|
+
conf.insert_json5("scouting/multicast/enabled", "false")
|
|
193
|
+
session = zenoh.open(conf)
|
|
135
194
|
|
|
136
|
-
peer =
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
print(f"[host-agent] zenoh peer
|
|
195
|
+
peer = Peer(host, token, advertise, zenoh_port)
|
|
196
|
+
try:
|
|
197
|
+
peer.register()
|
|
198
|
+
except Exception as exc:
|
|
199
|
+
print(f"[host-agent] registration with {peer.base} failed, retrying in background: {exc}", flush=True)
|
|
200
|
+
threading.Thread(target=peer.maintain, daemon=True, name="zenoh-peer").start()
|
|
201
|
+
print(f"[host-agent] zenoh peer listening :{zenoh_port} (advertised as {advertise})", flush=True)
|
|
143
202
|
|
|
144
203
|
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
145
204
|
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
@@ -150,12 +209,13 @@ def main() -> None:
|
|
|
150
209
|
try:
|
|
151
210
|
while True:
|
|
152
211
|
conn, _addr = srv.accept()
|
|
153
|
-
threading.Thread(target=_handle_conn, args=(conn,
|
|
212
|
+
threading.Thread(target=_handle_conn, args=(conn, session), daemon=True).start()
|
|
154
213
|
except KeyboardInterrupt:
|
|
155
214
|
pass
|
|
156
215
|
finally:
|
|
216
|
+
peer.close()
|
|
157
217
|
with contextlib.suppress(Exception):
|
|
158
|
-
|
|
218
|
+
session.close()
|
|
159
219
|
|
|
160
220
|
|
|
161
221
|
if __name__ == "__main__":
|
package/cli/src/mcp.mjs
CHANGED
|
@@ -39,10 +39,11 @@ import { homedir } from "node:os";
|
|
|
39
39
|
import { join, resolve } from "node:path";
|
|
40
40
|
import { fileURLToPath } from "node:url";
|
|
41
41
|
import { kitRoot, kitVersion, findPluginRoot, venvPython } from "./util.mjs";
|
|
42
|
+
import { PYTHON_DEV_IMAGE } from "./python.mjs";
|
|
42
43
|
import { SHIPPED_STORIES_SRC, SHIPPED_DESIGN_TOKENS } from "./storybook.mjs";
|
|
43
44
|
import { CPP_DEV_IMAGE } from "./cpp.mjs";
|
|
44
45
|
|
|
45
|
-
const
|
|
46
|
+
const TEMPLATE_READMES = { python: "common/README.md", cpp: "readme-cpp/README.md" };
|
|
46
47
|
// Design tokens: prefer the shipped copy, fall back to the monorepo source.
|
|
47
48
|
const DESIGN_TOKENS_CSS = existsSync(SHIPPED_DESIGN_TOKENS)
|
|
48
49
|
? SHIPPED_DESIGN_TOKENS
|
|
@@ -260,10 +261,29 @@ function splitSections(text) {
|
|
|
260
261
|
return sections;
|
|
261
262
|
}
|
|
262
263
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
const
|
|
266
|
-
|
|
264
|
+
// Standalone binaries have no templates/ on disk.
|
|
265
|
+
async function readTemplateReadme(rel) {
|
|
266
|
+
const onDisk = join(kitRoot, "cli/templates", rel);
|
|
267
|
+
if (existsSync(onDisk)) return readFileSync(onDisk, "utf8");
|
|
268
|
+
try {
|
|
269
|
+
const { TEMPLATES } = await import("./templates-embedded.mjs");
|
|
270
|
+
return TEMPLATES[rel] ? Buffer.from(TEMPLATES[rel], "base64").toString("utf8") : "";
|
|
271
|
+
} catch {
|
|
272
|
+
return "";
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function searchReadme(query, lang) {
|
|
277
|
+
const langs = lang ? [lang] : ["python", "cpp"];
|
|
278
|
+
const seen = new Set();
|
|
279
|
+
const sections = [];
|
|
280
|
+
for (const l of langs) {
|
|
281
|
+
for (const section of splitSections(await readTemplateReadme(TEMPLATE_READMES[l]))) {
|
|
282
|
+
if (seen.has(section)) continue; // UI/manifest sections are shared by both READMEs
|
|
283
|
+
seen.add(section);
|
|
284
|
+
sections.push(langs.length > 1 && l === "cpp" ? `[C++ backend]\n${section}` : section);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
267
287
|
const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
268
288
|
|
|
269
289
|
return sections
|
|
@@ -357,9 +377,6 @@ function pythonCmd() {
|
|
|
357
377
|
// backend dependency). Returns a clear hint if it isn't importable.
|
|
358
378
|
function introspectRbSdk(script) {
|
|
359
379
|
const py = pythonCmd();
|
|
360
|
-
if (!py) {
|
|
361
|
-
return { error: "Python not found on PATH. Install Python 3 to use rb-sdk introspection." };
|
|
362
|
-
}
|
|
363
380
|
const probe = `
|
|
364
381
|
import json, importlib
|
|
365
382
|
try:
|
|
@@ -368,10 +385,44 @@ except Exception as e:
|
|
|
368
385
|
print(json.dumps({"__notinstalled__": str(e)})); raise SystemExit(0)
|
|
369
386
|
${script}
|
|
370
387
|
`;
|
|
388
|
+
if (!py) {
|
|
389
|
+
return introspectInDocker(probe) ?? { error: "Python not found on PATH. Install Python 3 to use rb-sdk introspection." };
|
|
390
|
+
}
|
|
371
391
|
const r = spawnSync(py, ["-c", probe], { encoding: "utf8", maxBuffer: 8 << 20 });
|
|
372
392
|
if (r.status !== 0) {
|
|
373
393
|
return { error: `python introspection failed: ${(r.stderr || "").trim().slice(0, 500)}` };
|
|
374
394
|
}
|
|
395
|
+
const local = parseProbe(r);
|
|
396
|
+
return local.error && local.notInstalled ? introspectInDocker(probe) ?? local : local;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// No macOS wheels: import the plugin's pinned SDK inside the python dev image (cached).
|
|
400
|
+
function introspectInDocker(probe) {
|
|
401
|
+
if (spawnSync("docker", ["image", "inspect", PYTHON_DEV_IMAGE], { encoding: "utf8" }).status !== 0) return null;
|
|
402
|
+
let ver = "";
|
|
403
|
+
try {
|
|
404
|
+
const req = readFileSync(join(findPluginRoot(), "requirements.txt"), "utf8");
|
|
405
|
+
ver = req.match(/^rainbow-rb-sdk==(\S+)/m)?.[1] ?? "";
|
|
406
|
+
} catch {
|
|
407
|
+
/* not a python plugin — latest */
|
|
408
|
+
}
|
|
409
|
+
// ponytail: "latest" cache is never refreshed; delete ~/.cache/... rb-sdk-py/latest to update.
|
|
410
|
+
const cache = join(UI_INDEX_CACHE_DIR, "rb-sdk-py", ver || "latest");
|
|
411
|
+
mkdirSync(cache, { recursive: true });
|
|
412
|
+
const pkg = ver ? `rainbow-rb-sdk==${ver}` : "rainbow-rb-sdk";
|
|
413
|
+
const script =
|
|
414
|
+
`[ -f /c/.ok ] || { pip3 install -q --break-system-packages --target /c '${pkg}' >&2 && touch /c/.ok; } && ` +
|
|
415
|
+
`PYTHONPATH=/c python3 -c "$PROBE"`;
|
|
416
|
+
const r = spawnSync("docker", ["run", "--rm", "-v", `${cache}:/c`, "-e", "PROBE", PYTHON_DEV_IMAGE, "bash", "-c", script], {
|
|
417
|
+
encoding: "utf8",
|
|
418
|
+
maxBuffer: 8 << 20,
|
|
419
|
+
env: { ...process.env, PROBE: probe },
|
|
420
|
+
});
|
|
421
|
+
if (r.status !== 0) return { error: `python introspection (docker) failed: ${(r.stderr || "").trim().slice(-500)}` };
|
|
422
|
+
return parseProbe(r);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function parseProbe(r) {
|
|
375
426
|
// Importing rainbow.rb_sdk has side effects that print to stdout (e.g. the
|
|
376
427
|
// Zenoh client logs its connection endpoints), ahead of our own final
|
|
377
428
|
// print(json.dumps(...)) — so parse the last line, not the whole output.
|
|
@@ -384,6 +435,7 @@ ${script}
|
|
|
384
435
|
}
|
|
385
436
|
if (data && data.__notinstalled__) {
|
|
386
437
|
return {
|
|
438
|
+
notInstalled: true,
|
|
387
439
|
error:
|
|
388
440
|
"rainbow-rb-sdk is not installed in this environment. Install it with `pip install rainbow-rb-sdk` " +
|
|
389
441
|
"(it's already in a plugin's requirements.txt), then retry.",
|
|
@@ -445,7 +497,7 @@ function cppHeaders(root) {
|
|
|
445
497
|
}
|
|
446
498
|
|
|
447
499
|
// "class Foo {" / "class Foo : public Bar {" — not forward declarations.
|
|
448
|
-
const CLASS_RE = /^\s*(?:class|struct)\s+(\w+)\b(?![^\n]*;\s*$)/;
|
|
500
|
+
const CLASS_RE = /^\s*(?:class|struct)\s+(?:[A-Z][A-Z0-9_]*_API\s+)?(\w+)\b(?![^\n]*;\s*$)/; // skip export macros (RB_SDK_API)
|
|
449
501
|
|
|
450
502
|
function cppClassesIn(text) {
|
|
451
503
|
return [...new Set(text.split("\n").map((l) => l.match(CLASS_RE)?.[1]).filter(Boolean))];
|
|
@@ -630,17 +682,23 @@ export async function runMcp() {
|
|
|
630
682
|
"rb.toast/alert/confirm/prompt, useRbFormData/useRbSave (save/restore form data through the " +
|
|
631
683
|
"host), manifest.json (including the \"variables\" declaration for the pyfm variable picker — " +
|
|
632
684
|
"names require the RBP_ prefix), program variables & executor state (ctx.get_variable/" +
|
|
633
|
-
"set_variable/executor_state/check_stop),
|
|
634
|
-
"
|
|
685
|
+
"set_variable/executor_state/check_stop), video streaming (MediaMTX: backend publishes RTSP to " +
|
|
686
|
+
"127.0.0.1:8554, UI plays WebRTC from :8889), contribution points, and backend runtimes " +
|
|
687
|
+
"(Python/C++/ROS2). Covers BOTH backends: the Python README (py/main.py, rainbow.rb_sdk) and the " +
|
|
688
|
+
"C++ README (cpp/main.cpp or an rclcpp node: rb_plugin::RBPluginCall serve/subscribe/publish/" +
|
|
689
|
+
"router(), rb_plugin::PluginProgramContext, RBManipulateSDK, CMake rb_sdk::rb_sdk). Pass lang " +
|
|
690
|
+
"\"python\" or \"cpp\" to match the plugin's manifest runtime (cpp or ros2+rclcpp → \"cpp\"); " +
|
|
691
|
+
"omit it to search both. IMPORTANT: " +
|
|
635
692
|
"the backend pattern depends on the contribution point — search 'which backend pattern' before " +
|
|
636
|
-
"writing
|
|
693
|
+
"writing the backend. Only a program.panel command uses PluginProgramContext; a toolbar/move.panel " +
|
|
637
694
|
"plugin controls the robot with the SDK (RBManipulateSDK/RBAmrSDK) directly.",
|
|
638
695
|
inputSchema: {
|
|
639
696
|
query: z.string().describe('Keywords, e.g. "save form data" or "rb.action"'),
|
|
697
|
+
lang: z.enum(["python", "cpp"]).optional().describe("Backend language; omit to search both"),
|
|
640
698
|
},
|
|
641
699
|
},
|
|
642
|
-
async ({ query }) => {
|
|
643
|
-
const sections = searchReadme(query);
|
|
700
|
+
async ({ query, lang }) => {
|
|
701
|
+
const sections = await searchReadme(query, lang);
|
|
644
702
|
if (sections.length === 0) {
|
|
645
703
|
return { content: [{ type: "text", text: `No matching section found for "${query}".` }] };
|
|
646
704
|
}
|
|
@@ -747,8 +805,9 @@ export async function runMcp() {
|
|
|
747
805
|
"...) — call before writing py/main.py; requires rainbow-rb-sdk to be pip-installed. lang \"cpp\": " +
|
|
748
806
|
"the C++ SDK headers (apt rainbow-rb-sdk-dev: RBManipulateSDK, rb_plugin::PluginProgramContext, " +
|
|
749
807
|
"...) grouped by header path — call before writing cpp/main.cpp or an rclcpp node; requires the " +
|
|
750
|
-
"muscat-pk-cpp-dev Docker image (built by `muscat-pk dev:back`).
|
|
751
|
-
"
|
|
808
|
+
"muscat-pk-cpp-dev Docker image (built by `muscat-pk dev:back`). RBPluginCall (UI pub/sub/serve, " +
|
|
809
|
+
"router) exists in both: Python rainbow.rb_sdk.RBPluginCall, C++ rb_plugin::RBPluginCall " +
|
|
810
|
+
"(plugin/rb_plugin.hpp).",
|
|
752
811
|
inputSchema: { lang: z.enum(["python", "cpp"]).optional().describe('Default "python"') },
|
|
753
812
|
},
|
|
754
813
|
async ({ lang }) => {
|
package/cli/src/new.mjs
CHANGED
|
@@ -17,7 +17,7 @@ import { color, ensureIdeVenv, hasBin, kitVersion, parseArgs, prompt, templatesD
|
|
|
17
17
|
// Like cpSync(src, dst, {recursive:true}) but reads from the embedded snapshot
|
|
18
18
|
// when the templates dir doesn't exist on disk (compiled binary mode).
|
|
19
19
|
// ponytail: import() is module-cached by the runtime — no manual cache needed.
|
|
20
|
-
async function copyTemplate(srcDir, dst) {
|
|
20
|
+
export async function copyTemplate(srcDir, dst) {
|
|
21
21
|
if (existsSync(templatesDir)) { cpSync(srcDir, dst, { recursive: true }); return; }
|
|
22
22
|
const { TEMPLATES } = await import("./templates-embedded.mjs");
|
|
23
23
|
const prefix = srcDir.slice(templatesDir.length + 1).replace(/\\/g, "/") + "/";
|
|
@@ -226,6 +226,9 @@ export async function runNew(args) {
|
|
|
226
226
|
rmSync(resolve(target, "py"), { recursive: true, force: true });
|
|
227
227
|
rmSync(resolve(target, "requirements.txt"), { force: true });
|
|
228
228
|
}
|
|
229
|
+
if (runtimeChoice.type === "cpp" || runtimeChoice.rosClient === "rclcpp") {
|
|
230
|
+
await copyTemplate(resolve(templatesDir, "readme-cpp"), target);
|
|
231
|
+
}
|
|
229
232
|
|
|
230
233
|
applyTokens(target, {
|
|
231
234
|
__PLUGIN_ID__: pluginId,
|
|
@@ -250,7 +253,7 @@ export async function runNew(args) {
|
|
|
250
253
|
// gitignore (templates can't ship a literal .gitignore via npm reliably)
|
|
251
254
|
writeFileSync(
|
|
252
255
|
resolve(target, ".gitignore"),
|
|
253
|
-
["node_modules", "dist", ".muscat-dev", ".venv", "*.zip", "ros2_ws/install", "cpp/build", ""].join("\n"),
|
|
256
|
+
["node_modules", "dist", ".muscat-dev", ".venv", "*.zip", "ros2_ws/install", "ros2_ws/build*", "ros2_ws/log", "cpp/build*", ""].join("\n"),
|
|
254
257
|
);
|
|
255
258
|
|
|
256
259
|
if (!opts["no-install"]) {
|
package/cli/src/ports.mjs
CHANGED
|
@@ -4,7 +4,15 @@ import { usesHostNetwork } from "./zenohDev.mjs";
|
|
|
4
4
|
|
|
5
5
|
export const PLUGIN_PORT_MIN = 28600;
|
|
6
6
|
export const PLUGIN_PORT_MAX = 28999;
|
|
7
|
-
export const
|
|
7
|
+
export const MEDIA_PORTS = [8554, 8889, 8189]; // MediaMTX: RTSP publish, WebRTC, WebRTC media
|
|
8
|
+
export const INTERNAL_DEV_PORTS = new Set([10000, ...MEDIA_PORTS]);
|
|
9
|
+
|
|
10
|
+
export function mediaPortFlags() {
|
|
11
|
+
return [
|
|
12
|
+
"-p", "127.0.0.1:8554:8554", "-p", "127.0.0.1:8889:8889",
|
|
13
|
+
"-p", "127.0.0.1:8189:8189/udp", "-p", "127.0.0.1:8189:8189/tcp",
|
|
14
|
+
];
|
|
15
|
+
}
|
|
8
16
|
export const RESERVED_BACKEND_PORTS = new Map([
|
|
9
17
|
[502, "Modbus TCP"],
|
|
10
18
|
[4840, "OPC-UA"],
|
|
@@ -118,14 +126,21 @@ export function publishPortFlags(ranges = []) {
|
|
|
118
126
|
return ranges.flatMap((port) => ["-p", `127.0.0.1:${port}:${port}`]);
|
|
119
127
|
}
|
|
120
128
|
|
|
129
|
+
// Skips OS-assigned loopback listeners (127.0.0.1:0, e.g. the SDK's Zenoh session).
|
|
121
130
|
function listeningTcpPorts(container) {
|
|
122
131
|
const script = `
|
|
132
|
+
read -r eph_lo eph_hi < /proc/sys/net/ipv4/ip_local_port_range 2>/dev/null || { eph_lo=32768; eph_hi=60999; }
|
|
123
133
|
for f in /proc/net/tcp /proc/net/tcp6; do
|
|
124
134
|
[ -r "$f" ] || continue
|
|
125
135
|
tail -n +2 "$f" | while read -r sl local_address rem_address st rest; do
|
|
126
136
|
[ "$st" = "0A" ] || continue
|
|
127
|
-
|
|
128
|
-
|
|
137
|
+
hex_ip="\${local_address%%:*}"
|
|
138
|
+
port=$((0x\${local_address##*:}))
|
|
139
|
+
case "$hex_ip" in
|
|
140
|
+
0100007F|00000000000000000000000001000000)
|
|
141
|
+
[ "$port" -ge "$eph_lo" ] && [ "$port" -le "$eph_hi" ] && continue ;;
|
|
142
|
+
esac
|
|
143
|
+
echo "$port"
|
|
129
144
|
done
|
|
130
145
|
done | sort -n -u
|
|
131
146
|
`;
|
|
@@ -141,10 +156,9 @@ done | sort -n -u
|
|
|
141
156
|
}
|
|
142
157
|
|
|
143
158
|
export function startPortContractMonitor({ container, ranges, log, intervalMs = 1000, allow = [] }) {
|
|
144
|
-
// Under --network host
|
|
145
|
-
//
|
|
146
|
-
|
|
147
|
-
if (usesHostNetwork()) return () => false;
|
|
159
|
+
// Under --network host (or a local Muscat container's netns) /proc/net/tcp shows every
|
|
160
|
+
// host/robot service port, not just the plugin's — the contract can't be enforced.
|
|
161
|
+
if (usesHostNetwork() || process.env.MUSCAT_PK_MUSCAT_CONTAINER) return () => false;
|
|
148
162
|
const declared = ranges ?? [];
|
|
149
163
|
// muscat-pk's own dev plumbing shares this netns — not the plugin's doing.
|
|
150
164
|
const ignored = new Set([...INTERNAL_DEV_PORTS, ...allow.map(Number).filter(Number.isInteger)]);
|
package/cli/src/python.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { spawnSync } from "node:child_process";
|
|
4
4
|
import { existsSync } from "node:fs";
|
|
5
5
|
import { dirname, resolve } from "node:path";
|
|
6
|
-
import { canSkipImageBuild, dockerUserFlags, hasBin, log, pruneMuskatImages, resolveDockerfile } from "./util.mjs";
|
|
6
|
+
import { canSkipImageBuild, imageKey, imageKeyLabel, dockerUserFlags, hasBin, log, pruneMuskatImages, resolveDockerfile } from "./util.mjs";
|
|
7
7
|
|
|
8
8
|
export const PYTHON_DEV_IMAGE = "muscat-pk-python-dev";
|
|
9
9
|
|
|
@@ -15,16 +15,17 @@ export function assertDockerReady() {
|
|
|
15
15
|
|
|
16
16
|
/** Build the dev image from the kit's bundled Dockerfile (Docker layer-caches). */
|
|
17
17
|
export async function ensureImage(image = PYTHON_DEV_IMAGE, { platform = null } = {}) {
|
|
18
|
-
|
|
18
|
+
const dockerfile = await resolveDockerfile("Dockerfile.python");
|
|
19
|
+
const key = imageKey(dockerfile);
|
|
20
|
+
if (canSkipImageBuild(image, key)) {
|
|
19
21
|
log.step(`${image} already built — skipping docker build`);
|
|
20
22
|
return;
|
|
21
23
|
}
|
|
22
|
-
const dockerfile = await resolveDockerfile("Dockerfile.python");
|
|
23
24
|
log.step(`docker build -t ${image}${platform ? ` (${platform})` : ""} (first run is slow, then cached)…`);
|
|
24
25
|
const platformFlags = platform ? ["--platform", platform] : [];
|
|
25
26
|
const r = spawnSync(
|
|
26
27
|
"docker",
|
|
27
|
-
["build", ...platformFlags, "-t", image, "-f", dockerfile, dirname(dockerfile)],
|
|
28
|
+
["build", ...platformFlags, ...imageKeyLabel(key), "-t", image, "-f", dockerfile, dirname(dockerfile)],
|
|
28
29
|
{ stdio: "inherit" },
|
|
29
30
|
);
|
|
30
31
|
if (r.status !== 0) throw new Error("docker build failed.");
|
|
@@ -81,10 +82,9 @@ export async function lockAndVendor(pluginRoot, archs = ["amd64", "arm64"]) {
|
|
|
81
82
|
if (lockResult.status !== 0) throw new Error("lock generation failed (uv pip compile)");
|
|
82
83
|
log.ok("dist/requirements.lock ready");
|
|
83
84
|
|
|
84
|
-
const failed = [];
|
|
85
85
|
for (const arch of archs) {
|
|
86
86
|
const pipPlatforms = PIP_PLATFORM[arch];
|
|
87
|
-
if (!pipPlatforms)
|
|
87
|
+
if (!pipPlatforms) throw new Error(`unknown arch '${arch}'`);
|
|
88
88
|
log.step(`vendoring wheels for ${arch}…`);
|
|
89
89
|
const r = spawnSync(
|
|
90
90
|
"docker",
|
|
@@ -102,16 +102,8 @@ export async function lockAndVendor(pluginRoot, archs = ["amd64", "arm64"]) {
|
|
|
102
102
|
],
|
|
103
103
|
{ stdio: "inherit" },
|
|
104
104
|
);
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
} else {
|
|
109
|
-
log.ok(`dist/wheels/ ready (${arch})`);
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
if (failed.length === archs.length) {
|
|
114
|
-
throw new Error(`wheel vendoring failed for all archs: ${failed.join(", ")}`);
|
|
105
|
+
// A ZIP missing wheels fails to import on the robot — stop instead.
|
|
106
|
+
if (r.status !== 0) throw new Error(`wheel download failed for ${arch} (see pip output above)`);
|
|
107
|
+
log.ok(`dist/wheels/ ready (${arch})`);
|
|
115
108
|
}
|
|
116
|
-
return failed;
|
|
117
109
|
}
|
package/cli/src/ros2.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { spawnSync } from "node:child_process";
|
|
4
4
|
import { existsSync, readdirSync } from "node:fs";
|
|
5
5
|
import { dirname, resolve } from "node:path";
|
|
6
|
-
import { canSkipImageBuild, hasBin, log, pruneMuskatImages, resolveDockerfile } from "./util.mjs";
|
|
6
|
+
import { canSkipImageBuild, imageKey, imageKeyLabel, hasBin, log, pruneMuskatImages, resolveDockerfile } from "./util.mjs";
|
|
7
7
|
|
|
8
8
|
export const ROS2_DEV_IMAGE = "muscat-pk-ros2-dev";
|
|
9
9
|
|
|
@@ -30,15 +30,16 @@ export const ARCH_DOCKER_PLATFORM = {
|
|
|
30
30
|
|
|
31
31
|
/** Build the dev image from the kit's bundled Dockerfile (Docker layer-caches). */
|
|
32
32
|
export async function ensureImage(image = ROS2_DEV_IMAGE, { platform = null } = {}) {
|
|
33
|
-
|
|
33
|
+
const dockerfile = await resolveDockerfile("Dockerfile.dev");
|
|
34
|
+
const sdkVer = process.env.MUSCAT_PK_RB_SDK_VERSION;
|
|
35
|
+
const key = imageKey(dockerfile, sdkVer ?? "");
|
|
36
|
+
if (canSkipImageBuild(image, key)) {
|
|
34
37
|
log.step(`${image} already built — skipping docker build`);
|
|
35
38
|
return;
|
|
36
39
|
}
|
|
37
|
-
const dockerfile = await resolveDockerfile("Dockerfile.dev");
|
|
38
40
|
log.step(`docker build -t ${image}${platform ? ` (${platform})` : ""} (first run is slow, then cached)…`);
|
|
39
41
|
const platformFlags = platform ? ["--platform", platform] : [];
|
|
40
|
-
const
|
|
41
|
-
const buildArgs = sdkVer ? ["--build-arg", `RB_SDK_VERSION=${sdkVer}`] : [];
|
|
42
|
+
const buildArgs = [...(sdkVer ? ["--build-arg", `RB_SDK_VERSION=${sdkVer}`] : []), ...imageKeyLabel(key)];
|
|
42
43
|
const r = spawnSync(
|
|
43
44
|
"docker",
|
|
44
45
|
["build", ...platformFlags, ...buildArgs, "-t", image, "-f", dockerfile, dirname(dockerfile)],
|
|
@@ -73,12 +74,16 @@ export function colconBuild(root, { image = ROS2_DEV_IMAGE, symlink = false, arc
|
|
|
73
74
|
installBase = "../dist/ros2_ws/install";
|
|
74
75
|
}
|
|
75
76
|
const flag = symlink ? "--symlink-install" : "";
|
|
77
|
+
const buildBase = symlink ? "build" : arch ? `build-${arch}` : "build-dist";
|
|
76
78
|
const script = [
|
|
77
79
|
"pip install -r requirements.txt --break-system-packages --ignore-installed -q || true",
|
|
78
80
|
"cd ros2_ws",
|
|
79
|
-
//
|
|
80
|
-
|
|
81
|
-
|
|
81
|
+
// Export builds use their own build cache: sharing build/ with dev's --symlink-install
|
|
82
|
+
// (or another arch) breaks the next colcon build.
|
|
83
|
+
// rclcpp export: ship librb_sdk.so in <pkg>/lib/ (robot has no SDK) when the node links it.
|
|
84
|
+
`colcon build ${flag} --build-base ${buildBase} --install-base ${installBase}`.replace(" ", " ") +
|
|
85
|
+
(arch ? ` --cmake-args '-DCMAKE_INSTALL_RPATH=$ORIGIN/..'` : ""),
|
|
86
|
+
...(arch ? [`for d in ${installBase}/*/lib; do if readelf -d $(find "$d" -mindepth 2 -type f -executable) 2>/dev/null | grep -q librb_sdk; then cp -a $(dpkg -L rainbow-rb-sdk | grep -E '/librb_sdk[.]so[.][0-9]') "$d"/; fi; done`] : []),
|
|
82
87
|
].join(" && ");
|
|
83
88
|
const label = arch
|
|
84
89
|
? `colcon build (${arch}) → dist/ros2_ws/install-${arch}/…`
|