@rainbow-robotics/muscat-plugin-kit 1.10.1-dev.1 → 1.10.1

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 (205) hide show
  1. package/README.md +282 -282
  2. package/cli/README.md +274 -274
  3. package/cli/bin/create-muscat-plugin.mjs +8 -8
  4. package/cli/bin/muscat-pk.mjs +155 -155
  5. package/cli/bundle-templates.mjs +31 -31
  6. package/cli/docker/Dockerfile.cpp +28 -28
  7. package/cli/docker/Dockerfile.dev +30 -30
  8. package/cli/docker/Dockerfile.python +21 -21
  9. package/cli/m5-e2e.mjs +69 -69
  10. package/cli/prepack.mjs +10 -10
  11. package/cli/src/ai-rules.mjs +70 -70
  12. package/cli/src/build.mjs +447 -447
  13. package/cli/src/check-package.mjs +13 -13
  14. package/cli/src/completion.mjs +166 -166
  15. package/cli/src/cpp.mjs +78 -78
  16. package/cli/src/cppIde.mjs +75 -75
  17. package/cli/src/dev.mjs +165 -165
  18. package/cli/src/devBack.mjs +734 -727
  19. package/cli/src/devUi.mjs +247 -243
  20. package/cli/src/esbuild.mjs +56 -56
  21. package/cli/src/host_agent.py +223 -223
  22. package/cli/src/manifest.mjs +287 -287
  23. package/cli/src/mcp.mjs +1071 -1071
  24. package/cli/src/muscatLocal.mjs +209 -209
  25. package/cli/src/new.mjs +343 -343
  26. package/cli/src/pluginDevServer.mjs +111 -111
  27. package/cli/src/ports.mjs +193 -193
  28. package/cli/src/python.mjs +112 -112
  29. package/cli/src/ros2.mjs +109 -109
  30. package/cli/src/sdk.mjs +117 -117
  31. package/cli/src/storybook.mjs +168 -168
  32. package/cli/src/type.mjs +141 -141
  33. package/cli/src/uninstall.mjs +81 -81
  34. package/cli/src/update.mjs +295 -295
  35. package/cli/src/util.mjs +441 -441
  36. package/cli/src/zenohDev.mjs +83 -81
  37. package/cli/storybook-dist/_design-tokens.css +4957 -4957
  38. package/cli/storybook-dist/_stories-src/NxAvatar.stories.tsx +27 -27
  39. package/cli/storybook-dist/_stories-src/NxPagination.stories.tsx +28 -28
  40. package/cli/storybook-dist/_stories-src/RBAccordion/RBAccordion.stories.tsx +46 -46
  41. package/cli/storybook-dist/_stories-src/RBAccordion/RBAccordionGroup.stories.tsx +73 -73
  42. package/cli/storybook-dist/_stories-src/RBBadge/RBBadge.stories.tsx +93 -93
  43. package/cli/storybook-dist/_stories-src/RBBattery/RBBattery.stories.tsx +74 -74
  44. package/cli/storybook-dist/_stories-src/RBButton/RBCheckIconButton.stories.tsx +28 -28
  45. package/cli/storybook-dist/_stories-src/RBButton/RBCheckLabelButton.stories.tsx +84 -84
  46. package/cli/storybook-dist/_stories-src/RBButton/RBIconButton.stories.tsx +57 -57
  47. package/cli/storybook-dist/_stories-src/RBButton/RBInlineLabelButton.stories.tsx +41 -41
  48. package/cli/storybook-dist/_stories-src/RBButton/RBLabelButton.stories.tsx +60 -60
  49. package/cli/storybook-dist/_stories-src/RBDatePicker/RBDateInput.stories.tsx +44 -44
  50. package/cli/storybook-dist/_stories-src/RBDatePicker/RBRangeDateInput.stories.tsx +56 -56
  51. package/cli/storybook-dist/_stories-src/RBDialog/RBAlert.stories.tsx +51 -51
  52. package/cli/storybook-dist/_stories-src/RBDialog/RBConfirm.stories.tsx +55 -55
  53. package/cli/storybook-dist/_stories-src/RBDialog/RBPrompt.stories.tsx +52 -52
  54. package/cli/storybook-dist/_stories-src/RBDivider/RBDivider.stories.tsx +93 -93
  55. package/cli/storybook-dist/_stories-src/RBEmptyView/RBEmptyView.stories.tsx +49 -49
  56. package/cli/storybook-dist/_stories-src/RBIcons/RBColorIcons.stories.tsx +108 -108
  57. package/cli/storybook-dist/_stories-src/RBIcons/RBIcons.stories.tsx +106 -106
  58. package/cli/storybook-dist/_stories-src/RBInput/RBCheckBox.stories.tsx +89 -89
  59. package/cli/storybook-dist/_stories-src/RBInput/RBMultiRangeInput.stories.tsx +64 -64
  60. package/cli/storybook-dist/_stories-src/RBInput/RBRadioBox.stories.tsx +65 -65
  61. package/cli/storybook-dist/_stories-src/RBInput/RBRadioButton.stories.tsx +68 -68
  62. package/cli/storybook-dist/_stories-src/RBInput/RBSelectBox.stories.tsx +216 -216
  63. package/cli/storybook-dist/_stories-src/RBInput/RBSideNumberInput.stories.tsx +90 -90
  64. package/cli/storybook-dist/_stories-src/RBInput/RBText/RBTextArea.stories.tsx +45 -45
  65. package/cli/storybook-dist/_stories-src/RBInput/RBText/RBTextInput.stories.tsx +50 -50
  66. package/cli/storybook-dist/_stories-src/RBInput/RBTextField.stories.tsx +102 -102
  67. package/cli/storybook-dist/_stories-src/RBJoyStick/RBJoyStick.stories.tsx +31 -31
  68. package/cli/storybook-dist/_stories-src/RBLight/RBLight.stories.tsx +36 -36
  69. package/cli/storybook-dist/_stories-src/RBPagination/RBPagination.stories.tsx +49 -49
  70. package/cli/storybook-dist/_stories-src/RBShadcn/RBCard/RBCard.stories.tsx +89 -89
  71. package/cli/storybook-dist/_stories-src/RBShadcn/RBCommand/RBCommand.stories.tsx +116 -116
  72. package/cli/storybook-dist/_stories-src/RBShadcn/RBDialog/RBDialog.stories.tsx +74 -74
  73. package/cli/storybook-dist/_stories-src/RBShadcn/RBDrawer/RBDrawer.stories.tsx +99 -99
  74. package/cli/storybook-dist/_stories-src/RBShadcn/RBDropdownMenu/RBDropdownMenu.stories.tsx +123 -123
  75. package/cli/storybook-dist/_stories-src/RBShadcn/RBMenubar/RBMenubar.stories.tsx +135 -135
  76. package/cli/storybook-dist/_stories-src/RBShadcn/RBPopover/RBPopover.stories.tsx +69 -69
  77. package/cli/storybook-dist/_stories-src/RBShadcn/RBResizable/RBResizable.stories.tsx +49 -49
  78. package/cli/storybook-dist/_stories-src/RBShadcn/RBSheet/RBSheet.stories.tsx +91 -91
  79. package/cli/storybook-dist/_stories-src/RBShadcn/RBSlider/RBSlider.stories.tsx +38 -38
  80. package/cli/storybook-dist/_stories-src/RBShadcn/RBSwitch/RBSwitch.stories.tsx +112 -112
  81. package/cli/storybook-dist/_stories-src/RBShadcn/RBTabs/RBTabs.stories.tsx +75 -75
  82. package/cli/storybook-dist/_stories-src/RBShadcn/RBToggle/RBToggle.stories.tsx +51 -51
  83. package/cli/storybook-dist/_stories-src/RBShadcn/RBToggleGroup/RBToggleGroup.stories.tsx +84 -84
  84. package/cli/storybook-dist/_stories-src/RBTab/RBChipTab.stories.tsx +102 -102
  85. package/cli/storybook-dist/_stories-src/RBTab/RBChipsBoxTab.stories.tsx +78 -78
  86. package/cli/storybook-dist/_stories-src/RBTab/RBTab.stories.tsx +57 -57
  87. package/cli/storybook-dist/_stories-src/RBTable/RBTable.stories.tsx +177 -177
  88. package/cli/storybook-dist/_stories-src/RBTable/RBTableRow.stories.tsx +64 -64
  89. package/cli/storybook-dist/_stories-src/RBToast/RBToast.stories.tsx +117 -117
  90. package/cli/storybook-dist/_stories-src/RBTooltip/RBTooltip.stories.tsx +50 -50
  91. package/cli/storybook-dist/_stories-src/loading/RBSpinner.stories.tsx +30 -30
  92. package/cli/storybook-dist/_stories-src/nx-button/NxButton.stories.tsx +54 -54
  93. package/cli/storybook-dist/_stories-src/nx-button/NxPointerHoldButton.stories.tsx +57 -57
  94. package/cli/storybook-dist/_stories-src/nx-icons/NxIcons.stories.tsx +48 -48
  95. package/cli/storybook-dist/_stories-src/nx-inputs/NxNumberInput.stories.tsx +103 -103
  96. package/cli/storybook-dist/_stories-src/nx-inputs/NxRangeInput.stories.tsx +27 -27
  97. package/cli/storybook-dist/_stories-src/nx-inputs/NxSelectBox.stories.tsx +67 -67
  98. package/cli/storybook-dist/_stories-src/nx-inputs/NxSlideRange.stories.tsx +45 -45
  99. package/cli/storybook-dist/_stories-src/nx-inputs/NxSwitch.stories.tsx +27 -27
  100. package/cli/storybook-dist/_stories-src/nx-inputs/NxTextArea.stories.tsx +48 -48
  101. package/cli/storybook-dist/_stories-src/nx-inputs/NxTextInput.stories.tsx +46 -46
  102. package/cli/storybook-dist/assets/{Color-YHDXOIA2-D6JqZzDJ.js → Color-YHDXOIA2-B4IER17l.js} +1 -1
  103. package/cli/storybook-dist/assets/{Docs-ESb7JAHg.js → Docs-C8GCHFtK.js} +3 -3
  104. package/cli/storybook-dist/assets/{Docs-6gD2kUo1.js → Docs-CgdEMPQP.js} +2 -2
  105. package/cli/storybook-dist/assets/{Docs-B9wd8Ok7.js → Docs-Ck3WHvk6.js} +3 -3
  106. package/cli/storybook-dist/assets/{Docs-CgFbWWXM.js → Docs-D9NHsoeZ.js} +2 -2
  107. package/cli/storybook-dist/assets/{Docs-DBQup8Bu.js → Docs-DNYQC7nl.js} +3 -3
  108. package/cli/storybook-dist/assets/{Docs-CMjovnSY.js → Docs-DUfeiM4I.js} +4 -4
  109. package/cli/storybook-dist/assets/{DocsRenderer-CFRXHY34-BOUI1AHi.js → DocsRenderer-CFRXHY34-C0WhyDoR.js} +1 -1
  110. package/cli/storybook-dist/assets/{NxAvatar.stories-DsYFMUh3.js → NxAvatar.stories-CwnK8AUF.js} +1 -1
  111. package/cli/storybook-dist/assets/{NxButton.stories-CHfNcqsc.js → NxButton.stories-ueYdosfQ.js} +8 -8
  112. package/cli/storybook-dist/assets/{NxNumberInput.stories-BFw_4vFE.js → NxNumberInput.stories-M5ZCLbrK.js} +1 -1
  113. package/cli/storybook-dist/assets/{NxPagination.stories-DVfxyw4M.js → NxPagination.stories-bxxUnHEL.js} +1 -1
  114. package/cli/storybook-dist/assets/{NxPointerHoldButton.stories-BzyxUWoZ.js → NxPointerHoldButton.stories-BHxB2kr5.js} +1 -1
  115. package/cli/storybook-dist/assets/{NxRangeInput.stories-DmRdzBc1.js → NxRangeInput.stories-BKfr-fp4.js} +6 -6
  116. package/cli/storybook-dist/assets/{NxSelectBox.stories-B1KkNUOf.js → NxSelectBox.stories-CG8FuTBq.js} +1 -1
  117. package/cli/storybook-dist/assets/{NxSlideRange.stories-CyOdvfQ6.js → NxSlideRange.stories-BRTFtMTL.js} +1 -1
  118. package/cli/storybook-dist/assets/{NxSwitch.stories-DrDBw0qp.js → NxSwitch.stories-Bwuyzn-R.js} +1 -1
  119. package/cli/storybook-dist/assets/{NxTextArea.stories-DTB2aqNS.js → NxTextArea.stories-COq2sGqu.js} +1 -1
  120. package/cli/storybook-dist/assets/{NxTextInput.stories-DU0rucmM.js → NxTextInput.stories-CjsBLz05.js} +1 -1
  121. package/cli/storybook-dist/assets/{RBAccordion.stories-DTfOtXtp.js → RBAccordion.stories-BARwuZ0Q.js} +3 -3
  122. package/cli/storybook-dist/assets/{RBAccordionGroup.stories-9y9BjrUe.js → RBAccordionGroup.stories-COrPxETW.js} +10 -10
  123. package/cli/storybook-dist/assets/{RBAlert.stories-DLTAv9fT.js → RBAlert.stories-BQrkjZPt.js} +3 -3
  124. package/cli/storybook-dist/assets/{RBBadge.stories-BNMACewA.js → RBBadge.stories-BTsLL-lK.js} +1 -1
  125. package/cli/storybook-dist/assets/{RBBattery.stories-B3kXMAt5.js → RBBattery.stories-DAeQdJbc.js} +1 -1
  126. package/cli/storybook-dist/assets/{RBCard.stories-Y-s8R2n7.js → RBCard.stories-HyhOE80R.js} +39 -39
  127. package/cli/storybook-dist/assets/{RBCheckBox.stories-DBuT9tL4.js → RBCheckBox.stories-KFk0WqX_.js} +1 -1
  128. package/cli/storybook-dist/assets/{RBCheckIconButton.stories-CWu1TL56.js → RBCheckIconButton.stories-CXbbktF5.js} +1 -1
  129. package/cli/storybook-dist/assets/{RBCheckLabelButton.stories-sCu2MGCX.js → RBCheckLabelButton.stories-DWfYz_9Z.js} +17 -17
  130. package/cli/storybook-dist/assets/{RBChipTab.stories-BX9qLJkR.js → RBChipTab.stories-JPEgVFAS.js} +5 -5
  131. package/cli/storybook-dist/assets/{RBChipsBoxTab.stories-35u1aJ-P.js → RBChipsBoxTab.stories-CTPuWXrQ.js} +1 -1
  132. package/cli/storybook-dist/assets/{RBColorIcons.stories-DAOZ5Ies.js → RBColorIcons.stories-DiFRZ5jk.js} +1 -1
  133. package/cli/storybook-dist/assets/RBCommand.stories-Pf1HAVIR.js +73 -0
  134. package/cli/storybook-dist/assets/{RBConfirm.stories-iMXcUh7Z.js → RBConfirm.stories-D1cEPUXk.js} +3 -3
  135. package/cli/storybook-dist/assets/{RBDateInput.stories-hK5rXECf.js → RBDateInput.stories-RPYpCjig.js} +1 -1
  136. package/cli/storybook-dist/assets/RBDialog.stories-DJH-0XSR.js +38 -0
  137. package/cli/storybook-dist/assets/{RBDivider.stories-BAK-iZ1Z.js → RBDivider.stories-uEj1vOgK.js} +1 -1
  138. package/cli/storybook-dist/assets/{RBDrawer.stories-B2nqf2Nh.js → RBDrawer.stories-BiMzElb2.js} +12 -12
  139. package/cli/storybook-dist/assets/RBDropdownMenu.stories-Da_TBkla.js +86 -0
  140. package/cli/storybook-dist/assets/{RBEmptyView.stories-nCenrgBh.js → RBEmptyView.stories-BHeKGfPS.js} +1 -1
  141. package/cli/storybook-dist/assets/{RBIconButton.stories-DRH7O3sp.js → RBIconButton.stories-NEsRFoYp.js} +1 -1
  142. package/cli/storybook-dist/assets/{RBIcons.stories-D3PyJXG8.js → RBIcons.stories-CcZ-TTLS.js} +1 -1
  143. package/cli/storybook-dist/assets/{RBInlineLabelButton.stories-modQbt10.js → RBInlineLabelButton.stories-B0wpdQyR.js} +1 -1
  144. package/cli/storybook-dist/assets/{RBJoyStick.stories-BY14qVgR.js → RBJoyStick.stories-DEgBou3K.js} +1 -1
  145. package/cli/storybook-dist/assets/{RBLabelButton.stories-DzF7vGwm.js → RBLabelButton.stories-DT8drIsF.js} +1 -1
  146. package/cli/storybook-dist/assets/{RBLight.stories-lF4FRmVh.js → RBLight.stories-Dh-aoeyG.js} +1 -1
  147. package/cli/storybook-dist/assets/{RBMenubar.stories-XhrI7bsV.js → RBMenubar.stories-D-H7UqVh.js} +1 -1
  148. package/cli/storybook-dist/assets/{RBMultiRangeInput.stories-DprllQDl.js → RBMultiRangeInput.stories-hlclv6jI.js} +1 -1
  149. package/cli/storybook-dist/assets/{RBPagination.stories-2v2gBJfC.js → RBPagination.stories-DWCeoZ_S.js} +1 -1
  150. package/cli/storybook-dist/assets/{RBPopover.stories-C8iLIZpv.js → RBPopover.stories-C514M09_.js} +1 -1
  151. package/cli/storybook-dist/assets/{RBPrompt.stories-Th_HrWHI.js → RBPrompt.stories-BSItvfiT.js} +1 -1
  152. package/cli/storybook-dist/assets/{RBRadioBox.stories-CoHuLaN3.js → RBRadioBox.stories-BBb4YbFU.js} +1 -1
  153. package/cli/storybook-dist/assets/{RBRadioButton.stories-DULxE_ff.js → RBRadioButton.stories-CXyOGJCv.js} +1 -1
  154. package/cli/storybook-dist/assets/{RBRangeDateInput.stories-D8A9KBSS.js → RBRangeDateInput.stories-qvhubu0e.js} +1 -1
  155. package/cli/storybook-dist/assets/{RBResizable.stories-C41QfrQ3.js → RBResizable.stories-CZKeyGjH.js} +14 -14
  156. package/cli/storybook-dist/assets/{RBSelectBox.stories-C1gNmwzw.js → RBSelectBox.stories-CRKP3ybf.js} +1 -1
  157. package/cli/storybook-dist/assets/RBSheet.stories-sCM3ZJUa.js +39 -0
  158. package/cli/storybook-dist/assets/{RBSideNumberInput.stories-9LlgQ8nh.js → RBSideNumberInput.stories-Bb7gw8RF.js} +3 -3
  159. package/cli/storybook-dist/assets/{RBSlider.stories-oUgo5K9d.js → RBSlider.stories-23OAQU5z.js} +3 -3
  160. package/cli/storybook-dist/assets/{RBSpinner.stories-DDpohbY2.js → RBSpinner.stories-2xcQdcmt.js} +1 -1
  161. package/cli/storybook-dist/assets/{RBSwitch.stories-gW-GN7QO.js → RBSwitch.stories-BACUaJm_.js} +7 -7
  162. package/cli/storybook-dist/assets/{RBTab.stories-CN-PV3HH.js → RBTab.stories-CDHE3sVs.js} +1 -1
  163. package/cli/storybook-dist/assets/{RBTable.stories-BAZLpxtM.js → RBTable.stories-C0Cfhfl7.js} +29 -29
  164. package/cli/storybook-dist/assets/{RBTableRow.stories-CwjVrszB.js → RBTableRow.stories-BF8kIkpp.js} +1 -1
  165. package/cli/storybook-dist/assets/{RBTabs.stories-JuEo2sUL.js → RBTabs.stories-BdJWRq1o.js} +7 -7
  166. package/cli/storybook-dist/assets/{RBTextArea.stories-D0_Pv4qZ.js → RBTextArea.stories-CGy2xeYH.js} +3 -3
  167. package/cli/storybook-dist/assets/{RBTextField.stories-CCsOvZqV.js → RBTextField.stories-IfUOzxCM.js} +15 -15
  168. package/cli/storybook-dist/assets/{RBTextInput.stories-Cccn3VNm.js → RBTextInput.stories-VjXNFo4R.js} +3 -3
  169. package/cli/storybook-dist/assets/{RBToast.stories-I5617t0K.js → RBToast.stories-BzcobblV.js} +1 -1
  170. package/cli/storybook-dist/assets/{RBToggle.stories-DsgKjGsJ.js → RBToggle.stories-DuLE5mKv.js} +4 -4
  171. package/cli/storybook-dist/assets/{RBToggleGroup.stories-BZEzSDuz.js → RBToggleGroup.stories-CuR0N0GJ.js} +5 -5
  172. package/cli/storybook-dist/assets/{RBTooltip.stories-4oSyAWsA.js → RBTooltip.stories--aAgNC8_.js} +1 -1
  173. package/cli/storybook-dist/assets/{SideNumberInput-DSKbd0bG.js → SideNumberInput-DyWQxYUj.js} +743 -743
  174. package/cli/storybook-dist/assets/{entry-preview-B35XREGs.js → entry-preview-D7IHsza_.js} +1 -1
  175. package/cli/storybook-dist/assets/{iframe-BB797lmL.js → iframe-CbaIp5I-.js} +2 -2
  176. package/cli/storybook-dist/assets/{index-BU2567EP.js → index-C6CBgMuG.js} +3 -3
  177. package/cli/storybook-dist/assets/{preview-C7DaSWyc.js → preview-B-iYLDuW.js} +2 -2
  178. package/cli/storybook-dist/assets/{preview-CbuRHit4.js → preview-CRWqc9DH.js} +1 -1
  179. package/cli/storybook-dist/assets/{react-resizable-panels.browser-ChIvlwo7.js → react-resizable-panels.browser-8hC8dr-6.js} +8 -8
  180. package/cli/storybook-dist/iframe.html +2 -2
  181. package/cli/storybook-dist/index.html +1 -1
  182. package/cli/storybook-dist/project.json +1 -1
  183. package/cli/templates/common/.mcp.json +8 -8
  184. package/cli/templates/common/README.md +1231 -1231
  185. package/cli/templates/common/assets/icon.svg +45 -45
  186. package/cli/templates/common/manifest.json +26 -26
  187. package/cli/templates/common/package.json +22 -22
  188. package/cli/templates/common/py/main.py +125 -125
  189. package/cli/templates/common/tsconfig.json +13 -13
  190. package/cli/templates/common/ui/content.tsx +216 -216
  191. package/cli/templates/common/ui/style.css +6 -6
  192. package/cli/templates/common/vite.config.ts +10 -10
  193. package/cli/templates/cpp/cpp/main.cpp +73 -73
  194. package/cli/templates/ros2/ros2_ws/src/__PLUGIN_SLUG__/__PLUGIN_SLUG__/node.py +25 -25
  195. package/cli/templates/ros2/ros2_ws/src/__PLUGIN_SLUG__/package.xml +10 -10
  196. package/cli/templates/ros2/ros2_ws/src/__PLUGIN_SLUG__/setup.py +18 -18
  197. package/cli/templates/ros2-cpp/ros2_ws/src/__PLUGIN_SLUG__/package.xml +11 -11
  198. package/cli/templates/ros2-cpp/ros2_ws/src/__PLUGIN_SLUG__/src/node.cpp +23 -23
  199. package/cli/vite.d.mts +25 -25
  200. package/cli/vite.mjs +292 -292
  201. package/package.json +3 -3
  202. package/cli/storybook-dist/assets/RBCommand.stories-BSqXDkKw.js +0 -73
  203. package/cli/storybook-dist/assets/RBDialog.stories-BAktz-ku.js +0 -38
  204. package/cli/storybook-dist/assets/RBDropdownMenu.stories-DbW2fWAj.js +0 -86
  205. package/cli/storybook-dist/assets/RBSheet.stories-_6VH-RUm.js +0 -39
