@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.
Files changed (106) hide show
  1. package/cli/docker/Dockerfile.cpp +22 -5
  2. package/cli/docker/Dockerfile.dev +25 -5
  3. package/cli/docker/Dockerfile.python +19 -0
  4. package/cli/src/bridge.mjs +19 -2
  5. package/cli/src/build.mjs +31 -79
  6. package/cli/src/cpp.mjs +10 -6
  7. package/cli/src/dev.mjs +7 -2
  8. package/cli/src/devBack.mjs +39 -32
  9. package/cli/src/host_agent.py +143 -83
  10. package/cli/src/mcp.mjs +75 -16
  11. package/cli/src/new.mjs +5 -2
  12. package/cli/src/ports.mjs +21 -7
  13. package/cli/src/python.mjs +9 -17
  14. package/cli/src/ros2.mjs +13 -8
  15. package/cli/src/sdk.mjs +23 -21
  16. package/cli/src/templates-embedded.mjs +1 -1
  17. package/cli/src/type.mjs +42 -8
  18. package/cli/src/util.mjs +53 -5
  19. package/cli/storybook-dist/assets/{Color-YHDXOIA2-CRNvRzJD.js → Color-YHDXOIA2-DNIJVGRw.js} +1 -1
  20. package/cli/storybook-dist/assets/{Docs-80l_9VTs.js → Docs-CIT3omBi.js} +1 -1
  21. package/cli/storybook-dist/assets/{Docs-BgDx9SSn.js → Docs-CdX_ToEv.js} +1 -1
  22. package/cli/storybook-dist/assets/{Docs-BV0FgAF_.js → Docs-D9eMx0Cp.js} +1 -1
  23. package/cli/storybook-dist/assets/{Docs-C4S_Kfa9.js → Docs-RJox-ekp.js} +1 -1
  24. package/cli/storybook-dist/assets/{Docs-axk94PIl.js → Docs-wnrZBY76.js} +1 -1
  25. package/cli/storybook-dist/assets/{Docs-DurNQvJo.js → Docs-x99QNSvG.js} +1 -1
  26. package/cli/storybook-dist/assets/{DocsRenderer-CFRXHY34-CEdKXElc.js → DocsRenderer-CFRXHY34-CPuvjoGb.js} +1 -1
  27. package/cli/storybook-dist/assets/{NxAvatar.stories-CyZ4AE2b.js → NxAvatar.stories-B3OxXBgc.js} +1 -1
  28. package/cli/storybook-dist/assets/{NxButton.stories-Bx8UtGNd.js → NxButton.stories-UTqX8ZkA.js} +1 -1
  29. package/cli/storybook-dist/assets/{NxNumberInput.stories-skizAdd5.js → NxNumberInput.stories-GoLEe-uJ.js} +1 -1
  30. package/cli/storybook-dist/assets/{NxPagination.stories-gtIxt2u0.js → NxPagination.stories-Jj6dt6eG.js} +1 -1
  31. package/cli/storybook-dist/assets/{NxPointerHoldButton.stories-DSHMk3N5.js → NxPointerHoldButton.stories-tLN9u4nC.js} +1 -1
  32. package/cli/storybook-dist/assets/{NxRangeInput.stories-C1YtrFZ1.js → NxRangeInput.stories-C-MK73xb.js} +1 -1
  33. package/cli/storybook-dist/assets/{NxSelectBox.stories-DCySAtBp.js → NxSelectBox.stories-CBcFcOIX.js} +1 -1
  34. package/cli/storybook-dist/assets/{NxSlideRange.stories-gSaMOWo7.js → NxSlideRange.stories-oNQkoW66.js} +1 -1
  35. package/cli/storybook-dist/assets/{NxSwitch.stories-DF0DBiyG.js → NxSwitch.stories-CqBkO5GC.js} +1 -1
  36. package/cli/storybook-dist/assets/{NxTextArea.stories-Dill0M6z.js → NxTextArea.stories-DtysSyl4.js} +1 -1
  37. package/cli/storybook-dist/assets/{NxTextInput.stories-Bax14r48.js → NxTextInput.stories-BOLDbSgF.js} +1 -1
  38. package/cli/storybook-dist/assets/{RBAccordion.stories-CCwz_pIV.js → RBAccordion.stories-D9-wu9LY.js} +1 -1
  39. package/cli/storybook-dist/assets/{RBAccordionGroup.stories-BjDxxdPV.js → RBAccordionGroup.stories-BPfnIKm6.js} +1 -1
  40. package/cli/storybook-dist/assets/{RBAlert.stories-5z6GasgP.js → RBAlert.stories-CGn-c2ac.js} +1 -1
  41. package/cli/storybook-dist/assets/{RBBadge.stories-BwcM7i7w.js → RBBadge.stories-CBLmeoq1.js} +1 -1
  42. package/cli/storybook-dist/assets/{RBBattery.stories-DOeQH3rK.js → RBBattery.stories-oo47rbDh.js} +1 -1
  43. package/cli/storybook-dist/assets/{RBCard.stories-DcmiOXAV.js → RBCard.stories-SiD2VfQ4.js} +1 -1
  44. package/cli/storybook-dist/assets/{RBCheckBox.stories-BYru87fX.js → RBCheckBox.stories-sEEVrjSg.js} +1 -1
  45. package/cli/storybook-dist/assets/{RBCheckIconButton.stories-CXUjtR1a.js → RBCheckIconButton.stories-C5GQUjZf.js} +1 -1
  46. package/cli/storybook-dist/assets/{RBCheckLabelButton.stories-BSOQV7Du.js → RBCheckLabelButton.stories-DTaTqBvJ.js} +1 -1
  47. package/cli/storybook-dist/assets/{RBChipTab.stories-ByBmeukU.js → RBChipTab.stories-3AZr4kOz.js} +1 -1
  48. package/cli/storybook-dist/assets/{RBChipsBoxTab.stories-GtJ01RTr.js → RBChipsBoxTab.stories-hCJnBYQl.js} +1 -1
  49. package/cli/storybook-dist/assets/{RBColorIcons.stories-C9saET1u.js → RBColorIcons.stories-BX4RJygu.js} +1 -1
  50. package/cli/storybook-dist/assets/{RBCommand.stories-CcvnWlWZ.js → RBCommand.stories-BidZ4H8f.js} +1 -1
  51. package/cli/storybook-dist/assets/{RBConfirm.stories-hdBVSdBS.js → RBConfirm.stories-D3Txw1kT.js} +1 -1
  52. package/cli/storybook-dist/assets/{RBDateInput.stories-BE9JQl-E.js → RBDateInput.stories-CLxRaN6T.js} +1 -1
  53. package/cli/storybook-dist/assets/{RBDialog.stories-BaWuDgl_.js → RBDialog.stories-CEhmUGp-.js} +1 -1
  54. package/cli/storybook-dist/assets/{RBDivider.stories-D3INKm_Z.js → RBDivider.stories-peXP2KG9.js} +1 -1
  55. package/cli/storybook-dist/assets/{RBDrawer.stories-BZ8V0J6-.js → RBDrawer.stories-38iDf6uC.js} +1 -1
  56. package/cli/storybook-dist/assets/{RBDropdownMenu.stories-DxLi8HRw.js → RBDropdownMenu.stories-Dsi8tqSy.js} +1 -1
  57. package/cli/storybook-dist/assets/{RBEmptyView.stories-DQcvDt2S.js → RBEmptyView.stories-VMxWhzLP.js} +1 -1
  58. package/cli/storybook-dist/assets/{RBIconButton.stories-DJfuTpQ0.js → RBIconButton.stories-BPLhceGO.js} +1 -1
  59. package/cli/storybook-dist/assets/{RBIcons.stories-BSsyb0BK.js → RBIcons.stories-DuYdO951.js} +1 -1
  60. package/cli/storybook-dist/assets/{RBInlineLabelButton.stories-Di3th--x.js → RBInlineLabelButton.stories-CGNYhQyl.js} +1 -1
  61. package/cli/storybook-dist/assets/{RBJoyStick.stories-DfGazm4n.js → RBJoyStick.stories-CCmn-LMy.js} +1 -1
  62. package/cli/storybook-dist/assets/{RBLabelButton.stories-CLbxzW56.js → RBLabelButton.stories-IkKjthGk.js} +1 -1
  63. package/cli/storybook-dist/assets/{RBLight.stories-DeiuDm_P.js → RBLight.stories-eJFQOjPH.js} +1 -1
  64. package/cli/storybook-dist/assets/{RBMenubar.stories-BZQTV9e5.js → RBMenubar.stories-kg-xXGrW.js} +1 -1
  65. package/cli/storybook-dist/assets/{RBMultiRangeInput.stories-DDNmdi_n.js → RBMultiRangeInput.stories-Czr1r3gd.js} +1 -1
  66. package/cli/storybook-dist/assets/{RBPagination.stories-CLBCEyCU.js → RBPagination.stories-Dyu6Khnl.js} +1 -1
  67. package/cli/storybook-dist/assets/{RBPopover.stories-DRI5Kfa-.js → RBPopover.stories-CAM6hpf2.js} +1 -1
  68. package/cli/storybook-dist/assets/{RBPrompt.stories-DIAmypoJ.js → RBPrompt.stories-B4M-veaf.js} +1 -1
  69. package/cli/storybook-dist/assets/{RBRadioBox.stories-BS_N4aLf.js → RBRadioBox.stories-DeAOKvil.js} +1 -1
  70. package/cli/storybook-dist/assets/{RBRadioButton.stories-BTm8cYvm.js → RBRadioButton.stories-DjXwYXFy.js} +1 -1
  71. package/cli/storybook-dist/assets/{RBRangeDateInput.stories-D5hBsSyB.js → RBRangeDateInput.stories-9pzKhXGf.js} +1 -1
  72. package/cli/storybook-dist/assets/{RBResizable.stories-CxPEkbXQ.js → RBResizable.stories-B4Hi4DUD.js} +1 -1
  73. package/cli/storybook-dist/assets/{RBSelectBox.stories-FUQ7AcUi.js → RBSelectBox.stories-tVVv4c6z.js} +1 -1
  74. package/cli/storybook-dist/assets/{RBSheet.stories-mhVHH_71.js → RBSheet.stories-BwOCmaNN.js} +1 -1
  75. package/cli/storybook-dist/assets/{RBSideNumberInput.stories-5FMkLUim.js → RBSideNumberInput.stories-BbGOIitU.js} +1 -1
  76. package/cli/storybook-dist/assets/{RBSlider.stories-Bnhcp9pz.js → RBSlider.stories-yIDdawYQ.js} +1 -1
  77. package/cli/storybook-dist/assets/{RBSpinner.stories-Bf4416mP.js → RBSpinner.stories-CIJvpbgK.js} +1 -1
  78. package/cli/storybook-dist/assets/{RBSwitch.stories-BNhFjRaV.js → RBSwitch.stories-Bo8hrSiq.js} +1 -1
  79. package/cli/storybook-dist/assets/{RBTab.stories-Bsr7jlfb.js → RBTab.stories-Cq3N4Dgb.js} +1 -1
  80. package/cli/storybook-dist/assets/{RBTable.stories-BkA6pKzs.js → RBTable.stories-DDeZf-Wb.js} +1 -1
  81. package/cli/storybook-dist/assets/{RBTableRow.stories-D07Z7D5-.js → RBTableRow.stories-BeKVMSo3.js} +1 -1
  82. package/cli/storybook-dist/assets/{RBTabs.stories-DHnZiV21.js → RBTabs.stories-DovB7PPB.js} +1 -1
  83. package/cli/storybook-dist/assets/{RBTextArea.stories-CtLMLuV_.js → RBTextArea.stories-D7H4RvP8.js} +1 -1
  84. package/cli/storybook-dist/assets/{RBTextField.stories-Chz0i1-u.js → RBTextField.stories-CmkNXeEk.js} +1 -1
  85. package/cli/storybook-dist/assets/{RBTextInput.stories-Dk5cZfUF.js → RBTextInput.stories-hnxbQJsb.js} +1 -1
  86. package/cli/storybook-dist/assets/{RBToast.stories-tZxbjmr8.js → RBToast.stories-BklGGkNh.js} +1 -1
  87. package/cli/storybook-dist/assets/{RBToggle.stories-VTe7a4xZ.js → RBToggle.stories-C56AXyxq.js} +1 -1
  88. package/cli/storybook-dist/assets/{RBToggleGroup.stories-WiFnGGYx.js → RBToggleGroup.stories-CpLPdAF0.js} +1 -1
  89. package/cli/storybook-dist/assets/{RBTooltip.stories-B_L4W7Mn.js → RBTooltip.stories-BdOnCwGk.js} +1 -1
  90. package/cli/storybook-dist/assets/{SideNumberInput-os5wKKf7.js → SideNumberInput-DLmM2_Dy.js} +80 -80
  91. package/cli/storybook-dist/assets/{entry-preview-COJgWzHb.js → entry-preview-DqMcNOGI.js} +1 -1
  92. package/cli/storybook-dist/assets/{iframe-DQEjJuXA.js → iframe-C1kyDB-R.js} +2 -2
  93. package/cli/storybook-dist/assets/{index-DEgQqQ_-.js → index-CdVSDe2m.js} +3 -3
  94. package/cli/storybook-dist/assets/{preview-BZ5PpEp_.js → preview-C_JfiDjl.js} +2 -2
  95. package/cli/storybook-dist/assets/preview-DvwEjyb4.js +3 -0
  96. package/cli/storybook-dist/iframe.html +1 -1
  97. package/cli/storybook-dist/project.json +1 -1
  98. package/cli/templates/common/README.md +65 -4
  99. package/cli/templates/common/py/main.py +5 -1
  100. package/cli/templates/common/requirements.txt +3 -3
  101. package/cli/templates/cpp/cpp/CMakeLists.txt +1 -2
  102. package/cli/templates/cpp/cpp/main.cpp +48 -8
  103. package/cli/templates/readme-cpp/README.md +969 -0
  104. package/cli/templates/ros2-cpp/ros2_ws/src/__PLUGIN_SLUG__/CMakeLists.txt +1 -1
  105. package/package.json +3 -3
  106. package/cli/storybook-dist/assets/preview-CmCb8Xea.js +0 -3