@@ -1,1231 +1,1231 @@
1
- # __PLUGIN_NAME__
2
-
3
- A Muscat plugin project. Build, develop, and ship with `muscat-pk` CLI.
4
-
5
- ## Quick start
6
-
7
- ```bash
8
- muscat-pk dev # UI preview + backend together
9
- muscat-pk dev:ui # UI preview only
10
- muscat-pk dev:back # backend only (Python watchfiles / ROS2 Docker)
11
- muscat-pk build # build + distributable ZIP
12
- ```
13
-
14
- ---
15
-
16
- ## Project structure
17
-
18
- ```
19
- __PLUGIN_DIR__/
20
- ├─ ui/
21
- │ ├─ content.tsx # plugin UI entry point
22
- │ └─ style.css # plugin styles auto-loaded by muscat()
23
- ├─ py/
24
- │ └─ main.py # Python backend entry point
25
- ├─ assets/
26
- │ └─ icon.svg # icon referenced from manifest
27
- ├─ manifest.json # metadata, contribution points, runtime config
28
- ├─ README.md # this file (copied to dist/ on build)
29
- └─ requirements.txt # Python deps (auto-pinned to latest on build)
30
- ```
31
-
32
- > ROS2 plugins additionally include a `ros2_ws/` directory.
33
-
34
- ---
35
-
36
- ## Frontend (`ui/content.tsx`)
37
-
38
- ### JavaScript dependencies
39
-
40
- `muscat-pk build` runs this plugin project's own Vite build. Vite resolves
41
- imports from installed `node_modules`; it does not care whether a package is
42
- listed under `dependencies` or `devDependencies`, but the package must be
43
- installed.
44
-
45
- Do not run a production-only install such as `npm install --omit=dev` before
46
- building the plugin UI, because build tools like `vite`, `typescript`,
47
- `tailwindcss`, `@tailwindcss/vite`, `sass`, or `less` are usually
48
- `devDependencies`.
49
-
50
- The scaffold keeps this split:
51
-
52
- | package kind | examples | why |
53
- |---|---|---|
54
- | `dependencies` | `@rainbow-robotics/plugin-ui`, UI libraries imported by plugin source | Author-facing APIs used by `ui/*.tsx` |
55
- | `devDependencies` | `vite`, `typescript`, `tailwindcss`, `@tailwindcss/vite`, `sass`, `less` | Build/dev tools needed before `dist/*.js` is produced |
56
-
57
- The built plugin ZIP does not include `package.json` or `node_modules`. For
58
- production, Muscat provides `@rainbow-robotics/plugin-ui`, React, and ReactDOM
59
- through the host import map; plugin UI build output imports those as host
60
- externals.
61
-
62
- ### Styles
63
-
64
- `muscat()` automatically loads `ui/style.{css,scss,sass,less}` (or
65
- `ui/styles.{css,scss,sass,less}`) for every UI entry, plus
66
- `ui/{entry}.{css,scss,sass,less}` for a matching entry-specific stylesheet. You
67
- do not need to import CSS from every `.tsx` file.
68
-
69
- The scaffold uses Tailwind's theme/utilities layers only, so plugin utilities
70
- are generated from files under `ui/` without applying Tailwind preflight resets
71
- to the host page.
72
-
73
- Built CSS selectors are scoped under the current plugin id
74
- (`[data-muscat-plugin-id="..."]`). CSS-in-JS libraries such as Emotion work
75
- normally, but global styles should be scoped by the author.
76
-
77
- #### Tailwind
78
-
79
- This template enables Tailwind in `vite.config.ts`:
80
-
81
- ```ts
82
- import { defineConfig } from "vite";
83
- import tailwindcss from "@tailwindcss/vite";
84
- import { muscat } from "@rainbow-robotics/muscat-plugin-kit/vite";
85
-
86
- export default defineConfig({
87
- plugins: [tailwindcss(), muscat()],
88
- });
89
- ```
90
-
91
- The default `ui/style.css` uses Tailwind theme/utilities only, not preflight:
92
-
93
- ```css
94
- @layer theme, utilities;
95
-
96
- @import "tailwindcss/theme.css" layer(theme);
97
- @import "tailwindcss/utilities.css" layer(utilities);
98
-
99
- @source "./**/*.{ts,tsx}";
100
- ```
101
-
102
- #### SCSS, Sass, and Less
103
-
104
- Install the preprocessor package, then create one of the auto-loaded style files.
105
-
106
- ```bash
107
- npm i -D sass
108
- ```
109
-
110
- ```scss
111
- /* ui/style.scss */
112
- $accent: #3b82f6;
113
-
114
- .panel {
115
- border: 1px solid color-mix(in srgb, $accent 40%, transparent);
116
-
117
- &__title {
118
- color: $accent;
119
- }
120
- }
121
- ```
122
-
123
- For Less, install `less` and use `ui/style.less`.
124
-
125
- #### Emotion
126
-
127
- Emotion dependencies may be in `dependencies` or `devDependencies`; Vite can
128
- bundle them when they are installed and imported by the plugin.
129
-
130
- For `styled`, no Vite config change is needed:
131
-
132
- ```bash
133
- npm i @emotion/react @emotion/styled
134
- ```
135
-
136
- ```tsx
137
- import styled from "@emotion/styled";
138
-
139
- const Panel = styled.div({
140
- display: "grid",
141
- gap: 12,
142
- });
143
- ```
144
-
145
- For Emotion's `css` prop, pass React plugin options through `muscat()` and add
146
- `jsxImportSource` to `tsconfig.json`:
147
-
148
- ```bash
149
- npm i @emotion/react @emotion/styled
150
- npm i -D @emotion/babel-plugin
151
- ```
152
-
153
- ```ts
154
- // vite.config.ts
155
- import { defineConfig } from "vite";
156
- import tailwindcss from "@tailwindcss/vite";
157
- import { muscat } from "@rainbow-robotics/muscat-plugin-kit/vite";
158
-
159
- export default defineConfig({
160
- plugins: [
161
- tailwindcss(),
162
- muscat({
163
- react: {
164
- jsxImportSource: "@emotion/react",
165
- babel: {
166
- plugins: ["@emotion/babel-plugin"],
167
- },
168
- },
169
- }),
170
- ],
171
- });
172
- ```
173
-
174
- ```json
175
- {
176
- "compilerOptions": {
177
- "jsxImportSource": "@emotion/react"
178
- }
179
- }
180
- ```
181
-
182
- Emotion `Global` writes global CSS at runtime, so scope it manually or avoid it
183
- inside plugins.
184
-
185
- ### useRb() hook
186
-
187
- Access the communication API between the plugin module and UI from any plugin component.
188
-
189
- ```tsx
190
- import { useRb } from "@rainbow-robotics/plugin-ui/sdk";
191
-
192
- function App() {
193
- const rb = useRb();
194
- }
195
- ```
196
-
197
- ### rb API
198
-
199
- | Method | Description |
200
- | ------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
201
- | `rb.pub(topic, payload)` | Publish a message to a topic |
202
- | `rb.sub(topic, handler)` | Subscribe to a topic — returns `{ undeclare() }` |
203
- | `rb.action(topic, payload?)` | Send a request and await a reply |
204
- | `rb.serve(topic, handler)` | Handle incoming requests from the backend |
205
- | `rb.toast({ title?, message, type?, actionLabel?, handleActionClick?, closeOnActionClick?, duration?, onClose? })` | Show a host toast notification. `message` accepts a string or `ReactElement`. `type` can be `"info"`, `"danger"`, `"warning"`, `"caution"`, or `"success"` |
206
- | `rb.alert({ title?, message, buttonText? })` | Show a host alert dialog. Resolves when the dialog is closed |
207
- | `rb.confirm({ title?, message, buttonText? })` | Show a host confirmation dialog. `buttonText` accepts `{ yes, no }`. Resolves to `true` or `false` |
208
- | `rb.prompt({ title?, message, value?, placeholder?, buttonText? })` | Show a host prompt dialog. `buttonText` accepts `{ yes, no }`. Resolves to the entered string or `undefined` |
209
-
210
- Topic names passed to `pub`, `sub`, `action`, and `serve` are isolated per plugin.
211
-
212
-
213
- All methods support Generic Types:
214
-
215
- ```tsx
216
- const status = await rb.action<{ ok: boolean }, { id: number }>(
217
- "get-status",
218
- { id: 1 },
219
- );
220
-
221
- const sub = await rb.sub<{ value: number }>("sensor/data", (data) => {
222
- console.log(data.value);
223
- });
224
- sub.undeclare(); // cleanup
225
-
226
- const ok = await rb.confirm({
227
- title: "Run",
228
- message: "Start the backend action?",
229
- });
230
- if (ok) {
231
- await rb.action("run");
232
- }
233
- ```
234
-
235
- ### Saving and restoring form data (`useRbFormData` / `useRbSave`)
236
-
237
- If the host embeds your plugin in a form it owns (e.g. a program step editor
238
- with its own Save button), it can hand your plugin previously-saved values and
239
- pull the current values back out when the user saves. The host — not the
240
- plugin — persists the data (IndexedDB, backend, etc.).
241
-
242
- ```tsx
243
- import { useState } from "react";
244
- import { useRbFormData, useRbSave } from "@rainbow-robotics/plugin-ui/react";
245
-
246
- type GripArgs = { targetName: string; width: number };
247
- const DEFAULTS: GripArgs = { targetName: "", width: 20 };
248
-
249
- export default function App() {
250
- // Previously-saved values, or undefined for a brand-new instance.
251
- const saved = useRbFormData<GripArgs>();
252
- const [args, setArgs] = useState<GripArgs>({ ...DEFAULTS, ...saved });
253
-
254
- // Registers a getter the host calls when its Save button is clicked.
255
- // Always returns the CURRENT values — re-registering on every render keeps
256
- // the closure fresh, which useRbSave already handles for you.
257
- useRbSave(() => ({
258
- args,
259
- summary: `${args.targetName || "target"} · ${args.width}mm`,
260
- }));
261
-
262
- return (/* ...form UI reading/writing `args`... */);
263
- }
264
- ```
265
-
266
- The value returned from `useRbSave` must be JSON-serialisable — the host
267
- persists it as-is. Not every host embeds plugins in a save/restore form; if
268
- yours doesn't, you can skip these hooks entirely.
269
-
270
- ### UI components
271
-
272
- Import Muscat components from `@rainbow-robotics/plugin-ui/react` and export a
273
- React component as the default export. The host mounts that component directly
274
- and provides `useRb()` through context.
275
-
276
- ```tsx
277
- import { useState } from "react";
278
- import { RBLabelButton, RBSlider, RBSwitch } from "@rainbow-robotics/plugin-ui/react";
279
- import { useRb } from "@rainbow-robotics/plugin-ui/sdk";
280
-
281
- export default function App() {
282
- const rb = useRb();
283
- const [width, setWidth] = useState(20);
284
- const [enabled, setEnabled] = useState(true);
285
-
286
- return (
287
- <div className="flex flex-col gap-4">
288
- <RBSlider
289
- value={[width]}
290
- min={0}
291
- max={100}
292
- onValueChange={(values: number[]) => setWidth(values[0] ?? 0)}
293
- />
294
- <RBSwitch checked={enabled} onCheckedChange={setEnabled} />
295
- <RBLabelButton
296
- disabled={!enabled}
297
- onClick={() => rb.action("grip", { width })}
298
- >
299
- Grip
300
- </RBLabelButton>
301
- </div>
302
- );
303
- }
304
- ```
305
-
306
- ---
307
-
308
- ## Developing directly on the robot PC
309
-
310
- If you're running `muscat-pk dev` **on the robot PC itself**, Muscat already serves the
311
- plugin gateway on port 10000. The CLI sees that, does not start its own local Muscat, and
312
- your plugin backend connects to the robot's gateway — exactly what you want when
313
- developing on the robot.
314
-
315
- ---
316
-
317
- ## Backend runtimes
318
-
319
- `manifest.json` chooses one backend runtime. `muscat-pk dev` runs backend code in
320
- a Docker container matching the robot environment. Backend code should contain
321
- plugin logic only; it should not start its own copy of the plugin gateway.
322
-
323
- ### Which backend pattern? (depends on the contribution point)
324
-
325
- **How you control the robot depends on what your plugin contributes** (see
326
- `manifest.json` → `contributes[].point`). Don't mix these up:
327
-
328
- | Contribution point | What it is | Backend pattern |
329
- | --- | --- | --- |
330
- | `program.panel` (with `commandInfo`) | Defines a **custom command** the plugin adds to the Program page's rule-based sequence builder. The operator places it into the program tree like any built-in command (Move J, Point, Wait, …); it runs when the program reaches that step. | **`PluginProgramContext`** — integrated with the program's stop/pause and variables. See _Program command backend_ below. |
331
- | `toolbar` / `lnb` / `move.panel` | A live UI panel/popover the operator interacts with directly | Call the robot **SDK directly** (`RBManipulateSDK`, `RBAmrSDK`) from `py/main.py`, or `rb.action(...)` → your backend → SDK. **Not** a program step — there is no stop/pause/variable context. |
332
-
333
- Rule of thumb: use `PluginProgramContext` **only** for a `program.panel` command
334
- that runs as part of a program. To move the robot live from a toolbar/move panel,
335
- use the SDK — a program-step context does not apply.
336
-
337
- ### Frontend-only (`none`)
338
-
339
- Use this when the plugin only has UI and calls host services:
340
-
341
- ```json
342
- { "runtime": { "type": "none" } }
343
- ```
344
-
345
- ### Python
346
-
347
- Python plugins use `py/main.py` and can communicate with the UI through
348
- `rainbow.rb_sdk.RBPluginCall`.
349
-
350
- ```python
351
- import time
352
- from rainbow.rb_sdk import RBAmrSDK, RBManipulateSDK, RBPluginCall
353
-
354
- plugin = RBPluginCall()
355
-
356
-
357
- plugin.publish("state", {"ready": True})
358
-
359
-
360
- @plugin.subscribe("event")
361
- def on_ui_event(payload=None):
362
- print(payload)
363
-
364
-
365
- @plugin.serve("tool/echo")
366
- def echo(payload=None):
367
- return {"echo": payload}
368
-
369
-
370
- def call_frontend():
371
- res = plugin.call("tool/ping", payload={"hello": "plugin"})
372
- print(res)
373
-
374
-
375
- def move_j_example():
376
- amr = RBAmrSDK()
377
- manipulate = RBManipulateSDK()
378
- return manipulate.move.call_move_j(
379
- robot_model="C500920",
380
- target={"tar_values": [0, 0, 0, 0, 0, 0], "tar_frame": 0, "tar_unit": 0},
381
- )
382
-
383
-
384
- if __name__ == "__main__":
385
- while True:
386
- time.sleep(1)
387
- ```
388
-
389
- #### External endpoints (`plugin.router`)
390
-
391
- `plugin.serve` / `plugin.subscribe` / `plugin.publish` are **internal only** (plugin
392
- UI ↔ backend, plugin ↔ plugin). To expose an endpoint to the outside world —
393
- REST, Socket.IO, TCP, MQTT, WebRTC — use `plugin.router`. Declaring one is a
394
- single decorator; the running Muscat (`common`) picks it up without a restart,
395
- and it disappears when the plugin stops or is removed.
396
-
397
- > Requires a robot running `rainbow-rb-sdk` **0.0.9.dev5 or newer** (the SDK is
398
- > provided by the plugin container at runtime). On older robots `plugin.router`
399
- > does not exist and raises `AttributeError`.
400
-
401
- ```python
402
- from rainbow.rb_sdk import RBPluginCall
403
- from rainbow.rb_sdk.plugin import PluginRouteError
404
-
405
- plugin = RBPluginCall() # plugin_id comes from env RB_PLUGIN_ID
406
-
407
-
408
- @plugin.router.on("/hello") # all 5 transports
409
- def hello(payload: dict, meta: dict):
410
- return {"msg": f"hi {payload.get('name')}"} # return value = response body
411
-
412
-
413
- @plugin.router.on("/admin", explicit_routes=["api"]) # REST only
414
- def admin(payload, meta):
415
- if not (meta.get("principal") or {}).get("is_admin"):
416
- raise PluginRouteError(403, "admin only") # becomes the external status
417
- ...
418
-
419
-
420
- @plugin.router.subscribe("/cmd", explicit_events=["tcp"]) # receive external TCP publish
421
- def on_cmd(payload: dict):
422
- ...
423
-
424
-
425
- plugin.router.emit("/state", {"busy": True}) # push to external subscribers
426
- ```
427
-
428
- | API | Meaning | External address |
429
- | --- | --- | --- |
430
- | `router.on(path, explicit_routes=None)` | request/response handler | `common/plugin/{plugin_id}/{path}` (REST is always `POST`) |
431
- | `router.subscribe(topic, explicit_events=None)` | receive external publishes | external side publishes to `common/plugin/{plugin_id}/{topic}` |
432
- | `router.emit(topic, payload)` | plugin → external push | external side subscribes to `common/plugin/{plugin_id}/{topic}` |
433
-
434
- - The `plugin_id` prefix is added by the SDK — write only the path in plugin code.
435
- - Paths may have several segments (`/tool/echo`). Declaring the same path twice raises `ValueError`.
436
- - Transport names: `api | socket | tcp | mqtt | webrtc`. `subscribe` accepts only `tcp | mqtt | webrtc`. Unknown names raise `ValueError`.
437
- - `explicit_routes` / `explicit_events` of `None` means every transport. A request on a transport not listed gets 404.
438
-
439
- Handler contract:
440
-
441
- - Signature `(payload, meta)`, `(payload)` or `()` — only the arguments you declare are injected. Sync and async both work.
442
- - `payload`: what the caller sent (for REST: JSON body merged with query params). The shape is up to the plugin.
443
- - `meta`: `{"transport": "api"|"socket"|"tcp"|"mqtt"|"webrtc", "principal": dict | None}`. When the robot's login policy is on, `principal` holds the token subject. **Role checks are the plugin's job** — inspect `meta["principal"]` yourself.
444
- - The return value is the response body (must be JSON-serialisable). `None` → empty response.
445
- - Status codes: `raise PluginRouteError(code, message)`. Any other exception → 500 with `str(exc)` as the message.
446
- - `subscribe` handlers take `(payload)` only and return nothing.
447
-
448
- #### Calling plugin endpoints from outside
449
-
450
- | Transport | How |
451
- | --- | --- |
452
- | REST | `POST /common/plugin/{plugin_id}/{path}` (through the api-gateway), JSON body |
453
- | Socket.IO | emit event `common/plugin/{plugin_id}/{path}`, response arrives in the ack |
454
- | TCP | topic `common/plugin/{plugin_id}/{path}` request / publish |
455
- | MQTT | request: `common/plugin/{plugin_id}/{path}/__req__`, publish: `common/plugin/{plugin_id}/{topic}` |
456
- | WebRTC | data channel topic `common/plugin/{plugin_id}/{path}` |
457
-
458
- Auth: when the robot's login policy is on, a token is **required** (any role — GUEST passes). When it is off, calls go straight through. REST uses the same Bearer token as the other Muscat APIs.
459
-
460
- ```bash
461
- curl -s -X POST http://<robot>/common/plugin/my-plugin/hello \
462
- -H 'Content-Type: application/json' -d '{"name":"kim"}'
463
- ```
464
-
465
- | Situation | Status |
466
- | --- | --- |
467
- | No plugin declares that path / plugin is down / transport not allowed | 404 |
468
- | Plugin did not answer within 10 s | 504 |
469
- | `common` ↔ gateway not connected | 503 |
470
- | Handler raised | 500 (`PluginRouteError` → its `code`) |
471
-
472
- Known limits:
473
-
474
- - A plugin path named `install` cannot be called over REST (`POST /common/plugin/{id}/install` is taken by the registry install route first). Socket/TCP are unaffected.
475
- - Swagger shows one catch-all route, not per-plugin paths.
476
- - The MQTT broker echoes to the sender: a plugin that `emit`s and `subscribe`s the same topic receives its own push over `mqtt`. Drop `mqtt` from `explicit_events` or use separate topics. (TCP is filtered by `sender_id`.)
477
- - `on` and `subscribe` may share a name — they live in separate tables.
478
-
479
- During `muscat-pk dev` the local Muscat runtime takes the `router.*` registrations
480
- on its gateway (port `10000`), and its `common` (port `3000`) routes
481
- `common/plugin/...` to them like on the robot — try
482
- `curl http://localhost:3000/common/plugin/<plugin-id>/<path>`. After restarting the plugin process,
483
- external calls may return 504 for up to about a minute (until the gateway notices
484
- the old socket is gone), then 404 — that is expected.
485
-
486
- #### Program command backend (`program.panel`)
487
-
488
- A `program.panel` contribution defines a **custom command** your plugin adds to
489
- the Muscat **Program page**, where the operator builds a robot program as a
490
- rule-based sequence (a program tree). Your command appears alongside the
491
- built-ins (Move J, Point, Wait, …); the operator places it into the tree, and
492
- each time the program executes that step the host calls your backend through
493
- `PluginProgramContext` — receiving the step args, checking for stop/pause,
494
- reading/writing program variables, and signalling completion. This pattern is
495
- **only** for `program.panel`; a toolbar/move-panel plugin uses the SDK directly
496
- instead (see the table above).
497
-
498
- ```python
499
- """
500
- Plugin backend main.py example (program.panel command).
501
-
502
- manifest.json:
503
- "runtime": {"type": "python", "entry": "main.py"},
504
- "contributes": [{
505
- "point": "program.panel",
506
- "ui": "program",
507
- "label": {"en": "My Plugin Step"},
508
- "commandInfo": {
509
- "name": "My Plugin Step",
510
- "icon": "assets/icon.svg",
511
- "funcName": "rb_program_sdk.plugin_execute",
512
- "args": {"message": "hello"} # plugin-author-defined args
513
- }
514
- }]
515
-
516
- The host invokes commandInfo.funcName (rb_program_sdk.plugin_execute) with
517
- args.plugin_id + your args when the program reaches this step.
518
- """
519
- import asyncio
520
- from rainbow.rb_sdk import PluginProgramContext
521
-
522
- ctx = PluginProgramContext(plugin_id="my-plugin")
523
-
524
-
525
- @ctx.on_execute
526
- async def handle(args: dict) -> None:
527
- """Called once when my Step executes. Variable writes apply to the
528
- running program immediately + broadcast to other robots' main tasks."""
529
- count = await ctx.get_variable("counter") # read an existing program variable
530
- await ctx.set_variable("counter", count + 1) # update an existing variable — allowed
531
- await ctx.set_variable("RBP_TEST", 1) # NEW variables must start with RBP_
532
-
533
- for i in range(100):
534
- await ctx.check_stop() # auto-scoped to this Step's robot model
535
- await asyncio.sleep(0.5)
536
-
537
- await ctx.done()
538
-
539
-
540
- async def background_monitor() -> None:
541
- """Resident task outside any Step. If no program is running,
542
- get returns None and set is silently ignored — always safe to call."""
543
- while True:
544
- await asyncio.sleep(1.0)
545
-
546
- # targets every running main task (no robot_model param — always broadcast)
547
- await ctx.set_variable("RBP_TEST", 3)
548
-
549
- # get options
550
- v1 = await ctx.get_variable("counter") # first value found in the whole tree
551
- v2 = await ctx.get_variable("counter", robot_model="C500920") # a specific robot
552
- v3 = await ctx.get_variable("RBP_TEST", plugin_id="my-plugin") # the value my-plugin last wrote
553
-
554
- if ctx.executor_state("C500920") == "paused":
555
- print("C500920 program is paused")
556
-
557
-
558
- async def main() -> None:
559
- asyncio.create_task(background_monitor())
560
- await ctx.start() # stays resident
561
-
562
-
563
- asyncio.run(main())
564
- ```
565
-
566
- #### Program variables & executor state (`PluginProgramContext`)
567
-
568
- `rainbow.rb_sdk.PluginProgramContext` lets the plugin process interact with the
569
- running program (PyFM): read/write program variables and observe execution
570
- state — inside a Step handler or from a resident task, at any time.
571
-
572
- - The `on_execute` handler is called **only when PyFM executes this plugin's
573
- Step**. One Step execution = one handler call, and the Step does not finish
574
- until you call `ctx.done()` — always call it.
575
- - The plugin process itself stays resident after `ctx.start()`; executor state
576
- queries and variable access below keep working whether or not a Step is
577
- executing.
578
-
579
- #### Variable naming rules (important)
580
-
581
- PyFM variables are a **program-wide shared namespace**. One rule:
582
-
583
- - **Modifying an existing variable** — the name passes as-is. Exchanging
584
- variables between the program and the plugin is the intended usage.
585
- - **Creating a variable that doesn't exist** — the `RBP_` prefix is
586
- **required** (enforced by the SDK/server; a violation raises
587
- `RuntimeError`). Applies equally inside and outside Step execution.
588
-
589
- For new variable names, a convention that includes the plugin id is strongly
590
- recommended:
591
-
592
- ```
593
- RBP_{plugin_id}_{name}
594
-
595
- e.g. RBP_vision_detect_count, RBP_gripper_last_error
596
- ```
597
-
598
- Why:
599
-
600
- - `RBP_` — instantly identifies plugin-created variables in the program
601
- editor/UI.
602
- - `{plugin_id}` — **prevents name collisions with other plugins.** If two
603
- plugins use the same name they overwrite the same variable (`RBP_` alone is
604
- not something the SDK guards against).
605
-
606
- #### Reading / writing variables
607
-
608
- ```python
609
- value = await ctx.get_variable("counter") # read (no name restriction)
610
- await ctx.set_variable("counter", value + 1) # modify existing variable — allowed
611
- await ctx.set_variable("RBP_my-plugin_result", 42) # create new variable — RBP_ required
612
- ```
613
-
614
- `set_variable` always applies to **every running main task** (a plugin belongs
615
- to muscat, not to a specific robot — there is no `robot_model` parameter):
616
-
617
- | When | Own program | Other robots' main tasks |
618
- |---|---|---|
619
- | During own Step execution | via the control queryable — **immediate** | broadcast — each at its next Step boundary |
620
- | Outside a Step (resident task, …) | resident endpoint — next Step boundary | same |
621
-
622
- `get_variable` has options:
623
-
624
- ```python
625
- await ctx.get_variable("counter") # search all main tasks (first value found)
626
- await ctx.get_variable("counter", robot_model="C500920") # a specific robot's value
627
- await ctx.get_variable("RBP_TEST", plugin_id="A.plugin") # the value that plugin last wrote
628
- ```
629
-
630
- Caveats:
631
-
632
- - If no program is running, `set_variable` is **silently ignored** and
633
- `get_variable` returns `None`. (`RuntimeError` is raised only for real
634
- errors, like the `RBP_` violation on a new variable — the message is tagged
635
- `[plugin:{id}]` with the offending plugin.)
636
- - Rarely, a write can be lost if it races the worker's variable flush
637
- (lock-free read-modify-write). For important values, re-check and retry on
638
- the next cycle.
639
-
640
- #### Per-plugin value storage (meta)
641
-
642
- Besides the shared value, each variable entry records which plugin last wrote
643
- what:
644
-
645
- ```python
646
- # state_dict["variables"] — after A wrote 1 and B wrote 2
647
- "RBP_TEST": {
648
- "value": 2, # shared slot = last written value
649
- "type": "local",
650
- "meta": {"writers": {"A.plugin": 1, "B.plugin": 2}}, # per-plugin record
651
- }
652
- ```
653
-
654
- So the exact value is recoverable even when names collide:
655
-
656
- - Plugin SDK: `ctx.get_variable("RBP_TEST", plugin_id="A.plugin")` → `1`
657
- - Program step expression: `COUNT = get_var("A.plugin", "RBP_TEST") + 1` → `2`
658
- - A plain name (`RBP_TEST += 1`) uses the shared slot (last written value) → `3`
659
-
660
- #### Executor state
661
-
662
- Subscribes to the `muscat/executor/state` publish (0.8 s interval) and exposes
663
- per-robot-model state:
664
-
665
- ```python
666
- ctx.executor_state("C500920") # "running" | "paused" | "waiting" | ... | None
667
- ctx.executor_state() # {"C500920": "paused", "amr": "running"}
668
- ctx.is_paused("C500920") # whether that model is paused
669
- ctx.is_stopped("C500920") # stop/error transition of a model seen active this run
670
- ```
671
-
672
- - States: `idle | running | paused | waiting | stopped | post_start | completed | error`
673
- - A model with no running task is `None` / absent from the dict.
674
- - The publish interval is 0.8 s, so expect up to ~1 s of latency.
675
- - With `IS_DEV=true` there is no publish at all — always `None`.
676
-
677
- To get a callback at the moment the state changes, without polling:
678
-
679
- ```python
680
- @ctx.on_executor_state_change
681
- async def on_change(robot_model: str, state: str) -> None:
682
- if state == "paused":
683
- print(f"{robot_model} paused")
684
- ```
685
-
686
- - Not called for repeated publishes of the same state — fires exactly once
687
- per per-model state change.
688
- - An exception in the handler is only logged; state tracking keeps running.
689
-
690
- #### Responding to pause / stop
691
-
692
- ```python
693
- @ctx.on_execute
694
- async def handle(args: dict) -> None:
695
- for i in range(100):
696
- # stop → CancelledError (propagated to the executor as a stop),
697
- # pause → waits here until resumed.
698
- # Inside on_execute this automatically watches only the robot model
699
- # executing this Step.
700
- await ctx.check_stop()
701
-
702
- ... # actual work
703
- await asyncio.sleep(0.5)
704
-
705
- await ctx.done()
706
- ```
707
-
708
- Scope priority for `check_stop` / `is_paused` / `is_stopped`:
709
-
710
- 1. `robot_model` given — that model only
711
- 2. Omitted inside `on_execute` — **only the model executing this Step**
712
- (passed in by the executor)
713
- 3. Omitted elsewhere (resident task, …) — all models
714
-
715
- A handler doing long-running work must call `check_stop()` every loop
716
- iteration so it reacts to pause/stop immediately. Without it, the plugin keeps
717
- running even after the operator presses Stop.
718
-
719
- Variables your plugin wants the Program page variable picker to offer must be
720
- pre-declared in `manifest.json` under `"variables"` (see the manifest section
721
- below).
722
-
723
- ### C++
724
-
725
- C++ plugins use `cpp/` and are built with CMake inside Docker against the
726
- Rainbow Robotics C++ SDK (see _C++ SDK_ below).
727
-
728
- ```cpp
729
- #include <chrono>
730
- #include <cstdlib>
731
- #include <iostream>
732
- #include <string>
733
- #include <thread>
734
-
735
- int main() {
736
- const char* plugin_id = std::getenv("RB_PLUGIN_ID");
737
- std::cout << "[__PLUGIN_NAME__] backend ready"
738
- << (plugin_id ? std::string(" (id=") + plugin_id + ")" : "")
739
- << std::endl;
740
-
741
- while (true) {
742
- std::this_thread::sleep_for(std::chrono::seconds(1));
743
- }
744
- return 0;
745
- }
746
- ```
747
-
748
- #### C++ SDK (`rainbow-rb-sdk-dev`)
749
-
750
- Both C++ runtimes (`cpp`, `ros2` + `rclcpp`) link the Rainbow Robotics C++ SDK
751
- from the apt package `rainbow-rb-sdk-dev`. The `muscat-pk` dev containers already
752
- have it, so `muscat-pk dev` / `build` need nothing extra. To build outside the
753
- container (your own Ubuntu 24.04 machine, or the robot PC):
754
-
755
- ```bash
756
- curl -fsSL https://rainbowrobotics.github.io/rb-sdk/rb-sdk-apt-repo.gpg.key | sudo gpg --dearmor -o /usr/share/keyrings/rb-sdk-apt.gpg
757
- echo 'deb [signed-by=/usr/share/keyrings/rb-sdk-apt.gpg] https://rainbowrobotics.github.io/rb-sdk stable main' | sudo tee /etc/apt/sources.list.d/rb-sdk.list
758
- sudo apt update && sudo apt install rainbow-rb-sdk-dev
759
- ```
760
-
761
- - CMake: `find_package(rb_sdk REQUIRED)` + `target_link_libraries(<target> rb_sdk::rb_sdk)` (C++20). pkg-config: `rb_sdk`.
762
- - Headers: `<rainbow/rb_sdk/manipulate/rb_manipulate_sdk.hpp>` (`RBManipulateSDK`),
763
- `<rainbow/rb_sdk/program/plugin_program_context.hpp>` (`rb_plugin::PluginProgramContext`, for `program.panel` commands — the header's top comment has a full `main()` example).
764
- - The binary is linked **dynamically**; `muscat-pk build` copies the matching `librb_sdk.so` into `dist/<arch>/lib/` (the robot has no SDK installed), so each plugin keeps the SDK version it was built with. The version is the one pinned in `CMakeLists.txt` (`find_package(rb_sdk <version> EXACT REQUIRED)`); change it with `muscat-pk sdk update <version>`.
765
- - UI ↔ backend messaging works from C++ too: `rb_plugin::RBPluginCall` in `<rainbow/rb_sdk/plugin/rb_plugin.hpp>` (see the scaffolded `cpp/main.cpp`).
766
-
767
- ### ROS2 (rclpy)
768
-
769
- ROS2 Python plugins use `ros2_ws/` with `runtime.rosClient: "rclpy"`. relative topic names are placed under the plugin's ROS namespace — the plugin id
770
- with "." → "/" and "-" → "_" (`com.example.my-plugin` → `/com/example/my_plugin`), in dev
771
- and on the robot. Use an absolute name (`/cmd_vel`) for topics shared with other nodes.
772
- `RBPluginCall` works in the node module too (UI messaging and `plugin.router`).
773
-
774
- ```python
775
- # ros2_ws/src/__PLUGIN_SLUG__/__PLUGIN_SLUG__/node.py
776
- import rclpy
777
- from rclpy.node import Node
778
- from rainbow.rb_sdk import RBManipulateSDK
779
-
780
- sdk = RBManipulateSDK()
781
-
782
-
783
- class __PLUGIN_CLASS__Node(Node):
784
- def __init__(self):
785
- super().__init__("__PLUGIN_SLUG___node")
786
- self.get_logger().info("__PLUGIN_NAME__ node started")
787
- # use sdk alongside ROS2 publishers/subscribers/services as needed
788
- ```
789
-
790
-
791
- #### Talking to an external PC over ROS 2
792
-
793
- ROS 2 discovery stays on the robot (`ROS_AUTOMATIC_DISCOVERY_RANGE=LOCALHOST`; `muscat-pk dev`
794
- runs the same), so robots on one LAN don't mix topics. Allow specific PCs from the plugin UI —
795
- Muscat keeps the list per plugin, starts your backend with `ROS_STATIC_PEERS` set to it, and
796
- restarts the plugin when it changes. Your ROS code needs no changes; the PC needs no extra settings.
797
-
798
- ```tsx
799
- const rb = useRb();
800
- const peers = await rb.rosPeers.get(); // ["192.168.0.50"]
801
- await rb.rosPeers.set(["192.168.0.50", "192.168.0.51"]); // saves + restarts the plugin; rejects on a bad IP
802
- ```
803
-
804
- - Per plugin: plugin B keeps its allowed PCs when plugin A (allowing the same PC) is stopped or removed.
805
- - It opens communication; it is not access control — a PC that knows the robot's IP can still
806
- add the robot to its own `ROS_STATIC_PEERS`.
807
- - In `muscat-pk dev` the list is kept in `.muscat-dev/ros-peers.json` (ROS 2 backends).
808
-
809
- ### ROS2 (rclcpp)
810
-
811
- ROS2 C++ plugins use `ros2_ws/` with `runtime.rosClient: "rclcpp"`. The node links
812
- the Rainbow C++ SDK the same way as the `cpp` runtime (see _C++ SDK_ above).
813
-
814
- ```cpp
815
- #include "rclcpp/rclcpp.hpp"
816
-
817
- class __PLUGIN_CLASS__Node : public rclcpp::Node {
818
- public:
819
- __PLUGIN_CLASS__Node() : Node("__PLUGIN_SLUG___node") {
820
- RCLCPP_INFO(this->get_logger(), "__PLUGIN_NAME__ node started");
821
- }
822
- };
823
- ```
824
-
825
- ---
826
-
827
- ## Video streaming (MediaMTX)
828
-
829
- Muscat runs a MediaMTX media server. The backend publishes a stream over RTSP and
830
- the UI plays it over WebRTC. `muscat-pk dev` runs the same server (or uses a local
831
- Muscat's), so the addresses are identical in dev and on the robot.
832
-
833
- | | Address |
834
- | --- | --- |
835
- | Publish (backend) | `rtsp://127.0.0.1:8554/<plugin_id>/<name>` |
836
- | Play (UI) | `http://<host>:8889/<plugin_id>/<name>/` |
837
-
838
- - Prefix the path with your plugin id — paths are shared with robot cameras and other plugins.
839
- - The robot runtime has no ffmpeg, GStreamer or OpenCV; bring your own encoder (below).
840
-
841
- **Backend (Python)** — use PyAV: add `av` and `numpy` to `requirements.txt`. Its wheels
842
- include FFmpeg, so they ship in the ZIP like any other dependency.
843
-
844
- ```python
845
- import fractions, time
846
- import av, numpy as np
847
-
848
- out = av.open("rtsp://127.0.0.1:8554/__PLUGIN_ID__/cam", mode="w", format="rtsp",
849
- options={"rtsp_transport": "tcp"})
850
- stream = out.add_stream("libx264", rate=25, options={"preset": "ultrafast", "tune": "zerolatency"})
851
- stream.width, stream.height, stream.pix_fmt = 640, 360, "yuv420p"
852
-
853
- i = 0
854
- while True:
855
- img = np.zeros((360, 640, 3), dtype=np.uint8) # your RGB frame here
856
- frame = av.VideoFrame.from_ndarray(img, format="rgb24")
857
- frame.pts, frame.time_base = i, fractions.Fraction(1, 25)
858
- for packet in stream.encode(frame):
859
- out.mux(packet)
860
- i += 1
861
- time.sleep(1 / 25)
862
- ```
863
-
864
- Run the encoder loop in a thread (or `asyncio.to_thread`) so it doesn't block the
865
- `RBPluginCall` handlers.
866
-
867
- **UI** — embed MediaMTX's WebRTC player. `location.hostname` is `localhost` in the
868
- preview and the robot's address on the robot:
869
-
870
- ```tsx
871
- const STREAM = "__PLUGIN_ID__/cam"; // the path the backend publishes
872
-
873
- export default function Camera() {
874
- return (
875
- <iframe
876
- src={`${location.protocol}//${location.hostname}:8889/${STREAM}/`}
877
- allow="autoplay"
878
- className="aspect-video w-full border-0"
879
- />
880
- );
881
- }
882
- ```
883
-
884
- ---
885
-
886
- ## manifest.json
887
-
888
- ```jsonc
889
- {
890
- // unique plugin ID — reverse-domain format recommended
891
- "id": "com.example.my-plugin",
892
- "name": "My Plugin",
893
- "version": "1.0.0",
894
-
895
- // minimum Muscat version required to load this plugin
896
- "minMuscatVersion": "1.0.0",
897
-
898
- // plugin author (empty string is fine)
899
- "author": "Your Name",
900
-
901
- // displayed in the Muscat plugin store / info panel
902
- // string or { en (required), ko, ja, zh, vi, th, es }
903
- "description": { "en": "Plugin description", "ko": "플러그인 설명" },
904
-
905
- // SVG string (single file) or PNG/JPG size map (all three sizes required)
906
- "icons": "assets/icon.svg",
907
- // "icons": { "16": "assets/icon-16.png", "48": "assets/icon-48.png", "128": "assets/icon-128.png" },
908
-
909
- // targetModels and/or targetCategorys — at least one is required
910
- // "all" = every target, or list specific values
911
- "targetModels": "all",
912
- // "targetModels": ["C500920", "C500930"],
913
- "targetCategorys": "all",
914
- // "targetCategorys": ["manipulate", "amr", "rby1", "vision"],
915
-
916
- "contributes": [
917
- // program.panel — defines a custom command for the Program page sequence
918
- // builder; runs via PluginProgramContext when the program reaches it
919
- {
920
- "point": "program.panel",
921
- "icon": "assets/icon.svg",
922
- "label": { "ko": "내 플러그인", "en": "My Plugin" },
923
- "ui": "program", // must be unique across all contributions
924
- "commandInfo": {
925
- "name": "My Plugin",
926
- "icon": "assets/icon.svg",
927
- "funcName": "rb_program_sdk.plugin_execute",
928
- "args": {}
929
- }
930
- },
931
- // toolbar — adds a button to the top toolbar (required)
932
- {
933
- "point": "toolbar",
934
- "icon": "assets/icon.svg",
935
- "label": { "ko": "내 플러그인", "en": "My Plugin" },
936
- "ui": "content", // must be unique across all contributions
937
- "opens": "popover" // popover | panel | page
938
- },
939
- // move.panel — adds a panel to the Move screen
940
- {
941
- "point": "move.panel",
942
- "icon": "assets/icon.svg",
943
- "label": { "ko": "내 플러그인", "en": "My Plugin" },
944
- "ui": "move" // must be unique across all contributions
945
- }
946
- ],
947
-
948
- // optional: variables this plugin exposes to pyfm programs — the Program
949
- // page variable picker lists ONLY declared variables. name must start with
950
- // RBP_; description is optional but without it the UI can't explain the
951
- // variable, so write one.
952
- "variables": [
953
- { "name": "RBP_grip_force", "type": "number", "init": 0.5, "description": { "ko": "그리퍼 힘", "en": "Gripper force" } },
954
- { "name": "RBP_target_pose", "type": "array", "init": [0, 0, 0] }
955
- ],
956
-
957
- // runtime — one of:
958
- // { "type": "none" }
959
- // { "type": "python", "entry": "main.py" }
960
- // { "type": "cpp", "entry": "<executable name>" }
961
- // { "type": "ros2", "rosClient": "rclpy" | "rclcpp", "package": "...", "launch": "..._node" }
962
- "runtime": { "type": "python", "entry": "main.py" },
963
-
964
- // optional: TCP ports opened by this plugin backend
965
- // only number[] is allowed; ranges/objects are not accepted
966
- // external plugin ports must be within 28600-28999
967
- "ports": [28600]
968
- }
969
- ```
970
-
971
- **Backend types:**
972
-
973
- | type | source dir | build / run | entry |
974
- |---|---|---|---|
975
- | `python` | `py/` | pip + run in Docker | `main.py` |
976
- | `cpp` | `cpp/` | CMake in Docker | built executable name |
977
- | `ros2` (rclpy) | `ros2_ws/` | colcon (ament_python) in Docker | — |
978
- | `ros2` (rclcpp) | `ros2_ws/` | colcon (ament_cmake) in Docker | — |
979
-
980
- > Every back end runs in a Docker container matching the robot (Ubuntu 24.04 + Python 3.12), so dev behaves like production and compiled binaries are ABI-compatible. **Docker is required** for all backend types (only a frontend-only `none` plugin skips it). No local Python/toolchain setup is needed.
981
-
982
- **Contribution points:**
983
-
984
- | point | Where it appears | Backend | `commandInfo` |
985
- |---|---|---|:---:|
986
- | `toolbar` | Top toolbar button (must have at least one) | SDK / `rb.action` | — |
987
- | `lnb` | Left navigation entry | SDK / `rb.action` | — |
988
- | `move.panel` | Move screen panel | SDK / `rb.action` | — |
989
- | `program.panel` | Custom command in the Program page sequence builder | `PluginProgramContext` | ✓ |
990
-
991
- **`commandInfo` fields** (for `program.panel`):
992
-
993
- | field | type | description |
994
- |---|---|---|
995
- | `name` | `string` | Display name of the command in the program tree |
996
- | `icon` | `string` | Icon path |
997
- | `iconClassName` | `string?` | Optional CSS class for the icon |
998
- | `funcName` | `string?` | Backend function name to call |
999
- | `args` | `Record<string, any>?` | Default arguments |
1000
- | `disabled` | `boolean?` | Whether the node is disabled |
1001
- | `allowChildren` | `string[]?` | Allowed child commands |
1002
- | `allowParents` | `string[]?` | Allowed parent commands |
1003
- | `notAllowChildren` | `string[]?` | Disallowed child commands |
1004
- | `syncGroupId` | `boolean?` | Sync group ID across children |
1005
- | `disableCopy` | `boolean?` | Prevent copy |
1006
- | `disableDelete` | `boolean?` | Prevent delete |
1007
- | `requireChildren` | `boolean?` | Whether a child command is required |
1008
- | `requireParent` | `boolean?` | Whether a parent command is required |
1009
- | `childrenCommand` | `string?` | Default command type for children |
1010
-
1011
- **Validation rules:**
1012
- - `id`, `name`, `version`, `minMuscatVersion` — required non-empty strings
1013
- - `author` — required non-empty string
1014
- - `description` — required (`string` or `{ en (required), ko?, ja?, zh?, vi?, th?, es? }`)
1015
- - `icons` — SVG: single string path ending in `.svg`; PNG/JPG: object with `"16"`, `"48"`, `"128"` all present
1016
- - `targetModels` — `"all"` or a non-empty array of robot model ID strings (at least one of `targetModels` / `targetCategorys` required)
1017
- - `targetCategorys` — `"all"` or a non-empty array of `"manipulate" | "amr" | "rby1" | "vision"` (at least one of `targetModels` / `targetCategorys` required)
1018
- - `contributes` — the plugin's screens; when present it must contain at least one `toolbar` entry. Leave it out (or `[]`) for a backend-only plugin: no UI is built, and Node.js is not needed to build it
1019
- - `ui` — each contribution must have a unique value
1020
- - `commandInfo` — required when `point` is `"program.panel"`
1021
- - `ports` — optional `number[]`; every port must be within `28600-28999`
1022
- and must not be a reserved robot/industrial/internal port
1023
- - `variables` — optional array of `{ name, type, init, description? }`:
1024
- - `name` — must start with `RBP_`, then letters/digits/underscore; unique per plugin
1025
- - `type` — `"number" | "string" | "array"`
1026
- - `init` — required initial value, must match `type`
1027
- - `description` — optional `string` or i18n object (`en` required in object form)
1028
-
1029
- `muscat-pk build` throws on any validation error. `muscat-pk dev` prints
1030
- warnings — except `variables` errors (e.g. a name without the `RBP_` prefix),
1031
- which are hard errors in `dev` / `dev:back` too.
1032
-
1033
- **Backend port rules:**
1034
- - Declare a port only if your backend opens a TCP server/listener.
1035
- - **Every listening TCP port must be declared.** `muscat-pk dev` and
1036
- `muscat-pk dev:back` watch the backend and stop it the moment it opens a port
1037
- that is not in `ports`. A port outside `28600-28999` can't be declared, so it
1038
- must not be opened either — picking e.g. `5500` is not a way around the rule.
1039
- It would escape this check *and* the robot's install-time conflict check, and
1040
- only collide once it's running on a robot.
1041
- - Reserved ports include `502`, `2222`, `4840`, `5170-5179`, `8000-8999`,
1042
- `10000`, `30004`, `34964`, `44818`, `50000-50005`, and `50051`.
1043
- - The robot rejects installation (HTTP 409) if a declared port is reserved,
1044
- out of range, or already claimed by another enabled plugin.
1045
-
1046
- **Reaching your backend's ports during development:**
1047
- - Declared ports are published to `127.0.0.1` on your machine, so
1048
- `curl http://127.0.0.1:28600` works from the host on macOS, Windows, and Linux.
1049
- - On native Linux the dev container uses host networking, so the ports are on
1050
- the host already.
1051
- - If a declared port is already taken on your machine, `muscat-pk dev` warns and
1052
- skips publishing that one — the backend still listens inside the container.
1053
-
1054
- ---
1055
-
1056
- ## Localized documentation
1057
-
1058
- You can ship per-language README files alongside the main `README.md`. Place them in the project root:
1059
-
1060
- ```
1061
- README.md # default / English fallback
1062
- README.ko.md # Korean
1063
- README.en.md # explicit English (optional)
1064
- README.ja.md # Japanese
1065
- ```
1066
-
1067
- All `README*.md` files found at the project root are automatically copied to `dist/` on
1068
- `muscat-pk build`. Muscat displays the file matching the user's locale,
1069
- falling back to `README.md` if none matches.
1070
-
1071
- ---
1072
-
1073
- ## Installing Python dependencies
1074
-
1075
- Add any package with an optional version pin to `requirements.txt`:
1076
-
1077
- ```
1078
- rainbow-rb-sdk
1079
- rainbow-rb-flat-buffers
1080
- numpy==1.26.4
1081
- opencv-python
1082
- ```
1083
-
1084
- Run `muscat-pk dev` or `muscat-pk dev:back` — dependencies are installed automatically
1085
- inside the dev container (`pip install -r requirements.txt`) on every startup. No
1086
- local Python is required for this.
1087
-
1088
- `muscat-pk dev:back` also best-effort creates/refreshes a local `.venv` in the
1089
- background (via `uv` if available, else your system Python) with the same deps —
1090
- purely so your editor (pylint, pyright, mypy, ...) can resolve imports and lint
1091
- correctly. It's not used to run the backend; Docker is the actual execution
1092
- environment, so this step is silently skipped if no local Python toolchain is
1093
- available. Point your editor's Python interpreter at `.venv`.
1094
-
1095
- `muscat-pk sdk update` also refreshes `.venv` in place if one already exists, so
1096
- your editor picks up the new `rainbow-rb-sdk` version immediately.
1097
-
1098
- > `muscat-pk build` only **pins** versions in `requirements.txt` — it does not run `pip install` (the robot installs them at load time).
1099
-
1100
- ---
1101
-
1102
- ## Switching backend type
1103
-
1104
- ```bash
1105
- muscat-pk type change # interactive
1106
- muscat-pk type change --to cpp # → C++
1107
- muscat-pk type change --to ros2 # → ROS2 (rclpy / Python)
1108
- muscat-pk type change --to ros2-cpp # → ROS2 (rclcpp / C++)
1109
- muscat-pk type change --to python # → Python
1110
- ```
1111
-
1112
- > Existing backend dirs (`ros2_ws/`, `cpp/`) are not deleted when switching away. Remove them manually if no longer needed.
1113
-
1114
- > Switching between a Python and a C++ backend asks whether to replace this README with the matching one (the old file is kept as `README.md.bak`). Add `--readme` to answer yes without the prompt.
1115
-
1116
- ---
1117
-
1118
- ## AI coding assistants (MCP)
1119
-
1120
- If you build your plugin with an AI assistant (Claude Code, Codex, …), give it
1121
- grounded knowledge of the Muscat UI components, design tokens, and SDK instead of
1122
- letting it guess. `muscat-pk mcp` is an MCP server exposing:
1123
-
1124
- - `list_components` / `get_component_props` / `get_component_example` — every
1125
- `@rainbow-robotics/plugin-ui/react` component with its real props and a working
1126
- Storybook example.
1127
- - `list_design_tokens` / `search_design_tokens` — the Tailwind classes to use
1128
- (`bg-bg-*`, `text-fg-*`, `typo-*`, …) so your UI matches the host theme.
1129
- - `search_rb_sdk` — how to use `useRb()`, `rb.*`, `useRbFormData`/`useRbSave`, and
1130
- the backend runtimes.
1131
- - `list_rb_sdk` / `get_rb_sdk_api` — the installed `rainbow-rb-sdk` (Python
1132
- backend) classes and signatures; with `lang: "cpp"`, the C++ SDK headers
1133
- (`rainbow-rb-sdk-dev`, read out of the `muscat-pk` cpp dev image — build it
1134
- once with `muscat-pk dev:back`).
1135
-
1136
- **Browse the components live** with `muscat-pk storybook`.
1137
-
1138
- `muscat-pk new` also writes `AGENTS.md` (read by Codex, Cursor, Gemini CLI, Copilot)
1139
- and a `CLAUDE.md` that imports it: the UI rules (plugin-ui components instead of
1140
- native `<select>`/`<input>`/`<button>`, design tokens for colors) for assistants that
1141
- do not call the MCP tools on their own. Add your own project notes there.
1142
-
1143
- ### Register the server
1144
-
1145
- **Claude Code** — a `.mcp.json` is already scaffolded at your plugin root, so
1146
- it works out of the box:
1147
-
1148
- ```json
1149
- {
1150
- "mcpServers": {
1151
- "muscat": {
1152
- "command": "muscat-pk",
1153
- "args": ["mcp"]
1154
- }
1155
- }
1156
- }
1157
- ```
1158
-
1159
- **Codex** — register once with:
1160
-
1161
- ```bash
1162
- muscat-pk mcp --install codex
1163
- ```
1164
-
1165
- This appends the server to `~/.codex/config.toml` (idempotent — safe to run
1166
- again). Equivalent manual config:
1167
-
1168
- ```toml
1169
- [mcp_servers.muscat]
1170
- command = "muscat-pk"
1171
- args = ["mcp"]
1172
- ```
1173
-
1174
- Any other MCP client works too — it's a standard stdio MCP server started with
1175
- `muscat-pk mcp`. If `muscat-pk` isn't on the client's PATH, use its absolute path
1176
- (`which muscat-pk`) as `command`.
1177
-
1178
- ---
1179
-
1180
- ## Shipping
1181
-
1182
- ```bash
1183
- muscat-pk build # build + ZIP
1184
- muscat-pk build --no-zip # build only, no ZIP
1185
- ```
1186
-
1187
- > Building the other arch (e.g. arm64 on an amd64 PC) runs in an emulated container for
1188
- > C++ backends. Docker Desktop has it built in; on Linux enable it once with
1189
- > `docker run --privileged --rm tonistiigi/binfmt --install all`, or pass `--arch` for one arch.
1190
-
1191
- - Builds `ui/content.tsx` → `dist/content.js` (minified)
1192
- - Pins `requirements.txt` to latest versions (stable-only on `main` branch)
1193
- - Copies `assets/`, `manifest.json`, `README*.md`, `py/`, and `requirements.txt` into `dist/`
1194
- - ROS2: runs `colcon build` inside the Jazzy container; includes `ros2_ws/install/`
1195
- - Output: `__PLUGIN_DIR__-{version}.zip`
1196
-
1197
- ZIP layout:
1198
-
1199
- ```
1200
- __PLUGIN_DIR__-1.0.0.zip
1201
- ├─ dist/
1202
- │ ├─ content.js # built UI
1203
- │ ├─ manifest.json
1204
- │ ├─ assets/
1205
- │ ├─ README.md
1206
- │ ├─ README.ko.md # if present
1207
- │ ├─ main.py # from py/
1208
- │ └─ requirements.txt # pinned
1209
- └─ ros2_ws/install/ # ROS2 only
1210
- ```
1211
-
1212
- ---
1213
-
1214
- ## All commands
1215
-
1216
- ```bash
1217
- muscat-pk dev # UI + backend
1218
- muscat-pk dev:ui # UI preview only (preview-host)
1219
- muscat-pk dev:back # backend only (Python: watchfiles / ROS2: Docker)
1220
- muscat-pk build # build → dist/ + distributable ZIP (--no-zip to skip)
1221
- muscat-pk storybook # browse the rb-components UI library in a browser
1222
- muscat-pk mcp # MCP server for AI assistants (components/tokens/SDK docs)
1223
- ```
1224
-
1225
- ```bash
1226
- muscat-pk type change # switch backend type
1227
- muscat-pk sdk list # show current Python SDK version
1228
- muscat-pk sdk update # update Python SDK to latest
1229
- muscat-pk update # update muscat-plugin-kit itself
1230
- muscat-pk uninstall # remove muscat-pk itself
1231
- ```
1
+ # __PLUGIN_NAME__
2
+
3
+ A Muscat plugin project. Build, develop, and ship with `muscat-pk` CLI.
4
+
5
+ ## Quick start
6
+
7
+ ```bash
8
+ muscat-pk dev # UI preview + backend together
9
+ muscat-pk dev:ui # UI preview only
10
+ muscat-pk dev:back # backend only (Python watchfiles / ROS2 Docker)
11
+ muscat-pk build # build + distributable ZIP
12
+ ```
13
+
14
+ ---
15
+
16
+ ## Project structure
17
+
18
+ ```
19
+ __PLUGIN_DIR__/
20
+ ├─ ui/
21
+ │ ├─ content.tsx # plugin UI entry point
22
+ │ └─ style.css # plugin styles auto-loaded by muscat()
23
+ ├─ py/
24
+ │ └─ main.py # Python backend entry point
25
+ ├─ assets/
26
+ │ └─ icon.svg # icon referenced from manifest
27
+ ├─ manifest.json # metadata, contribution points, runtime config
28
+ ├─ README.md # this file (copied to dist/ on build)
29
+ └─ requirements.txt # Python deps (auto-pinned to latest on build)
30
+ ```
31
+
32
+ > ROS2 plugins additionally include a `ros2_ws/` directory.
33
+
34
+ ---
35
+
36
+ ## Frontend (`ui/content.tsx`)
37
+
38
+ ### JavaScript dependencies
39
+
40
+ `muscat-pk build` runs this plugin project's own Vite build. Vite resolves
41
+ imports from installed `node_modules`; it does not care whether a package is
42
+ listed under `dependencies` or `devDependencies`, but the package must be
43
+ installed.
44
+
45
+ Do not run a production-only install such as `npm install --omit=dev` before
46
+ building the plugin UI, because build tools like `vite`, `typescript`,
47
+ `tailwindcss`, `@tailwindcss/vite`, `sass`, or `less` are usually
48
+ `devDependencies`.
49
+
50
+ The scaffold keeps this split:
51
+
52
+ | package kind | examples | why |
53
+ |---|---|---|
54
+ | `dependencies` | `@rainbow-robotics/plugin-ui`, UI libraries imported by plugin source | Author-facing APIs used by `ui/*.tsx` |
55
+ | `devDependencies` | `vite`, `typescript`, `tailwindcss`, `@tailwindcss/vite`, `sass`, `less` | Build/dev tools needed before `dist/*.js` is produced |
56
+
57
+ The built plugin ZIP does not include `package.json` or `node_modules`. For
58
+ production, Muscat provides `@rainbow-robotics/plugin-ui`, React, and ReactDOM
59
+ through the host import map; plugin UI build output imports those as host
60
+ externals.
61
+
62
+ ### Styles
63
+
64
+ `muscat()` automatically loads `ui/style.{css,scss,sass,less}` (or
65
+ `ui/styles.{css,scss,sass,less}`) for every UI entry, plus
66
+ `ui/{entry}.{css,scss,sass,less}` for a matching entry-specific stylesheet. You
67
+ do not need to import CSS from every `.tsx` file.
68
+
69
+ The scaffold uses Tailwind's theme/utilities layers only, so plugin utilities
70
+ are generated from files under `ui/` without applying Tailwind preflight resets
71
+ to the host page.
72
+
73
+ Built CSS selectors are scoped under the current plugin id
74
+ (`[data-muscat-plugin-id="..."]`). CSS-in-JS libraries such as Emotion work
75
+ normally, but global styles should be scoped by the author.
76
+
77
+ #### Tailwind
78
+
79
+ This template enables Tailwind in `vite.config.ts`:
80
+
81
+ ```ts
82
+ import { defineConfig } from "vite";
83
+ import tailwindcss from "@tailwindcss/vite";
84
+ import { muscat } from "@rainbow-robotics/muscat-plugin-kit/vite";
85
+
86
+ export default defineConfig({
87
+ plugins: [tailwindcss(), muscat()],
88
+ });
89
+ ```
90
+
91
+ The default `ui/style.css` uses Tailwind theme/utilities only, not preflight:
92
+
93
+ ```css
94
+ @layer theme, utilities;
95
+
96
+ @import "tailwindcss/theme.css" layer(theme);
97
+ @import "tailwindcss/utilities.css" layer(utilities);
98
+
99
+ @source "./**/*.{ts,tsx}";
100
+ ```
101
+
102
+ #### SCSS, Sass, and Less
103
+
104
+ Install the preprocessor package, then create one of the auto-loaded style files.
105
+
106
+ ```bash
107
+ npm i -D sass
108
+ ```
109
+
110
+ ```scss
111
+ /* ui/style.scss */
112
+ $accent: #3b82f6;
113
+
114
+ .panel {
115
+ border: 1px solid color-mix(in srgb, $accent 40%, transparent);
116
+
117
+ &__title {
118
+ color: $accent;
119
+ }
120
+ }
121
+ ```
122
+
123
+ For Less, install `less` and use `ui/style.less`.
124
+
125
+ #### Emotion
126
+
127
+ Emotion dependencies may be in `dependencies` or `devDependencies`; Vite can
128
+ bundle them when they are installed and imported by the plugin.
129
+
130
+ For `styled`, no Vite config change is needed:
131
+
132
+ ```bash
133
+ npm i @emotion/react @emotion/styled
134
+ ```
135
+
136
+ ```tsx
137
+ import styled from "@emotion/styled";
138
+
139
+ const Panel = styled.div({
140
+ display: "grid",
141
+ gap: 12,
142
+ });
143
+ ```
144
+
145
+ For Emotion's `css` prop, pass React plugin options through `muscat()` and add
146
+ `jsxImportSource` to `tsconfig.json`:
147
+
148
+ ```bash
149
+ npm i @emotion/react @emotion/styled
150
+ npm i -D @emotion/babel-plugin
151
+ ```
152
+
153
+ ```ts
154
+ // vite.config.ts
155
+ import { defineConfig } from "vite";
156
+ import tailwindcss from "@tailwindcss/vite";
157
+ import { muscat } from "@rainbow-robotics/muscat-plugin-kit/vite";
158
+
159
+ export default defineConfig({
160
+ plugins: [
161
+ tailwindcss(),
162
+ muscat({
163
+ react: {
164
+ jsxImportSource: "@emotion/react",
165
+ babel: {
166
+ plugins: ["@emotion/babel-plugin"],
167
+ },
168
+ },
169
+ }),
170
+ ],
171
+ });
172
+ ```
173
+
174
+ ```json
175
+ {
176
+ "compilerOptions": {
177
+ "jsxImportSource": "@emotion/react"
178
+ }
179
+ }
180
+ ```
181
+
182
+ Emotion `Global` writes global CSS at runtime, so scope it manually or avoid it
183
+ inside plugins.
184
+
185
+ ### useRb() hook
186
+
187
+ Access the communication API between the plugin module and UI from any plugin component.
188
+
189
+ ```tsx
190
+ import { useRb } from "@rainbow-robotics/plugin-ui/sdk";
191
+
192
+ function App() {
193
+ const rb = useRb();
194
+ }
195
+ ```
196
+
197
+ ### rb API
198
+
199
+ | Method | Description |
200
+ | ------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
201
+ | `rb.pub(topic, payload)` | Publish a message to a topic |
202
+ | `rb.sub(topic, handler)` | Subscribe to a topic — returns `{ undeclare() }` |
203
+ | `rb.action(topic, payload?)` | Send a request and await a reply |
204
+ | `rb.serve(topic, handler)` | Handle incoming requests from the backend |
205
+ | `rb.toast({ title?, message, type?, actionLabel?, handleActionClick?, closeOnActionClick?, duration?, onClose? })` | Show a host toast notification. `message` accepts a string or `ReactElement`. `type` can be `"info"`, `"danger"`, `"warning"`, `"caution"`, or `"success"` |
206
+ | `rb.alert({ title?, message, buttonText? })` | Show a host alert dialog. Resolves when the dialog is closed |
207
+ | `rb.confirm({ title?, message, buttonText? })` | Show a host confirmation dialog. `buttonText` accepts `{ yes, no }`. Resolves to `true` or `false` |
208
+ | `rb.prompt({ title?, message, value?, placeholder?, buttonText? })` | Show a host prompt dialog. `buttonText` accepts `{ yes, no }`. Resolves to the entered string or `undefined` |
209
+
210
+ Topic names passed to `pub`, `sub`, `action`, and `serve` are isolated per plugin.
211
+
212
+
213
+ All methods support Generic Types:
214
+
215
+ ```tsx
216
+ const status = await rb.action<{ ok: boolean }, { id: number }>(
217
+ "get-status",
218
+ { id: 1 },
219
+ );
220
+
221
+ const sub = await rb.sub<{ value: number }>("sensor/data", (data) => {
222
+ console.log(data.value);
223
+ });
224
+ sub.undeclare(); // cleanup
225
+
226
+ const ok = await rb.confirm({
227
+ title: "Run",
228
+ message: "Start the backend action?",
229
+ });
230
+ if (ok) {
231
+ await rb.action("run");
232
+ }
233
+ ```
234
+
235
+ ### Saving and restoring form data (`useRbFormData` / `useRbSave`)
236
+
237
+ If the host embeds your plugin in a form it owns (e.g. a program step editor
238
+ with its own Save button), it can hand your plugin previously-saved values and
239
+ pull the current values back out when the user saves. The host — not the
240
+ plugin — persists the data (IndexedDB, backend, etc.).
241
+
242
+ ```tsx
243
+ import { useState } from "react";
244
+ import { useRbFormData, useRbSave } from "@rainbow-robotics/plugin-ui/react";
245
+
246
+ type GripArgs = { targetName: string; width: number };
247
+ const DEFAULTS: GripArgs = { targetName: "", width: 20 };
248
+
249
+ export default function App() {
250
+ // Previously-saved values, or undefined for a brand-new instance.
251
+ const saved = useRbFormData<GripArgs>();
252
+ const [args, setArgs] = useState<GripArgs>({ ...DEFAULTS, ...saved });
253
+
254
+ // Registers a getter the host calls when its Save button is clicked.
255
+ // Always returns the CURRENT values — re-registering on every render keeps
256
+ // the closure fresh, which useRbSave already handles for you.
257
+ useRbSave(() => ({
258
+ args,
259
+ summary: `${args.targetName || "target"} · ${args.width}mm`,
260
+ }));
261
+
262
+ return (/* ...form UI reading/writing `args`... */);
263
+ }
264
+ ```
265
+
266
+ The value returned from `useRbSave` must be JSON-serialisable — the host
267
+ persists it as-is. Not every host embeds plugins in a save/restore form; if
268
+ yours doesn't, you can skip these hooks entirely.
269
+
270
+ ### UI components
271
+
272
+ Import Muscat components from `@rainbow-robotics/plugin-ui/react` and export a
273
+ React component as the default export. The host mounts that component directly
274
+ and provides `useRb()` through context.
275
+
276
+ ```tsx
277
+ import { useState } from "react";
278
+ import { RBLabelButton, RBSlider, RBSwitch } from "@rainbow-robotics/plugin-ui/react";
279
+ import { useRb } from "@rainbow-robotics/plugin-ui/sdk";
280
+
281
+ export default function App() {
282
+ const rb = useRb();
283
+ const [width, setWidth] = useState(20);
284
+ const [enabled, setEnabled] = useState(true);
285
+
286
+ return (
287
+ <div className="flex flex-col gap-4">
288
+ <RBSlider
289
+ value={[width]}
290
+ min={0}
291
+ max={100}
292
+ onValueChange={(values: number[]) => setWidth(values[0] ?? 0)}
293
+ />
294
+ <RBSwitch checked={enabled} onCheckedChange={setEnabled} />
295
+ <RBLabelButton
296
+ disabled={!enabled}
297
+ onClick={() => rb.action("grip", { width })}
298
+ >
299
+ Grip
300
+ </RBLabelButton>
301
+ </div>
302
+ );
303
+ }
304
+ ```
305
+
306
+ ---
307
+
308
+ ## Developing directly on the robot PC
309
+
310
+ If you're running `muscat-pk dev` **on the robot PC itself**, Muscat already serves the
311
+ plugin gateway on port 10000. The CLI sees that, does not start its own local Muscat, and
312
+ your plugin backend connects to the robot's gateway — exactly what you want when
313
+ developing on the robot.
314
+
315
+ ---
316
+
317
+ ## Backend runtimes
318
+
319
+ `manifest.json` chooses one backend runtime. `muscat-pk dev` runs backend code in
320
+ a Docker container matching the robot environment. Backend code should contain
321
+ plugin logic only; it should not start its own copy of the plugin gateway.
322
+
323
+ ### Which backend pattern? (depends on the contribution point)
324
+
325
+ **How you control the robot depends on what your plugin contributes** (see
326
+ `manifest.json` → `contributes[].point`). Don't mix these up:
327
+
328
+ | Contribution point | What it is | Backend pattern |
329
+ | --- | --- | --- |
330
+ | `program.panel` (with `commandInfo`) | Defines a **custom command** the plugin adds to the Program page's rule-based sequence builder. The operator places it into the program tree like any built-in command (Move J, Point, Wait, …); it runs when the program reaches that step. | **`PluginProgramContext`** — integrated with the program's stop/pause and variables. See _Program command backend_ below. |
331
+ | `toolbar` / `lnb` / `move.panel` | A live UI panel/popover the operator interacts with directly | Call the robot **SDK directly** (`RBManipulateSDK`, `RBAmrSDK`) from `py/main.py`, or `rb.action(...)` → your backend → SDK. **Not** a program step — there is no stop/pause/variable context. |
332
+
333
+ Rule of thumb: use `PluginProgramContext` **only** for a `program.panel` command
334
+ that runs as part of a program. To move the robot live from a toolbar/move panel,
335
+ use the SDK — a program-step context does not apply.
336
+
337
+ ### Frontend-only (`none`)
338
+
339
+ Use this when the plugin only has UI and calls host services:
340
+
341
+ ```json
342
+ { "runtime": { "type": "none" } }
343
+ ```
344
+
345
+ ### Python
346
+
347
+ Python plugins use `py/main.py` and can communicate with the UI through
348
+ `rainbow.rb_sdk.RBPluginCall`.
349
+
350
+ ```python
351
+ import time
352
+ from rainbow.rb_sdk import RBAmrSDK, RBManipulateSDK, RBPluginCall
353
+
354
+ plugin = RBPluginCall()
355
+
356
+
357
+ plugin.publish("state", {"ready": True})
358
+
359
+
360
+ @plugin.subscribe("event")
361
+ def on_ui_event(payload=None):
362
+ print(payload)
363
+
364
+
365
+ @plugin.serve("tool/echo")
366
+ def echo(payload=None):
367
+ return {"echo": payload}
368
+
369
+
370
+ def call_frontend():
371
+ res = plugin.call("tool/ping", payload={"hello": "plugin"})
372
+ print(res)
373
+
374
+
375
+ def move_j_example():
376
+ amr = RBAmrSDK()
377
+ manipulate = RBManipulateSDK()
378
+ return manipulate.move.call_move_j(
379
+ robot_model="C500920",
380
+ target={"tar_values": [0, 0, 0, 0, 0, 0], "tar_frame": 0, "tar_unit": 0},
381
+ )
382
+
383
+
384
+ if __name__ == "__main__":
385
+ while True:
386
+ time.sleep(1)
387
+ ```
388
+
389
+ #### External endpoints (`plugin.router`)
390
+
391
+ `plugin.serve` / `plugin.subscribe` / `plugin.publish` are **internal only** (plugin
392
+ UI ↔ backend, plugin ↔ plugin). To expose an endpoint to the outside world —
393
+ REST, Socket.IO, TCP, MQTT, WebRTC — use `plugin.router`. Declaring one is a
394
+ single decorator; the running Muscat (`common`) picks it up without a restart,
395
+ and it disappears when the plugin stops or is removed.
396
+
397
+ > Requires a robot running `rainbow-rb-sdk` **0.0.9.dev5 or newer** (the SDK is
398
+ > provided by the plugin container at runtime). On older robots `plugin.router`
399
+ > does not exist and raises `AttributeError`.
400
+
401
+ ```python
402
+ from rainbow.rb_sdk import RBPluginCall
403
+ from rainbow.rb_sdk.plugin import PluginRouteError
404
+
405
+ plugin = RBPluginCall() # plugin_id comes from env RB_PLUGIN_ID
406
+
407
+
408
+ @plugin.router.on("/hello") # all 5 transports
409
+ def hello(payload: dict, meta: dict):
410
+ return {"msg": f"hi {payload.get('name')}"} # return value = response body
411
+
412
+
413
+ @plugin.router.on("/admin", explicit_routes=["api"]) # REST only
414
+ def admin(payload, meta):
415
+ if not (meta.get("principal") or {}).get("is_admin"):
416
+ raise PluginRouteError(403, "admin only") # becomes the external status
417
+ ...
418
+
419
+
420
+ @plugin.router.subscribe("/cmd", explicit_events=["tcp"]) # receive external TCP publish
421
+ def on_cmd(payload: dict):
422
+ ...
423
+
424
+
425
+ plugin.router.emit("/state", {"busy": True}) # push to external subscribers
426
+ ```
427
+
428
+ | API | Meaning | External address |
429
+ | --- | --- | --- |
430
+ | `router.on(path, explicit_routes=None)` | request/response handler | `common/plugin/{plugin_id}/{path}` (REST is always `POST`) |
431
+ | `router.subscribe(topic, explicit_events=None)` | receive external publishes | external side publishes to `common/plugin/{plugin_id}/{topic}` |
432
+ | `router.emit(topic, payload)` | plugin → external push | external side subscribes to `common/plugin/{plugin_id}/{topic}` |
433
+
434
+ - The `plugin_id` prefix is added by the SDK — write only the path in plugin code.
435
+ - Paths may have several segments (`/tool/echo`). Declaring the same path twice raises `ValueError`.
436
+ - Transport names: `api | socket | tcp | mqtt | webrtc`. `subscribe` accepts only `tcp | mqtt | webrtc`. Unknown names raise `ValueError`.
437
+ - `explicit_routes` / `explicit_events` of `None` means every transport. A request on a transport not listed gets 404.
438
+
439
+ Handler contract:
440
+
441
+ - Signature `(payload, meta)`, `(payload)` or `()` — only the arguments you declare are injected. Sync and async both work.
442
+ - `payload`: what the caller sent (for REST: JSON body merged with query params). The shape is up to the plugin.
443
+ - `meta`: `{"transport": "api"|"socket"|"tcp"|"mqtt"|"webrtc", "principal": dict | None}`. When the robot's login policy is on, `principal` holds the token subject. **Role checks are the plugin's job** — inspect `meta["principal"]` yourself.
444
+ - The return value is the response body (must be JSON-serialisable). `None` → empty response.
445
+ - Status codes: `raise PluginRouteError(code, message)`. Any other exception → 500 with `str(exc)` as the message.
446
+ - `subscribe` handlers take `(payload)` only and return nothing.
447
+
448
+ #### Calling plugin endpoints from outside
449
+
450
+ | Transport | How |
451
+ | --- | --- |
452
+ | REST | `POST /common/plugin/{plugin_id}/{path}` (through the api-gateway), JSON body |
453
+ | Socket.IO | emit event `common/plugin/{plugin_id}/{path}`, response arrives in the ack |
454
+ | TCP | topic `common/plugin/{plugin_id}/{path}` request / publish |
455
+ | MQTT | request: `common/plugin/{plugin_id}/{path}/__req__`, publish: `common/plugin/{plugin_id}/{topic}` |
456
+ | WebRTC | data channel topic `common/plugin/{plugin_id}/{path}` |
457
+
458
+ Auth: when the robot's login policy is on, a token is **required** (any role — GUEST passes). When it is off, calls go straight through. REST uses the same Bearer token as the other Muscat APIs.
459
+
460
+ ```bash
461
+ curl -s -X POST http://<robot>/common/plugin/my-plugin/hello \
462
+ -H 'Content-Type: application/json' -d '{"name":"kim"}'
463
+ ```
464
+
465
+ | Situation | Status |
466
+ | --- | --- |
467
+ | No plugin declares that path / plugin is down / transport not allowed | 404 |
468
+ | Plugin did not answer within 10 s | 504 |
469
+ | `common` ↔ gateway not connected | 503 |
470
+ | Handler raised | 500 (`PluginRouteError` → its `code`) |
471
+
472
+ Known limits:
473
+
474
+ - A plugin path named `install` cannot be called over REST (`POST /common/plugin/{id}/install` is taken by the registry install route first). Socket/TCP are unaffected.
475
+ - Swagger shows one catch-all route, not per-plugin paths.
476
+ - The MQTT broker echoes to the sender: a plugin that `emit`s and `subscribe`s the same topic receives its own push over `mqtt`. Drop `mqtt` from `explicit_events` or use separate topics. (TCP is filtered by `sender_id`.)
477
+ - `on` and `subscribe` may share a name — they live in separate tables.
478
+
479
+ During `muscat-pk dev` the local Muscat runtime takes the `router.*` registrations
480
+ on its gateway (port `10000`), and its `common` (port `3000`) routes
481
+ `common/plugin/...` to them like on the robot — try
482
+ `curl http://localhost:3000/common/plugin/<plugin-id>/<path>`. After restarting the plugin process,
483
+ external calls may return 504 for up to about a minute (until the gateway notices
484
+ the old socket is gone), then 404 — that is expected.
485
+
486
+ #### Program command backend (`program.panel`)
487
+
488
+ A `program.panel` contribution defines a **custom command** your plugin adds to
489
+ the Muscat **Program page**, where the operator builds a robot program as a
490
+ rule-based sequence (a program tree). Your command appears alongside the
491
+ built-ins (Move J, Point, Wait, …); the operator places it into the tree, and
492
+ each time the program executes that step the host calls your backend through
493
+ `PluginProgramContext` — receiving the step args, checking for stop/pause,
494
+ reading/writing program variables, and signalling completion. This pattern is
495
+ **only** for `program.panel`; a toolbar/move-panel plugin uses the SDK directly
496
+ instead (see the table above).
497
+
498
+ ```python
499
+ """
500
+ Plugin backend main.py example (program.panel command).
501
+
502
+ manifest.json:
503
+ "runtime": {"type": "python", "entry": "main.py"},
504
+ "contributes": [{
505
+ "point": "program.panel",
506
+ "ui": "program",
507
+ "label": {"en": "My Plugin Step"},
508
+ "commandInfo": {
509
+ "name": "My Plugin Step",
510
+ "icon": "assets/icon.svg",
511
+ "funcName": "rb_program_sdk.plugin_execute",
512
+ "args": {"message": "hello"} # plugin-author-defined args
513
+ }
514
+ }]
515
+
516
+ The host invokes commandInfo.funcName (rb_program_sdk.plugin_execute) with
517
+ args.plugin_id + your args when the program reaches this step.
518
+ """
519
+ import asyncio
520
+ from rainbow.rb_sdk import PluginProgramContext
521
+
522
+ ctx = PluginProgramContext(plugin_id="my-plugin")
523
+
524
+
525
+ @ctx.on_execute
526
+ async def handle(args: dict) -> None:
527
+ """Called once when my Step executes. Variable writes apply to the
528
+ running program immediately + broadcast to other robots' main tasks."""
529
+ count = await ctx.get_variable("counter") # read an existing program variable
530
+ await ctx.set_variable("counter", count + 1) # update an existing variable — allowed
531
+ await ctx.set_variable("RBP_TEST", 1) # NEW variables must start with RBP_
532
+
533
+ for i in range(100):
534
+ await ctx.check_stop() # auto-scoped to this Step's robot model
535
+ await asyncio.sleep(0.5)
536
+
537
+ await ctx.done()
538
+
539
+
540
+ async def background_monitor() -> None:
541
+ """Resident task outside any Step. If no program is running,
542
+ get returns None and set is silently ignored — always safe to call."""
543
+ while True:
544
+ await asyncio.sleep(1.0)
545
+
546
+ # targets every running main task (no robot_model param — always broadcast)
547
+ await ctx.set_variable("RBP_TEST", 3)
548
+
549
+ # get options
550
+ v1 = await ctx.get_variable("counter") # first value found in the whole tree
551
+ v2 = await ctx.get_variable("counter", robot_model="C500920") # a specific robot
552
+ v3 = await ctx.get_variable("RBP_TEST", plugin_id="my-plugin") # the value my-plugin last wrote
553
+
554
+ if ctx.executor_state("C500920") == "paused":
555
+ print("C500920 program is paused")
556
+
557
+
558
+ async def main() -> None:
559
+ asyncio.create_task(background_monitor())
560
+ await ctx.start() # stays resident
561
+
562
+
563
+ asyncio.run(main())
564
+ ```
565
+
566
+ #### Program variables & executor state (`PluginProgramContext`)
567
+
568
+ `rainbow.rb_sdk.PluginProgramContext` lets the plugin process interact with the
569
+ running program (PyFM): read/write program variables and observe execution
570
+ state — inside a Step handler or from a resident task, at any time.
571
+
572
+ - The `on_execute` handler is called **only when PyFM executes this plugin's
573
+ Step**. One Step execution = one handler call, and the Step does not finish
574
+ until you call `ctx.done()` — always call it.
575
+ - The plugin process itself stays resident after `ctx.start()`; executor state
576
+ queries and variable access below keep working whether or not a Step is
577
+ executing.
578
+
579
+ #### Variable naming rules (important)
580
+
581
+ PyFM variables are a **program-wide shared namespace**. One rule:
582
+
583
+ - **Modifying an existing variable** — the name passes as-is. Exchanging
584
+ variables between the program and the plugin is the intended usage.
585
+ - **Creating a variable that doesn't exist** — the `RBP_` prefix is
586
+ **required** (enforced by the SDK/server; a violation raises
587
+ `RuntimeError`). Applies equally inside and outside Step execution.
588
+
589
+ For new variable names, a convention that includes the plugin id is strongly
590
+ recommended:
591
+
592
+ ```
593
+ RBP_{plugin_id}_{name}
594
+
595
+ e.g. RBP_vision_detect_count, RBP_gripper_last_error
596
+ ```
597
+
598
+ Why:
599
+
600
+ - `RBP_` — instantly identifies plugin-created variables in the program
601
+ editor/UI.
602
+ - `{plugin_id}` — **prevents name collisions with other plugins.** If two
603
+ plugins use the same name they overwrite the same variable (`RBP_` alone is
604
+ not something the SDK guards against).
605
+
606
+ #### Reading / writing variables
607
+
608
+ ```python
609
+ value = await ctx.get_variable("counter") # read (no name restriction)
610
+ await ctx.set_variable("counter", value + 1) # modify existing variable — allowed
611
+ await ctx.set_variable("RBP_my-plugin_result", 42) # create new variable — RBP_ required
612
+ ```
613
+
614
+ `set_variable` always applies to **every running main task** (a plugin belongs
615
+ to muscat, not to a specific robot — there is no `robot_model` parameter):
616
+
617
+ | When | Own program | Other robots' main tasks |
618
+ |---|---|---|
619
+ | During own Step execution | via the control queryable — **immediate** | broadcast — each at its next Step boundary |
620
+ | Outside a Step (resident task, …) | resident endpoint — next Step boundary | same |
621
+
622
+ `get_variable` has options:
623
+
624
+ ```python
625
+ await ctx.get_variable("counter") # search all main tasks (first value found)
626
+ await ctx.get_variable("counter", robot_model="C500920") # a specific robot's value
627
+ await ctx.get_variable("RBP_TEST", plugin_id="A.plugin") # the value that plugin last wrote
628
+ ```
629
+
630
+ Caveats:
631
+
632
+ - If no program is running, `set_variable` is **silently ignored** and
633
+ `get_variable` returns `None`. (`RuntimeError` is raised only for real
634
+ errors, like the `RBP_` violation on a new variable — the message is tagged
635
+ `[plugin:{id}]` with the offending plugin.)
636
+ - Rarely, a write can be lost if it races the worker's variable flush
637
+ (lock-free read-modify-write). For important values, re-check and retry on
638
+ the next cycle.
639
+
640
+ #### Per-plugin value storage (meta)
641
+
642
+ Besides the shared value, each variable entry records which plugin last wrote
643
+ what:
644
+
645
+ ```python
646
+ # state_dict["variables"] — after A wrote 1 and B wrote 2
647
+ "RBP_TEST": {
648
+ "value": 2, # shared slot = last written value
649
+ "type": "local",
650
+ "meta": {"writers": {"A.plugin": 1, "B.plugin": 2}}, # per-plugin record
651
+ }
652
+ ```
653
+
654
+ So the exact value is recoverable even when names collide:
655
+
656
+ - Plugin SDK: `ctx.get_variable("RBP_TEST", plugin_id="A.plugin")` → `1`
657
+ - Program step expression: `COUNT = get_var("A.plugin", "RBP_TEST") + 1` → `2`
658
+ - A plain name (`RBP_TEST += 1`) uses the shared slot (last written value) → `3`
659
+
660
+ #### Executor state
661
+
662
+ Subscribes to the `muscat/executor/state` publish (0.8 s interval) and exposes
663
+ per-robot-model state:
664
+
665
+ ```python
666
+ ctx.executor_state("C500920") # "running" | "paused" | "waiting" | ... | None
667
+ ctx.executor_state() # {"C500920": "paused", "amr": "running"}
668
+ ctx.is_paused("C500920") # whether that model is paused
669
+ ctx.is_stopped("C500920") # stop/error transition of a model seen active this run
670
+ ```
671
+
672
+ - States: `idle | running | paused | waiting | stopped | post_start | completed | error`
673
+ - A model with no running task is `None` / absent from the dict.
674
+ - The publish interval is 0.8 s, so expect up to ~1 s of latency.
675
+ - With `IS_DEV=true` there is no publish at all — always `None`.
676
+
677
+ To get a callback at the moment the state changes, without polling:
678
+
679
+ ```python
680
+ @ctx.on_executor_state_change
681
+ async def on_change(robot_model: str, state: str) -> None:
682
+ if state == "paused":
683
+ print(f"{robot_model} paused")
684
+ ```
685
+
686
+ - Not called for repeated publishes of the same state — fires exactly once
687
+ per per-model state change.
688
+ - An exception in the handler is only logged; state tracking keeps running.
689
+
690
+ #### Responding to pause / stop
691
+
692
+ ```python
693
+ @ctx.on_execute
694
+ async def handle(args: dict) -> None:
695
+ for i in range(100):
696
+ # stop → CancelledError (propagated to the executor as a stop),
697
+ # pause → waits here until resumed.
698
+ # Inside on_execute this automatically watches only the robot model
699
+ # executing this Step.
700
+ await ctx.check_stop()
701
+
702
+ ... # actual work
703
+ await asyncio.sleep(0.5)
704
+
705
+ await ctx.done()
706
+ ```
707
+
708
+ Scope priority for `check_stop` / `is_paused` / `is_stopped`:
709
+
710
+ 1. `robot_model` given — that model only
711
+ 2. Omitted inside `on_execute` — **only the model executing this Step**
712
+ (passed in by the executor)
713
+ 3. Omitted elsewhere (resident task, …) — all models
714
+
715
+ A handler doing long-running work must call `check_stop()` every loop
716
+ iteration so it reacts to pause/stop immediately. Without it, the plugin keeps
717
+ running even after the operator presses Stop.
718
+
719
+ Variables your plugin wants the Program page variable picker to offer must be
720
+ pre-declared in `manifest.json` under `"variables"` (see the manifest section
721
+ below).
722
+
723
+ ### C++
724
+
725
+ C++ plugins use `cpp/` and are built with CMake inside Docker against the
726
+ Rainbow Robotics C++ SDK (see _C++ SDK_ below).
727
+
728
+ ```cpp
729
+ #include <chrono>
730
+ #include <cstdlib>
731
+ #include <iostream>
732
+ #include <string>
733
+ #include <thread>
734
+
735
+ int main() {
736
+ const char* plugin_id = std::getenv("RB_PLUGIN_ID");
737
+ std::cout << "[__PLUGIN_NAME__] backend ready"
738
+ << (plugin_id ? std::string(" (id=") + plugin_id + ")" : "")
739
+ << std::endl;
740
+
741
+ while (true) {
742
+ std::this_thread::sleep_for(std::chrono::seconds(1));
743
+ }
744
+ return 0;
745
+ }
746
+ ```
747
+
748
+ #### C++ SDK (`rainbow-rb-sdk-dev`)
749
+
750
+ Both C++ runtimes (`cpp`, `ros2` + `rclcpp`) link the Rainbow Robotics C++ SDK
751
+ from the apt package `rainbow-rb-sdk-dev`. The `muscat-pk` dev containers already
752
+ have it, so `muscat-pk dev` / `build` need nothing extra. To build outside the
753
+ container (your own Ubuntu 24.04 machine, or the robot PC):
754
+
755
+ ```bash
756
+ curl -fsSL https://rainbowrobotics.github.io/rb-sdk/rb-sdk-apt-repo.gpg.key | sudo gpg --dearmor -o /usr/share/keyrings/rb-sdk-apt.gpg
757
+ echo 'deb [signed-by=/usr/share/keyrings/rb-sdk-apt.gpg] https://rainbowrobotics.github.io/rb-sdk stable main' | sudo tee /etc/apt/sources.list.d/rb-sdk.list
758
+ sudo apt update && sudo apt install rainbow-rb-sdk-dev
759
+ ```
760
+
761
+ - CMake: `find_package(rb_sdk REQUIRED)` + `target_link_libraries(<target> rb_sdk::rb_sdk)` (C++20). pkg-config: `rb_sdk`.
762
+ - Headers: `<rainbow/rb_sdk/manipulate/rb_manipulate_sdk.hpp>` (`RBManipulateSDK`),
763
+ `<rainbow/rb_sdk/program/plugin_program_context.hpp>` (`rb_plugin::PluginProgramContext`, for `program.panel` commands — the header's top comment has a full `main()` example).
764
+ - The binary is linked **dynamically**; `muscat-pk build` copies the matching `librb_sdk.so` into `dist/<arch>/lib/` (the robot has no SDK installed), so each plugin keeps the SDK version it was built with. The version is the one pinned in `CMakeLists.txt` (`find_package(rb_sdk <version> EXACT REQUIRED)`); change it with `muscat-pk sdk update <version>`.
765
+ - UI ↔ backend messaging works from C++ too: `rb_plugin::RBPluginCall` in `<rainbow/rb_sdk/plugin/rb_plugin.hpp>` (see the scaffolded `cpp/main.cpp`).
766
+
767
+ ### ROS2 (rclpy)
768
+
769
+ ROS2 Python plugins use `ros2_ws/` with `runtime.rosClient: "rclpy"`. relative topic names are placed under the plugin's ROS namespace — the plugin id
770
+ with "." → "/" and "-" → "_" (`com.example.my-plugin` → `/com/example/my_plugin`), in dev
771
+ and on the robot. Use an absolute name (`/cmd_vel`) for topics shared with other nodes.
772
+ `RBPluginCall` works in the node module too (UI messaging and `plugin.router`).
773
+
774
+ ```python
775
+ # ros2_ws/src/__PLUGIN_SLUG__/__PLUGIN_SLUG__/node.py
776
+ import rclpy
777
+ from rclpy.node import Node
778
+ from rainbow.rb_sdk import RBManipulateSDK
779
+
780
+ sdk = RBManipulateSDK()
781
+
782
+
783
+ class __PLUGIN_CLASS__Node(Node):
784
+ def __init__(self):
785
+ super().__init__("__PLUGIN_SLUG___node")
786
+ self.get_logger().info("__PLUGIN_NAME__ node started")
787
+ # use sdk alongside ROS2 publishers/subscribers/services as needed
788
+ ```
789
+
790
+
791
+ #### Talking to an external PC over ROS 2
792
+
793
+ ROS 2 discovery stays on the robot (`ROS_AUTOMATIC_DISCOVERY_RANGE=LOCALHOST`; `muscat-pk dev`
794
+ runs the same), so robots on one LAN don't mix topics. Allow specific PCs from the plugin UI —
795
+ Muscat keeps the list per plugin, starts your backend with `ROS_STATIC_PEERS` set to it, and
796
+ restarts the plugin when it changes. Your ROS code needs no changes; the PC needs no extra settings.
797
+
798
+ ```tsx
799
+ const rb = useRb();
800
+ const peers = await rb.rosPeers.get(); // ["192.168.0.50"]
801
+ await rb.rosPeers.set(["192.168.0.50", "192.168.0.51"]); // saves + restarts the plugin; rejects on a bad IP
802
+ ```
803
+
804
+ - Per plugin: plugin B keeps its allowed PCs when plugin A (allowing the same PC) is stopped or removed.
805
+ - It opens communication; it is not access control — a PC that knows the robot's IP can still
806
+ add the robot to its own `ROS_STATIC_PEERS`.
807
+ - In `muscat-pk dev` the list is kept in `.muscat-dev/ros-peers.json` (ROS 2 backends).
808
+
809
+ ### ROS2 (rclcpp)
810
+
811
+ ROS2 C++ plugins use `ros2_ws/` with `runtime.rosClient: "rclcpp"`. The node links
812
+ the Rainbow C++ SDK the same way as the `cpp` runtime (see _C++ SDK_ above).
813
+
814
+ ```cpp
815
+ #include "rclcpp/rclcpp.hpp"
816
+
817
+ class __PLUGIN_CLASS__Node : public rclcpp::Node {
818
+ public:
819
+ __PLUGIN_CLASS__Node() : Node("__PLUGIN_SLUG___node") {
820
+ RCLCPP_INFO(this->get_logger(), "__PLUGIN_NAME__ node started");
821
+ }
822
+ };
823
+ ```
824
+
825
+ ---
826
+
827
+ ## Video streaming (MediaMTX)
828
+
829
+ Muscat runs a MediaMTX media server. The backend publishes a stream over RTSP and
830
+ the UI plays it over WebRTC. `muscat-pk dev` runs the same server (or uses a local
831
+ Muscat's), so the addresses are identical in dev and on the robot.
832
+
833
+ | | Address |
834
+ | --- | --- |
835
+ | Publish (backend) | `rtsp://127.0.0.1:8554/<plugin_id>/<name>` |
836
+ | Play (UI) | `http://<host>:8889/<plugin_id>/<name>/` |
837
+
838
+ - Prefix the path with your plugin id — paths are shared with robot cameras and other plugins.
839
+ - The robot runtime has no ffmpeg, GStreamer or OpenCV; bring your own encoder (below).
840
+
841
+ **Backend (Python)** — use PyAV: add `av` and `numpy` to `requirements.txt`. Its wheels
842
+ include FFmpeg, so they ship in the ZIP like any other dependency.
843
+
844
+ ```python
845
+ import fractions, time
846
+ import av, numpy as np
847
+
848
+ out = av.open("rtsp://127.0.0.1:8554/__PLUGIN_ID__/cam", mode="w", format="rtsp",
849
+ options={"rtsp_transport": "tcp"})
850
+ stream = out.add_stream("libx264", rate=25, options={"preset": "ultrafast", "tune": "zerolatency"})
851
+ stream.width, stream.height, stream.pix_fmt = 640, 360, "yuv420p"
852
+
853
+ i = 0
854
+ while True:
855
+ img = np.zeros((360, 640, 3), dtype=np.uint8) # your RGB frame here
856
+ frame = av.VideoFrame.from_ndarray(img, format="rgb24")
857
+ frame.pts, frame.time_base = i, fractions.Fraction(1, 25)
858
+ for packet in stream.encode(frame):
859
+ out.mux(packet)
860
+ i += 1
861
+ time.sleep(1 / 25)
862
+ ```
863
+
864
+ Run the encoder loop in a thread (or `asyncio.to_thread`) so it doesn't block the
865
+ `RBPluginCall` handlers.
866
+
867
+ **UI** — embed MediaMTX's WebRTC player. `location.hostname` is `localhost` in the
868
+ preview and the robot's address on the robot:
869
+
870
+ ```tsx
871
+ const STREAM = "__PLUGIN_ID__/cam"; // the path the backend publishes
872
+
873
+ export default function Camera() {
874
+ return (
875
+ <iframe
876
+ src={`${location.protocol}//${location.hostname}:8889/${STREAM}/`}
877
+ allow="autoplay"
878
+ className="aspect-video w-full border-0"
879
+ />
880
+ );
881
+ }
882
+ ```
883
+
884
+ ---
885
+
886
+ ## manifest.json
887
+
888
+ ```jsonc
889
+ {
890
+ // unique plugin ID — reverse-domain format recommended
891
+ "id": "com.example.my-plugin",
892
+ "name": "My Plugin",
893
+ "version": "1.0.0",
894
+
895
+ // minimum Muscat version required to load this plugin
896
+ "minMuscatVersion": "1.0.0",
897
+
898
+ // plugin author (empty string is fine)
899
+ "author": "Your Name",
900
+
901
+ // displayed in the Muscat plugin store / info panel
902
+ // string or { en (required), ko, ja, zh, vi, th, es }
903
+ "description": { "en": "Plugin description", "ko": "플러그인 설명" },
904
+
905
+ // SVG string (single file) or PNG/JPG size map (all three sizes required)
906
+ "icons": "assets/icon.svg",
907
+ // "icons": { "16": "assets/icon-16.png", "48": "assets/icon-48.png", "128": "assets/icon-128.png" },
908
+
909
+ // targetModels and/or targetCategorys — at least one is required
910
+ // "all" = every target, or list specific values
911
+ "targetModels": "all",
912
+ // "targetModels": ["C500920", "C500930"],
913
+ "targetCategorys": "all",
914
+ // "targetCategorys": ["manipulate", "amr", "rby1", "vision"],
915
+
916
+ "contributes": [
917
+ // program.panel — defines a custom command for the Program page sequence
918
+ // builder; runs via PluginProgramContext when the program reaches it
919
+ {
920
+ "point": "program.panel",
921
+ "icon": "assets/icon.svg",
922
+ "label": { "ko": "내 플러그인", "en": "My Plugin" },
923
+ "ui": "program", // must be unique across all contributions
924
+ "commandInfo": {
925
+ "name": "My Plugin",
926
+ "icon": "assets/icon.svg",
927
+ "funcName": "rb_program_sdk.plugin_execute",
928
+ "args": {}
929
+ }
930
+ },
931
+ // toolbar — adds a button to the top toolbar (required)
932
+ {
933
+ "point": "toolbar",
934
+ "icon": "assets/icon.svg",
935
+ "label": { "ko": "내 플러그인", "en": "My Plugin" },
936
+ "ui": "content", // must be unique across all contributions
937
+ "opens": "popover" // popover | panel | page
938
+ },
939
+ // move.panel — adds a panel to the Move screen
940
+ {
941
+ "point": "move.panel",
942
+ "icon": "assets/icon.svg",
943
+ "label": { "ko": "내 플러그인", "en": "My Plugin" },
944
+ "ui": "move" // must be unique across all contributions
945
+ }
946
+ ],
947
+
948
+ // optional: variables this plugin exposes to pyfm programs — the Program
949
+ // page variable picker lists ONLY declared variables. name must start with
950
+ // RBP_; description is optional but without it the UI can't explain the
951
+ // variable, so write one.
952
+ "variables": [
953
+ { "name": "RBP_grip_force", "type": "number", "init": 0.5, "description": { "ko": "그리퍼 힘", "en": "Gripper force" } },
954
+ { "name": "RBP_target_pose", "type": "array", "init": [0, 0, 0] }
955
+ ],
956
+
957
+ // runtime — one of:
958
+ // { "type": "none" }
959
+ // { "type": "python", "entry": "main.py" }
960
+ // { "type": "cpp", "entry": "<executable name>" }
961
+ // { "type": "ros2", "rosClient": "rclpy" | "rclcpp", "package": "...", "launch": "..._node" }
962
+ "runtime": { "type": "python", "entry": "main.py" },
963
+
964
+ // optional: TCP ports opened by this plugin backend
965
+ // only number[] is allowed; ranges/objects are not accepted
966
+ // external plugin ports must be within 28600-28999
967
+ "ports": [28600]
968
+ }
969
+ ```
970
+
971
+ **Backend types:**
972
+
973
+ | type | source dir | build / run | entry |
974
+ |---|---|---|---|
975
+ | `python` | `py/` | pip + run in Docker | `main.py` |
976
+ | `cpp` | `cpp/` | CMake in Docker | built executable name |
977
+ | `ros2` (rclpy) | `ros2_ws/` | colcon (ament_python) in Docker | — |
978
+ | `ros2` (rclcpp) | `ros2_ws/` | colcon (ament_cmake) in Docker | — |
979
+
980
+ > Every back end runs in a Docker container matching the robot (Ubuntu 24.04 + Python 3.12), so dev behaves like production and compiled binaries are ABI-compatible. **Docker is required** for all backend types (only a frontend-only `none` plugin skips it). No local Python/toolchain setup is needed.
981
+
982
+ **Contribution points:**
983
+
984
+ | point | Where it appears | Backend | `commandInfo` |
985
+ |---|---|---|:---:|
986
+ | `toolbar` | Top toolbar button (must have at least one) | SDK / `rb.action` | — |
987
+ | `lnb` | Left navigation entry | SDK / `rb.action` | — |
988
+ | `move.panel` | Move screen panel | SDK / `rb.action` | — |
989
+ | `program.panel` | Custom command in the Program page sequence builder | `PluginProgramContext` | ✓ |
990
+
991
+ **`commandInfo` fields** (for `program.panel`):
992
+
993
+ | field | type | description |
994
+ |---|---|---|
995
+ | `name` | `string` | Display name of the command in the program tree |
996
+ | `icon` | `string` | Icon path |
997
+ | `iconClassName` | `string?` | Optional CSS class for the icon |
998
+ | `funcName` | `string?` | Backend function name to call |
999
+ | `args` | `Record<string, any>?` | Default arguments |
1000
+ | `disabled` | `boolean?` | Whether the node is disabled |
1001
+ | `allowChildren` | `string[]?` | Allowed child commands |
1002
+ | `allowParents` | `string[]?` | Allowed parent commands |
1003
+ | `notAllowChildren` | `string[]?` | Disallowed child commands |
1004
+ | `syncGroupId` | `boolean?` | Sync group ID across children |
1005
+ | `disableCopy` | `boolean?` | Prevent copy |
1006
+ | `disableDelete` | `boolean?` | Prevent delete |
1007
+ | `requireChildren` | `boolean?` | Whether a child command is required |
1008
+ | `requireParent` | `boolean?` | Whether a parent command is required |
1009
+ | `childrenCommand` | `string?` | Default command type for children |
1010
+
1011
+ **Validation rules:**
1012
+ - `id`, `name`, `version`, `minMuscatVersion` — required non-empty strings
1013
+ - `author` — required non-empty string
1014
+ - `description` — required (`string` or `{ en (required), ko?, ja?, zh?, vi?, th?, es? }`)
1015
+ - `icons` — SVG: single string path ending in `.svg`; PNG/JPG: object with `"16"`, `"48"`, `"128"` all present
1016
+ - `targetModels` — `"all"` or a non-empty array of robot model ID strings (at least one of `targetModels` / `targetCategorys` required)
1017
+ - `targetCategorys` — `"all"` or a non-empty array of `"manipulate" | "amr" | "rby1" | "vision"` (at least one of `targetModels` / `targetCategorys` required)
1018
+ - `contributes` — the plugin's screens; when present it must contain at least one `toolbar` entry. Leave it out (or `[]`) for a backend-only plugin: no UI is built, and Node.js is not needed to build it
1019
+ - `ui` — each contribution must have a unique value
1020
+ - `commandInfo` — required when `point` is `"program.panel"`
1021
+ - `ports` — optional `number[]`; every port must be within `28600-28999`
1022
+ and must not be a reserved robot/industrial/internal port
1023
+ - `variables` — optional array of `{ name, type, init, description? }`:
1024
+ - `name` — must start with `RBP_`, then letters/digits/underscore; unique per plugin
1025
+ - `type` — `"number" | "string" | "array"`
1026
+ - `init` — required initial value, must match `type`
1027
+ - `description` — optional `string` or i18n object (`en` required in object form)
1028
+
1029
+ `muscat-pk build` throws on any validation error. `muscat-pk dev` prints
1030
+ warnings — except `variables` errors (e.g. a name without the `RBP_` prefix),
1031
+ which are hard errors in `dev` / `dev:back` too.
1032
+
1033
+ **Backend port rules:**
1034
+ - Declare a port only if your backend opens a TCP server/listener.
1035
+ - **Every listening TCP port must be declared.** `muscat-pk dev` and
1036
+ `muscat-pk dev:back` watch the backend and stop it the moment it opens a port
1037
+ that is not in `ports`. A port outside `28600-28999` can't be declared, so it
1038
+ must not be opened either — picking e.g. `5500` is not a way around the rule.
1039
+ It would escape this check *and* the robot's install-time conflict check, and
1040
+ only collide once it's running on a robot.
1041
+ - Reserved ports include `502`, `2222`, `4840`, `5170-5179`, `8000-8999`,
1042
+ `10000`, `30004`, `34964`, `44818`, `50000-50005`, and `50051`.
1043
+ - The robot rejects installation (HTTP 409) if a declared port is reserved,
1044
+ out of range, or already claimed by another enabled plugin.
1045
+
1046
+ **Reaching your backend's ports during development:**
1047
+ - Declared ports are published to `127.0.0.1` on your machine, so
1048
+ `curl http://127.0.0.1:28600` works from the host on macOS, Windows, and Linux.
1049
+ - On native Linux the dev container uses host networking, so the ports are on
1050
+ the host already.
1051
+ - If a declared port is already taken on your machine, `muscat-pk dev` warns and
1052
+ skips publishing that one — the backend still listens inside the container.
1053
+
1054
+ ---
1055
+
1056
+ ## Localized documentation
1057
+
1058
+ You can ship per-language README files alongside the main `README.md`. Place them in the project root:
1059
+
1060
+ ```
1061
+ README.md # default / English fallback
1062
+ README.ko.md # Korean
1063
+ README.en.md # explicit English (optional)
1064
+ README.ja.md # Japanese
1065
+ ```
1066
+
1067
+ All `README*.md` files found at the project root are automatically copied to `dist/` on
1068
+ `muscat-pk build`. Muscat displays the file matching the user's locale,
1069
+ falling back to `README.md` if none matches.
1070
+
1071
+ ---
1072
+
1073
+ ## Installing Python dependencies
1074
+
1075
+ Add any package with an optional version pin to `requirements.txt`:
1076
+
1077
+ ```
1078
+ rainbow-rb-sdk
1079
+ rainbow-rb-flat-buffers
1080
+ numpy==1.26.4
1081
+ opencv-python
1082
+ ```
1083
+
1084
+ Run `muscat-pk dev` or `muscat-pk dev:back` — dependencies are installed automatically
1085
+ inside the dev container (`pip install -r requirements.txt`) on every startup. No
1086
+ local Python is required for this.
1087
+
1088
+ `muscat-pk dev:back` also best-effort creates/refreshes a local `.venv` in the
1089
+ background (via `uv` if available, else your system Python) with the same deps —
1090
+ purely so your editor (pylint, pyright, mypy, ...) can resolve imports and lint
1091
+ correctly. It's not used to run the backend; Docker is the actual execution
1092
+ environment, so this step is silently skipped if no local Python toolchain is
1093
+ available. Point your editor's Python interpreter at `.venv`.
1094
+
1095
+ `muscat-pk sdk update` also refreshes `.venv` in place if one already exists, so
1096
+ your editor picks up the new `rainbow-rb-sdk` version immediately.
1097
+
1098
+ > `muscat-pk build` only **pins** versions in `requirements.txt` — it does not run `pip install` (the robot installs them at load time).
1099
+
1100
+ ---
1101
+
1102
+ ## Switching backend type
1103
+
1104
+ ```bash
1105
+ muscat-pk type change # interactive
1106
+ muscat-pk type change --to cpp # → C++
1107
+ muscat-pk type change --to ros2 # → ROS2 (rclpy / Python)
1108
+ muscat-pk type change --to ros2-cpp # → ROS2 (rclcpp / C++)
1109
+ muscat-pk type change --to python # → Python
1110
+ ```
1111
+
1112
+ > Existing backend dirs (`ros2_ws/`, `cpp/`) are not deleted when switching away. Remove them manually if no longer needed.
1113
+
1114
+ > Switching between a Python and a C++ backend asks whether to replace this README with the matching one (the old file is kept as `README.md.bak`). Add `--readme` to answer yes without the prompt.
1115
+
1116
+ ---
1117
+
1118
+ ## AI coding assistants (MCP)
1119
+
1120
+ If you build your plugin with an AI assistant (Claude Code, Codex, …), give it
1121
+ grounded knowledge of the Muscat UI components, design tokens, and SDK instead of
1122
+ letting it guess. `muscat-pk mcp` is an MCP server exposing:
1123
+
1124
+ - `list_components` / `get_component_props` / `get_component_example` — every
1125
+ `@rainbow-robotics/plugin-ui/react` component with its real props and a working
1126
+ Storybook example.
1127
+ - `list_design_tokens` / `search_design_tokens` — the Tailwind classes to use
1128
+ (`bg-bg-*`, `text-fg-*`, `typo-*`, …) so your UI matches the host theme.
1129
+ - `search_rb_sdk` — how to use `useRb()`, `rb.*`, `useRbFormData`/`useRbSave`, and
1130
+ the backend runtimes.
1131
+ - `list_rb_sdk` / `get_rb_sdk_api` — the installed `rainbow-rb-sdk` (Python
1132
+ backend) classes and signatures; with `lang: "cpp"`, the C++ SDK headers
1133
+ (`rainbow-rb-sdk-dev`, read out of the `muscat-pk` cpp dev image — build it
1134
+ once with `muscat-pk dev:back`).
1135
+
1136
+ **Browse the components live** with `muscat-pk storybook`.
1137
+
1138
+ `muscat-pk new` also writes `AGENTS.md` (read by Codex, Cursor, Gemini CLI, Copilot)
1139
+ and a `CLAUDE.md` that imports it: the UI rules (plugin-ui components instead of
1140
+ native `<select>`/`<input>`/`<button>`, design tokens for colors) for assistants that
1141
+ do not call the MCP tools on their own. Add your own project notes there.
1142
+
1143
+ ### Register the server
1144
+
1145
+ **Claude Code** — a `.mcp.json` is already scaffolded at your plugin root, so
1146
+ it works out of the box:
1147
+
1148
+ ```json
1149
+ {
1150
+ "mcpServers": {
1151
+ "muscat": {
1152
+ "command": "muscat-pk",
1153
+ "args": ["mcp"]
1154
+ }
1155
+ }
1156
+ }
1157
+ ```
1158
+
1159
+ **Codex** — register once with:
1160
+
1161
+ ```bash
1162
+ muscat-pk mcp --install codex
1163
+ ```
1164
+
1165
+ This appends the server to `~/.codex/config.toml` (idempotent — safe to run
1166
+ again). Equivalent manual config:
1167
+
1168
+ ```toml
1169
+ [mcp_servers.muscat]
1170
+ command = "muscat-pk"
1171
+ args = ["mcp"]
1172
+ ```
1173
+
1174
+ Any other MCP client works too — it's a standard stdio MCP server started with
1175
+ `muscat-pk mcp`. If `muscat-pk` isn't on the client's PATH, use its absolute path
1176
+ (`which muscat-pk`) as `command`.
1177
+
1178
+ ---
1179
+
1180
+ ## Shipping
1181
+
1182
+ ```bash
1183
+ muscat-pk build # build + ZIP
1184
+ muscat-pk build --no-zip # build only, no ZIP
1185
+ ```
1186
+
1187
+ > Building the other arch (e.g. arm64 on an amd64 PC) runs in an emulated container for
1188
+ > C++ backends. Docker Desktop has it built in; on Linux enable it once with
1189
+ > `docker run --privileged --rm tonistiigi/binfmt --install all`, or pass `--arch` for one arch.
1190
+
1191
+ - Builds `ui/content.tsx` → `dist/content.js` (minified)
1192
+ - Pins `requirements.txt` to latest versions (stable-only on `main` branch)
1193
+ - Copies `assets/`, `manifest.json`, `README*.md`, `py/`, and `requirements.txt` into `dist/`
1194
+ - ROS2: runs `colcon build` inside the Jazzy container; includes `ros2_ws/install/`
1195
+ - Output: `__PLUGIN_DIR__-{version}.zip`
1196
+
1197
+ ZIP layout:
1198
+
1199
+ ```
1200
+ __PLUGIN_DIR__-1.0.0.zip
1201
+ ├─ dist/
1202
+ │ ├─ content.js # built UI
1203
+ │ ├─ manifest.json
1204
+ │ ├─ assets/
1205
+ │ ├─ README.md
1206
+ │ ├─ README.ko.md # if present
1207
+ │ ├─ main.py # from py/
1208
+ │ └─ requirements.txt # pinned
1209
+ └─ ros2_ws/install/ # ROS2 only
1210
+ ```
1211
+
1212
+ ---
1213
+
1214
+ ## All commands
1215
+
1216
+ ```bash
1217
+ muscat-pk dev # UI + backend
1218
+ muscat-pk dev:ui # UI preview only (preview-host)
1219
+ muscat-pk dev:back # backend only (Python: watchfiles / ROS2: Docker)
1220
+ muscat-pk build # build → dist/ + distributable ZIP (--no-zip to skip)
1221
+ muscat-pk storybook # browse the rb-components UI library in a browser
1222
+ muscat-pk mcp # MCP server for AI assistants (components/tokens/SDK docs)
1223
+ ```
1224
+
1225
+ ```bash
1226
+ muscat-pk type change # switch backend type
1227
+ muscat-pk sdk list # show current Python SDK version
1228
+ muscat-pk sdk update # update Python SDK to latest
1229
+ muscat-pk update # update muscat-plugin-kit itself
1230
+ muscat-pk uninstall # remove muscat-pk itself
1231
+ ```