@@ -1,10 +1,7 @@
1
- """Host-side raw Zenoh byte-proxy.
1
+ """Host-side Zenoh byte-proxy for Docker Desktop containers (Mac/WSL remote dev).
2
2
 
3
- Runs on the host as a normal Zenoh peer (reverse-dials the robot, same as the
4
- SDK's own connect()) and serves a tiny domain-agnostic byte protocol over TCP
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 _handle_conn(conn: socket.socket, zenoh_client) -> None:
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
- sub_ids: dict[str, int] = {}
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
- if op == "query":
58
- payload = bytes.fromhex(req["payload_hex"]) if req.get("payload_hex") else None
59
- try:
60
- replies = zenoh_client._client.query(
61
- req["keyexpr"], payload, req.get("timeout_ms", 2000), req.get("target", 0)
62
- )
63
- out = {
64
- "type": "query_resp", "id": req["id"], "ok": True,
65
- "replies": [
66
- {"key": k, "payload_hex": bytes(p).hex(), "att": a, "encoding": e, "is_error": ie}
67
- for (k, p, a, e, ie) in replies
68
- ],
69
- }
70
- except Exception as exc:
71
- out = {"type": "query_resp", "id": req["id"], "ok": False, "error": f"{type(exc).__name__}: {exc}"}
72
- _send_frame(conn, send_lock, out)
73
-
74
- elif op == "put":
75
- payload = bytes.fromhex(req["payload_hex"]) if req.get("payload_hex") else b""
76
- try:
77
- zenoh_client._client.publish(req["keyexpr"], payload, "", 0, 5, False)
78
- out = {"type": "put_resp", "id": req["id"], "ok": True}
79
- except Exception as exc:
80
- out = {"type": "put_resp", "id": req["id"], "ok": False, "error": f"{type(exc).__name__}: {exc}"}
81
- _send_frame(conn, send_lock, out)
82
-
83
- elif op == "subscribe":
84
- keyexpr = req["keyexpr"]
85
-
86
- def on_sample(topic: str, payload_bytes: bytes, att: str, _ke: str = keyexpr) -> None:
87
- _send_frame(conn, send_lock, {
88
- "type": "sample", "keyexpr": _ke, "key": topic,
89
- "payload_hex": bytes(payload_bytes).hex(), "att": att,
90
- })
91
-
92
- try:
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 sub_id in sub_ids.values():
116
- try:
117
- zenoh_client._client.undeclare_subscriber(sub_id)
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
- def main() -> None:
124
- from rainbow.rb_sdk.base import RBBaseSDK
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
- # The robot reverse-dials the advertised host:port; force it to the host's
133
- # real LAN IP (matches what dev.mjs computed via ensureRemoteZenohEnv).
134
- RBBaseSDK._detect_local_ip = staticmethod(lambda _host: advertise)
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 = RBBaseSDK()
137
- connect_kwargs = {"host": host, "zenoh_port": zenoh_port, "mesh_timeout": 15.0}
138
- if token:
139
- connect_kwargs["token"] = token
140
-
141
- asyncio.run(peer.connect(**connect_kwargs))
142
- print(f"[host-agent] zenoh peer joined robot mesh (listening :{zenoh_port})", flush=True)
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, peer.zenoh_client), daemon=True).start()
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
- asyncio.run(peer.disconnect())
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 TEMPLATE_README = join(kitRoot, "cli/templates/common/README.md");
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
- function searchReadme(query) {
264
- if (!existsSync(TEMPLATE_README)) return [];
265
- const text = readFileSync(TEMPLATE_README, "utf8");
266
- const sections = splitSections(text);
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), contribution points, and backend runtimes " +
634
- "(Python/C++/ROS2). IMPORTANT: " +
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 py/main.py. Only a program.panel command uses PluginProgramContext; a toolbar/move.panel " +
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`). Note: RBPluginCall (UI pub/sub/" +
751
- "serve) exists in Python only.",
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 INTERNAL_DEV_PORTS = new Set([10000]);
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
- hex_port="\${local_address##*:}"
128
- printf "%d\\n" "0x$hex_port"
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 the container shares the host's netns, so
145
- // /proc/net/tcp shows every host/robot service port, not just the plugin's —
146
- // the contract can't be enforced. Skip it (no isolated ports to police).
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)]);
@@ -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
- if (canSkipImageBuild(image)) {
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) { log.warn(`unknown arch '${arch}' — skipped`); continue; }
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
- if (r.status !== 0) {
106
- log.warn(`wheel vendoring failed for ${arch}`);
107
- failed.push(arch);
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
- if (canSkipImageBuild(image)) {
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 sdkVer = process.env.MUSCAT_PK_RB_SDK_VERSION;
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
- // ponytail: rm only on cross-arch; same-arch dev rebuilds keep the cache
80
- ...(arch ? ["rm -rf build"] : []),
81
- `colcon build ${flag} --install-base ${installBase}`.trim(),
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}/…`