@rainbow-robotics/muscat-plugin-kit 1.3.8-dev.2 → 1.10.1-dev.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (213) 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 -153
  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 -0
  12. package/cli/src/build.mjs +447 -437
  13. package/cli/src/check-package.mjs +13 -13
  14. package/cli/src/completion.mjs +166 -166
  15. package/cli/src/cpp.mjs +78 -79
  16. package/cli/src/cppIde.mjs +75 -75
  17. package/cli/src/dev.mjs +165 -164
  18. package/cli/src/devBack.mjs +727 -670
  19. package/cli/src/devUi.mjs +243 -238
  20. package/cli/src/esbuild.mjs +56 -56
  21. package/cli/src/host_agent.py +223 -222
  22. package/cli/src/manifest.mjs +287 -283
  23. package/cli/src/mcp.mjs +1071 -1016
  24. package/cli/src/muscatLocal.mjs +209 -198
  25. package/cli/src/new.mjs +343 -338
  26. package/cli/src/pluginDevServer.mjs +111 -112
  27. package/cli/src/ports.mjs +193 -193
  28. package/cli/src/python.mjs +112 -113
  29. package/cli/src/ros2.mjs +109 -110
  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 -437
  36. package/cli/src/zenohDev.mjs +81 -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-DeirXVfl.js → Color-YHDXOIA2-D6JqZzDJ.js} +1 -1
  103. package/cli/storybook-dist/assets/{Docs-BLxyi8cP.js → Docs-6gD2kUo1.js} +2 -2
  104. package/cli/storybook-dist/assets/{Docs-NrBqEYvM.js → Docs-B9wd8Ok7.js} +3 -3
  105. package/cli/storybook-dist/assets/{Docs-wzFMK-XA.js → Docs-CMjovnSY.js} +4 -4
  106. package/cli/storybook-dist/assets/{Docs-Buyf7Djy.js → Docs-CgFbWWXM.js} +2 -2
  107. package/cli/storybook-dist/assets/{Docs-CnfIOwf8.js → Docs-DBQup8Bu.js} +3 -3
  108. package/cli/storybook-dist/assets/{Docs-BxmwdaYa.js → Docs-ESb7JAHg.js} +3 -3
  109. package/cli/storybook-dist/assets/{DocsRenderer-CFRXHY34-CWc43rTP.js → DocsRenderer-CFRXHY34-BOUI1AHi.js} +1 -1
  110. package/cli/storybook-dist/assets/{NxAvatar.stories-DfL0kqmN.js → NxAvatar.stories-DsYFMUh3.js} +1 -1
  111. package/cli/storybook-dist/assets/{NxButton.stories-WxSc_gsR.js → NxButton.stories-CHfNcqsc.js} +8 -8
  112. package/cli/storybook-dist/assets/{NxNumberInput.stories-C1D3oUiY.js → NxNumberInput.stories-BFw_4vFE.js} +1 -1
  113. package/cli/storybook-dist/assets/{NxPagination.stories-C1Qa88rn.js → NxPagination.stories-DVfxyw4M.js} +1 -1
  114. package/cli/storybook-dist/assets/{NxPointerHoldButton.stories-CmteJX3t.js → NxPointerHoldButton.stories-BzyxUWoZ.js} +1 -1
  115. package/cli/storybook-dist/assets/{NxRangeInput.stories-B7E-mhyI.js → NxRangeInput.stories-DmRdzBc1.js} +6 -6
  116. package/cli/storybook-dist/assets/{NxSelectBox.stories-De67vMdU.js → NxSelectBox.stories-B1KkNUOf.js} +1 -1
  117. package/cli/storybook-dist/assets/{NxSlideRange.stories-DvCV1MGk.js → NxSlideRange.stories-CyOdvfQ6.js} +1 -1
  118. package/cli/storybook-dist/assets/{NxSwitch.stories-DuHDZ9-v.js → NxSwitch.stories-DrDBw0qp.js} +1 -1
  119. package/cli/storybook-dist/assets/{NxTextArea.stories-BT1o0ngg.js → NxTextArea.stories-DTB2aqNS.js} +1 -1
  120. package/cli/storybook-dist/assets/{NxTextInput.stories-CSLBT83Q.js → NxTextInput.stories-DU0rucmM.js} +1 -1
  121. package/cli/storybook-dist/assets/{RBAccordion.stories-QavkYgV0.js → RBAccordion.stories-DTfOtXtp.js} +3 -3
  122. package/cli/storybook-dist/assets/{RBAccordionGroup.stories-C0RPGnO7.js → RBAccordionGroup.stories-9y9BjrUe.js} +10 -10
  123. package/cli/storybook-dist/assets/{RBAlert.stories-BUCAT0Sd.js → RBAlert.stories-DLTAv9fT.js} +3 -3
  124. package/cli/storybook-dist/assets/{RBBadge.stories-CnBXTQ4J.js → RBBadge.stories-BNMACewA.js} +1 -1
  125. package/cli/storybook-dist/assets/{RBBattery.stories-5FbsZcdy.js → RBBattery.stories-B3kXMAt5.js} +1 -1
  126. package/cli/storybook-dist/assets/{RBCard.stories-FwTKjtCi.js → RBCard.stories-Y-s8R2n7.js} +39 -39
  127. package/cli/storybook-dist/assets/{RBCheckBox.stories-C0y5psMI.js → RBCheckBox.stories-DBuT9tL4.js} +1 -1
  128. package/cli/storybook-dist/assets/{RBCheckIconButton.stories-sJjAZBVh.js → RBCheckIconButton.stories-CWu1TL56.js} +1 -1
  129. package/cli/storybook-dist/assets/{RBCheckLabelButton.stories-COJqEkDr.js → RBCheckLabelButton.stories-sCu2MGCX.js} +17 -17
  130. package/cli/storybook-dist/assets/{RBChipTab.stories-BnXj-Woa.js → RBChipTab.stories-BX9qLJkR.js} +5 -5
  131. package/cli/storybook-dist/assets/{RBChipsBoxTab.stories-DE1yl3rz.js → RBChipsBoxTab.stories-35u1aJ-P.js} +1 -1
  132. package/cli/storybook-dist/assets/{RBColorIcons.stories-CYASERLQ.js → RBColorIcons.stories-DAOZ5Ies.js} +1 -1
  133. package/cli/storybook-dist/assets/RBCommand.stories-BSqXDkKw.js +73 -0
  134. package/cli/storybook-dist/assets/{RBConfirm.stories-XLLTJZRf.js → RBConfirm.stories-iMXcUh7Z.js} +3 -3
  135. package/cli/storybook-dist/assets/{RBDateInput.stories-DpVylrQl.js → RBDateInput.stories-hK5rXECf.js} +1 -1
  136. package/cli/storybook-dist/assets/RBDialog.stories-BAktz-ku.js +38 -0
  137. package/cli/storybook-dist/assets/{RBDivider.stories-NOTo-jJG.js → RBDivider.stories-BAK-iZ1Z.js} +1 -1
  138. package/cli/storybook-dist/assets/{RBDrawer.stories-CUZaQyw8.js → RBDrawer.stories-B2nqf2Nh.js} +12 -12
  139. package/cli/storybook-dist/assets/RBDropdownMenu.stories-DbW2fWAj.js +86 -0
  140. package/cli/storybook-dist/assets/{RBEmptyView.stories-BIRNPjwt.js → RBEmptyView.stories-nCenrgBh.js} +1 -1
  141. package/cli/storybook-dist/assets/{RBIconButton.stories-CJWeIQTt.js → RBIconButton.stories-DRH7O3sp.js} +1 -1
  142. package/cli/storybook-dist/assets/{RBIcons.stories-Cu0txCUp.js → RBIcons.stories-D3PyJXG8.js} +1 -1
  143. package/cli/storybook-dist/assets/{RBInlineLabelButton.stories-DnGhYrxa.js → RBInlineLabelButton.stories-modQbt10.js} +1 -1
  144. package/cli/storybook-dist/assets/{RBJoyStick.stories-BlfvKcGb.js → RBJoyStick.stories-BY14qVgR.js} +1 -1
  145. package/cli/storybook-dist/assets/{RBLabelButton.stories-CFwmlhAE.js → RBLabelButton.stories-DzF7vGwm.js} +1 -1
  146. package/cli/storybook-dist/assets/{RBLight.stories-wMrFLJ7u.js → RBLight.stories-lF4FRmVh.js} +1 -1
  147. package/cli/storybook-dist/assets/{RBMenubar.stories-Bu5wtV-T.js → RBMenubar.stories-XhrI7bsV.js} +1 -1
  148. package/cli/storybook-dist/assets/{RBMultiRangeInput.stories-Db1d940z.js → RBMultiRangeInput.stories-DprllQDl.js} +1 -1
  149. package/cli/storybook-dist/assets/{RBPagination.stories-NCpjg-2W.js → RBPagination.stories-2v2gBJfC.js} +1 -1
  150. package/cli/storybook-dist/assets/{RBPopover.stories-CqsM8byU.js → RBPopover.stories-C8iLIZpv.js} +1 -1
  151. package/cli/storybook-dist/assets/{RBPrompt.stories-TMQEaHk-.js → RBPrompt.stories-Th_HrWHI.js} +1 -1
  152. package/cli/storybook-dist/assets/{RBRadioBox.stories-pnsBvtzU.js → RBRadioBox.stories-CoHuLaN3.js} +1 -1
  153. package/cli/storybook-dist/assets/{RBRadioButton.stories-BMDBfq82.js → RBRadioButton.stories-DULxE_ff.js} +1 -1
  154. package/cli/storybook-dist/assets/{RBRangeDateInput.stories-ljAsdz0Q.js → RBRangeDateInput.stories-D8A9KBSS.js} +1 -1
  155. package/cli/storybook-dist/assets/{RBResizable.stories-B5YDW687.js → RBResizable.stories-C41QfrQ3.js} +14 -14
  156. package/cli/storybook-dist/assets/{RBSelectBox.stories-DPtyIwEr.js → RBSelectBox.stories-C1gNmwzw.js} +1 -1
  157. package/cli/storybook-dist/assets/RBSheet.stories-_6VH-RUm.js +39 -0
  158. package/cli/storybook-dist/assets/{RBSideNumberInput.stories-DLsS3mzL.js → RBSideNumberInput.stories-9LlgQ8nh.js} +3 -3
  159. package/cli/storybook-dist/assets/{RBSlider.stories-BjTYZ3ri.js → RBSlider.stories-oUgo5K9d.js} +3 -3
  160. package/cli/storybook-dist/assets/{RBSpinner.stories-BWBvOGUY.js → RBSpinner.stories-DDpohbY2.js} +1 -1
  161. package/cli/storybook-dist/assets/{RBSwitch.stories-tpthe98k.js → RBSwitch.stories-gW-GN7QO.js} +7 -7
  162. package/cli/storybook-dist/assets/{RBTab.stories-BijmaLIo.js → RBTab.stories-CN-PV3HH.js} +1 -1
  163. package/cli/storybook-dist/assets/{RBTable.stories-Dojsz28q.js → RBTable.stories-BAZLpxtM.js} +29 -29
  164. package/cli/storybook-dist/assets/{RBTableRow.stories-CQajMiEa.js → RBTableRow.stories-CwjVrszB.js} +1 -1
  165. package/cli/storybook-dist/assets/{RBTabs.stories-BslRXoCw.js → RBTabs.stories-JuEo2sUL.js} +7 -7
  166. package/cli/storybook-dist/assets/{RBTextArea.stories-N44Q45Xp.js → RBTextArea.stories-D0_Pv4qZ.js} +3 -3
  167. package/cli/storybook-dist/assets/{RBTextField.stories-scpiy0Kx.js → RBTextField.stories-CCsOvZqV.js} +15 -15
  168. package/cli/storybook-dist/assets/{RBTextInput.stories-D_suHJaA.js → RBTextInput.stories-Cccn3VNm.js} +3 -3
  169. package/cli/storybook-dist/assets/{RBToast.stories-B73JQcKo.js → RBToast.stories-I5617t0K.js} +1 -1
  170. package/cli/storybook-dist/assets/{RBToggle.stories-CmujM833.js → RBToggle.stories-DsgKjGsJ.js} +4 -4
  171. package/cli/storybook-dist/assets/{RBToggleGroup.stories-DhCAfiKu.js → RBToggleGroup.stories-BZEzSDuz.js} +5 -5
  172. package/cli/storybook-dist/assets/{RBTooltip.stories-BixEjKZy.js → RBTooltip.stories-4oSyAWsA.js} +1 -1
  173. package/cli/storybook-dist/assets/{SideNumberInput-B7fbx2jk.js → SideNumberInput-DSKbd0bG.js} +743 -743
  174. package/cli/storybook-dist/assets/{entry-preview-BeR5Td7C.js → entry-preview-B35XREGs.js} +1 -1
  175. package/cli/storybook-dist/assets/{iframe-D3pKf_6w.js → iframe-BB797lmL.js} +2 -2
  176. package/cli/storybook-dist/assets/{index-gXlU0WyC.js → index-BU2567EP.js} +3 -3
  177. package/cli/storybook-dist/assets/{preview-GwaHbm9r.js → preview-C7DaSWyc.js} +2 -2
  178. package/cli/storybook-dist/assets/preview-CbuRHit4.js +3 -0
  179. package/cli/storybook-dist/assets/preview-CigNHo-I.css +1 -0
  180. package/cli/storybook-dist/assets/react-resizable-panels-Dp9zi-o3.css +1 -0
  181. package/cli/storybook-dist/assets/{react-resizable-panels.browser-D49JKwKo.js → react-resizable-panels.browser-ChIvlwo7.js} +8 -8
  182. package/cli/storybook-dist/iframe.html +2 -2
  183. package/cli/storybook-dist/index.html +1 -1
  184. package/cli/storybook-dist/project.json +1 -1
  185. package/cli/templates/common/.mcp.json +8 -8
  186. package/cli/templates/common/README.md +1231 -1226
  187. package/cli/templates/common/assets/icon.svg +45 -45
  188. package/cli/templates/common/manifest.json +26 -26
  189. package/cli/templates/common/package.json +22 -22
  190. package/cli/templates/common/py/main.py +125 -125
  191. package/cli/templates/common/requirements.txt +4 -4
  192. package/cli/templates/common/tsconfig.json +13 -13
  193. package/cli/templates/common/ui/content.tsx +216 -216
  194. package/cli/templates/common/ui/style.css +6 -6
  195. package/cli/templates/common/vite.config.ts +10 -10
  196. package/cli/templates/cpp/cpp/main.cpp +73 -73
  197. package/cli/templates/readme-cpp/README.md +6 -1
  198. package/cli/templates/ros2/ros2_ws/src/__PLUGIN_SLUG__/__PLUGIN_SLUG__/node.py +25 -25
  199. package/cli/templates/ros2/ros2_ws/src/__PLUGIN_SLUG__/package.xml +10 -10
  200. package/cli/templates/ros2/ros2_ws/src/__PLUGIN_SLUG__/setup.py +18 -18
  201. package/cli/templates/ros2-cpp/ros2_ws/src/__PLUGIN_SLUG__/package.xml +11 -11
  202. package/cli/templates/ros2-cpp/ros2_ws/src/__PLUGIN_SLUG__/src/node.cpp +23 -23
  203. package/cli/vite.d.mts +25 -25
  204. package/cli/vite.mjs +292 -292
  205. package/package.json +4 -4
  206. package/cli/src/templates-embedded.mjs +0 -4
  207. package/cli/storybook-dist/assets/RBCommand.stories-BYkWjdMG.js +0 -73
  208. package/cli/storybook-dist/assets/RBDialog.stories-DcPRAan8.js +0 -38
  209. package/cli/storybook-dist/assets/RBDropdownMenu.stories-5UC0ZA3H.js +0 -86
  210. package/cli/storybook-dist/assets/RBSheet.stories-DMTeF_hx.js +0 -39
  211. package/cli/storybook-dist/assets/preview-BYhMD55w.css +0 -1
  212. package/cli/storybook-dist/assets/preview-D-VpQB0_.js +0 -3
  213. package/cli/storybook-dist/assets/react-resizable-panels-DJMuB2U0.css +0 -1
@@ -1,670 +1,727 @@
1
- // muscat-pk dev:back — run the plugin back end during development.
2
- // Every backend runs in a warm Docker container that matches the robot
3
- // (Ubuntu 24.04), so dev behaves like production.
4
- // python : Python 3.12 container, pip install, run main.py with watchfiles
5
- // hot-reload. Shares the local Muscat container's netns (localhost:10000).
6
- // cpp : Ubuntu + cmake container, build cpp/, run the binary, rebuild +
7
- // restart on source changes.
8
- // ros2 : Jazzy + Python 3.12 container, colcon build, run the node, restart
9
- // on source changes (rclcpp rebuilds on change).
10
- import { spawn, spawnSync } from "node:child_process";
11
- import { existsSync, mkdirSync, readFileSync, watch, writeFileSync } from "node:fs";
12
- import { isIP } from "node:net";
13
- import { dirname, resolve } from "node:path";
14
- import { dockerUserFlags, ensureDocker, ensureIdeVenv, findPluginRoot, log, promptRobotConnection, realtimeFlags } from "./util.mjs";
15
- import { ensureRemoteZenohEnv, isRemoteRobot, usesHostNetwork } from "./zenohDev.mjs";
16
- import { localMuscatContainer } from "./muscatLocal.mjs";
17
- import { PLUGIN_ID_RE, assertManifestPorts, assertManifestVariables, manifestRuntime, readManifest, rosNamespace, validateManifest } from "./manifest.mjs";
18
- import {
19
- formatPortRanges,
20
- portEnvFlags,
21
- publishPortFlags,
22
- startPortContractMonitor,
23
- } from "./ports.mjs";
24
- import {
25
- PYTHON_DEV_IMAGE,
26
- assertDockerReady as pyAssertDockerReady,
27
- ensureImage as pyEnsureImage,
28
- } from "./python.mjs";
29
- import {
30
- ROS2_DEV_IMAGE,
31
- assertDockerReady,
32
- ensureImage,
33
- ros2PackageName,
34
- } from "./ros2.mjs";
35
- import {
36
- CPP_DEV_IMAGE,
37
- assertDockerReady as cppAssertDockerReady,
38
- ensureImage as cppEnsureImage,
39
- } from "./cpp.mjs";
40
-
41
- const MANAGED_DEV_ENV = "MUSCAT_PK_MANAGED_DEV";
42
-
43
- function exitAfterCleanup(code = 0) {
44
- if (process.env[MANAGED_DEV_ENV] !== "1") process.exit(code ?? 0);
45
- }
46
-
47
- export function stopDevBackContainers() {
48
- for (const container of [
49
- `${PYTHON_DEV_IMAGE}-run`,
50
- `${ROS2_DEV_IMAGE}-run`,
51
- `${CPP_DEV_IMAGE}-run`,
52
- ]) {
53
- spawnSync("docker", ["rm", "-f", container], { stdio: "ignore" });
54
- }
55
- }
56
-
57
- // Where the backend's 127.0.0.1 points. A local Muscat container (muscat-pk-muscat, muscat-dev):
58
- // join its netns so the gateway and MediaMTX resolve like on the robot. Otherwise its own
59
- // netns, gateway via the host.
60
- // IS_DEV Muscat (muscat-dev): common is a zenoh-router client and opens no 7447 listener,
61
- // so the plugin must join the router too or `execute` queries never reach it.
62
- function muscatZenohEnv(container) {
63
- const r = spawnSync("docker", ["inspect", "-f", "{{range .Config.Env}}{{println .}}{{end}}", container], { encoding: "utf8" });
64
- return /^IS_DEV=(true|1)$/im.test(r.stdout || "")
65
- ? ["-e", "ZENOH_PEER_CONNECT_ENDPOINTS=tcp/zenoh-router:7447"]
66
- : [];
67
- }
68
-
69
- function backendNetwork(portRanges) {
70
- const muscat = process.env.MUSCAT_PK_MUSCAT_CONTAINER;
71
- if (muscat) return { run: ["--network", `container:${muscat}`], env: muscatZenohEnv(muscat), shared: true };
72
- if (usesHostNetwork()) return { run: ["--network", "host"], env: [], shared: true };
73
- return {
74
- // host.docker.internal is automatic on Docker Desktop; this makes it resolve on Linux too.
75
- run: [...publishPortFlags(portRanges), "--add-host", "host.docker.internal:host-gateway"],
76
- env: ["-e", "RB_PLUGIN_GATEWAY_URL=http://host.docker.internal:10000"],
77
- shared: false,
78
- };
79
- }
80
-
81
- // rb.rosPeers in dev: answer muscat/plugin/<id>/ros_peers/{get,set} on the gateway the way
82
- // Muscat (common) does on the robot — keep the list in .muscat-dev/ and restart the node
83
- // with ROS_STATIC_PEERS.
84
- const ROS_PEERS_FILE = ".muscat-dev/ros-peers.json";
85
-
86
- function readRosPeers(root) {
87
- try {
88
- return JSON.parse(readFileSync(resolve(root, ROS_PEERS_FILE), "utf8"));
89
- } catch {
90
- return [];
91
- }
92
- }
93
-
94
- async function serveRosPeers(root, pluginId, restart) {
95
- if (!pluginId) return;
96
- const { RbWs } = await import("@rainbow-robotics/plugin-ui/ws");
97
- const ws = await Promise.race([
98
- RbWs.connect("http://127.0.0.1:10000", pluginId).catch(() => null),
99
- new Promise((r) => setTimeout(() => r(null), 5000)),
100
- ]);
101
- if (!ws) return log.warn("rb.rosPeers unavailable in dev: plugin gateway not reachable on :10000");
102
- const base = `/muscat/plugin/${pluginId}/ros_peers`;
103
- await ws.serve(`${base}/get`, () => ({ peers: readRosPeers(root) }));
104
- await ws.serve(`${base}/set`, (payload) => {
105
- const peers = payload?.peers;
106
- if (!Array.isArray(peers) || !peers.every((p) => typeof p === "string" && isIP(p))) {
107
- return { ok: false, error: "peers must be a list of IP addresses" };
108
- }
109
- const file = resolve(root, ROS_PEERS_FILE);
110
- mkdirSync(dirname(file), { recursive: true });
111
- writeFileSync(file, JSON.stringify(peers) + "\n");
112
- log.step(`ROS_STATIC_PEERS → ${peers.join(";") || "(none)"} — restarting node`);
113
- restart();
114
- return { ok: true, peers };
115
- });
116
- }
117
-
118
- // ── IDE tooling venv (best-effort, non-blocking) ────────────────────────────
119
- // `muscat-pk new` already creates this up front (see new.mjs); this just
120
- // refreshes it on every `dev`/`dev:back` in case requirements.txt changed.
121
- // Docker remains the actual execution environment — this never affects it.
122
- // Runs in the background so it never delays `dev:back` starting, and any
123
- // failure here (no local Python toolchain, offline, ...) is silent.
124
- function syncIdeVenv(root) {
125
- setImmediate(() => {
126
- try {
127
- if (ensureIdeVenv(root)) {
128
- log.info("IDE venv ready → .venv (point your editor's Python interpreter there for pylint/pyright)");
129
- }
130
- } catch {
131
- /* best-effort only */
132
- }
133
- });
134
- }
135
-
136
- // ── python ────────────────────────────────────────────────────────────────────
137
- // Runs in a warm Ubuntu 24.04 + Python 3.12 container (matches the robot).
138
- // Under `muscat-pk dev`, it shares the local Muscat container's netns, so the SDK
139
- // reaches the plugin gateway at the default localhost:10000.
140
- async function runPython(root, portRanges) {
141
- // Independent of Docker readiness — must still happen if Docker isn't
142
- // installed/running, since that's exactly when local IDE tooling matters most.
143
- syncIdeVenv(root);
144
- pyAssertDockerReady();
145
- await pyEnsureImage();
146
-
147
- const manifest = readManifest(root);
148
- const pluginId = manifest.id ?? "";
149
- const container = `${PYTHON_DEV_IMAGE}-run`;
150
- const robotIp = process.env.MUSCAT_ROBOT_HOST ?? "";
151
- const robotToken = process.env.MUSCAT_ROBOT_TOKEN ?? "";
152
- const zenohAdvertiseHost = process.env.MUSCAT_ZENOH_ADVERTISE_HOST ?? "";
153
- const zenohPort = process.env.MUSCAT_ZENOH_PORT ?? "";
154
- // See host_agent.py: a Docker Desktop container can't join the robot's
155
- // Zenoh mesh, so it speaks a byte protocol to the host-side proxy instead.
156
- const proxyEndpoint = process.env.MUSCAT_PROXY_ENDPOINT ?? "";
157
- // Native Linux (not WSL, not Docker Desktop) has real host networking: the
158
- // container shares the host's network stack, so it reaches the gateway at
159
- // localhost:10000 and the robot's Zenoh router / reverse-dial path directly
160
- // — no netns sharing, port publishing, host.docker.internal, or byte-proxy.
161
- const net = backendNetwork(portRanges);
162
- // Only the container's OWN reverse-dial path needs to publish a listen port.
163
- // With a shared netns the port is already reachable; with the byte-proxy
164
- // the container just connects out — neither needs -p.
165
- const standaloneZenohPortFlags =
166
- !net.shared && !proxyEndpoint && zenohPort ? ["-p", `${zenohPort}:${zenohPort}`] : [];
167
- const envFlags = [
168
- // docker exec below has no -t (no pseudo-TTY), so Python defaults to
169
- // block-buffered stdout — print() output wouldn't show up until the
170
- // buffer fills or the process exits. Force unbuffered.
171
- "-e", "PYTHONUNBUFFERED=1",
172
- ...net.env,
173
- ...(pluginId ? ["-e", `RB_PLUGIN_ID=${pluginId}`] : []),
174
- ...(robotIp ? ["-e", `MUSCAT_ROBOT_HOST=${robotIp}`] : []),
175
- ...(robotToken ? ["-e", `MUSCAT_ROBOT_TOKEN=${robotToken}`] : []),
176
- // Byte-proxy mode: the container has no Zenoh session of its own — the
177
- // entry script's shim speaks the raw query/put/subscribe protocol to the
178
- // host_agent process at this address instead.
179
- ...(proxyEndpoint ? ["-e", `MUSCAT_PROXY_ENDPOINT=${proxyEndpoint}`] : []),
180
- // Fallback (no proxy): the container reverse-dials the robot itself.
181
- ...(!proxyEndpoint && zenohAdvertiseHost ? ["-e", `MUSCAT_ZENOH_ADVERTISE_HOST=${zenohAdvertiseHost}`] : []),
182
- ...(!proxyEndpoint && zenohPort ? ["-e", `MUSCAT_ZENOH_PORT=${zenohPort}`] : []),
183
- ...portEnvFlags(portRanges),
184
- ];
185
- const docker = (args, opts = {}) =>
186
- spawnSync("docker", args, { cwd: root, stdio: "inherit", ...opts });
187
-
188
- // start a warm, long-lived container with the plugin mounted.
189
- // 호스트 uid 로 돌린다(dockerUserFlags) — root 로 쓴 build/·install/ 은 사용자가 지우지 못한다.
190
- docker(["rm", "-f", container], { stdio: "ignore" });
191
- log.step("starting Python dev container…");
192
- const up = docker(
193
- ["run", "-d", "--name", container, ...net.run, ...standaloneZenohPortFlags, ...realtimeFlags(), ...dockerUserFlags(),
194
- "-e", "PIP_ROOT_USER_ACTION=ignore",
195
- "-v", `${root}:/plugin`, "-w", "/plugin", PYTHON_DEV_IMAGE, "sleep", "infinity"],
196
- { stdio: "inherit" },
197
- );
198
- if (up.status !== 0) throw new Error("failed to start dev container.");
199
- const stopPortMonitor = startPortContractMonitor({ container, ranges: portRanges, log, allow: [zenohPort] });
200
-
201
- log.step("pip install requirements + watchfiles (in container)…");
202
- docker(["exec", container, "bash", "-lc",
203
- "pip install --break-system-packages --ignore-installed -q watchfiles" +
204
- " && { [ -f requirements.txt ] && pip install --break-system-packages --ignore-installed -q -r requirements.txt || true; }"]);
205
-
206
- docker(["exec", container, "bash", "-lc", `cat > /tmp/muscat-pk-python-dev-entry.py <<'PY'
207
- import os
208
-
209
- try:
210
- proxy_endpoint = os.getenv("MUSCAT_PROXY_ENDPOINT")
211
- if proxy_endpoint:
212
- # Byte-proxy mode: this container has no Zenoh session of its own.
213
- # ZenohClient's C++ core is replaced with a shim that speaks a raw
214
- # query/put/subscribe byte protocol to host_agent.py instead.
215
- import json
216
- import socket
217
- import struct
218
- import threading
219
-
220
- _proxy_host, _proxy_port_s = proxy_endpoint.rsplit(":", 1)
221
- _proxy_port = int(_proxy_port_s)
222
-
223
- def _send_frame(conn, lock, obj):
224
- body = json.dumps(obj).encode()
225
- with lock:
226
- conn.sendall(struct.pack(">I", len(body)) + body)
227
-
228
- def _recv_frame(conn):
229
- hdr = b""
230
- while len(hdr) < 4:
231
- chunk = conn.recv(4 - len(hdr))
232
- if not chunk:
233
- return None
234
- hdr += chunk
235
- n = struct.unpack(">I", hdr)[0]
236
- buf = b""
237
- while len(buf) < n:
238
- chunk = conn.recv(n - len(buf))
239
- if not chunk:
240
- return None
241
- buf += chunk
242
- return json.loads(buf.decode())
243
-
244
- class _ProxyRawClient:
245
- """Drop-in replacement for ZenohClient's C++ core, backed by the
246
- host_agent byte-proxy instead of a local Zenoh session."""
247
-
248
- sender = "RRS"
249
- sender_id = ""
250
-
251
- def __init__(self, host, port):
252
- self._conn = socket.create_connection((host, port), timeout=10)
253
- # create_connection() leaves the connect timeout applied to
254
- # the socket permanently; the background read loop below does
255
- # a long-lived blocking recv() for async "sample" pushes, so
256
- # it must not time out on an idle connection.
257
- self._conn.settimeout(None)
258
- self._send_lock = threading.Lock()
259
- self._req_id = 0
260
- self._req_lock = threading.Lock()
261
- self._pending = {}
262
- self._subs = {} # sub_id -> callback
263
- self._sub_by_keyexpr = {}
264
- threading.Thread(target=self._read_loop, daemon=True).start()
265
-
266
- def _next_id(self):
267
- with self._req_lock:
268
- self._req_id += 1
269
- return self._req_id
270
-
271
- def _read_loop(self):
272
- while True:
273
- frame = _recv_frame(self._conn)
274
- if frame is None:
275
- return
276
- ftype = frame.get("type")
277
- if ftype == "sample":
278
- cb = self._sub_by_keyexpr.get(frame["keyexpr"])
279
- if cb is not None:
280
- try:
281
- cb(frame["key"], bytes.fromhex(frame["payload_hex"]), frame.get("att", ""))
282
- except Exception as exc:
283
- print(f"[muscat-pk] proxy subscriber callback error: {exc}", flush=True)
284
- elif "id" in frame:
285
- ev = self._pending.pop(frame["id"], None)
286
- if ev is not None:
287
- ev[1] = frame
288
- ev[0].set()
289
-
290
- def _request(self, obj, timeout=30):
291
- req_id = self._next_id()
292
- obj["id"] = req_id
293
- ev = threading.Event()
294
- slot = [ev, None]
295
- self._pending[req_id] = slot
296
- _send_frame(self._conn, self._send_lock, obj)
297
- if not ev.wait(timeout):
298
- self._pending.pop(req_id, None)
299
- raise TimeoutError(f"proxy request timed out: {obj.get('type')} {obj.get('keyexpr')}")
300
- return slot[1]
301
-
302
- def query(self, keyexpr, payload, timeout_ms, target):
303
- resp = self._request({
304
- "type": "query", "keyexpr": keyexpr,
305
- "payload_hex": payload.hex() if payload else None,
306
- "timeout_ms": timeout_ms, "target": target,
307
- }, timeout=(timeout_ms / 1000) + 5)
308
- if not resp.get("ok"):
309
- raise RuntimeError(resp.get("error", "proxy query failed"))
310
- return [
311
- (r["key"], bytes.fromhex(r["payload_hex"]), r["att"], r["encoding"], r["is_error"])
312
- for r in resp["replies"]
313
- ]
314
-
315
- def query_all(self, keyexpr, payload, timeout_ms, target):
316
- return self.query(keyexpr, payload, timeout_ms, target)
317
-
318
- def publish(self, topic, payload, att, congestion_control, priority, express):
319
- resp = self._request({
320
- "type": "put", "keyexpr": topic,
321
- "payload_hex": bytes(payload).hex() if payload else None,
322
- })
323
- if not resp.get("ok"):
324
- raise RuntimeError(resp.get("error", "proxy put failed"))
325
-
326
- def has_subscriber(self, topic):
327
- return topic in self._sub_by_keyexpr
328
-
329
- def declare_subscriber(self, topic, callback, opts=None):
330
- resp = self._request({"type": "subscribe", "keyexpr": topic})
331
- if not resp.get("ok"):
332
- raise RuntimeError(resp.get("error", "proxy subscribe failed"))
333
- sub_id = self._next_id()
334
- self._subs[sub_id] = topic
335
- self._sub_by_keyexpr[topic] = callback
336
- return sub_id
337
-
338
- def undeclare_subscriber(self, sub_id):
339
- topic = self._subs.pop(sub_id, None)
340
- if topic is None:
341
- return
342
- self._sub_by_keyexpr.pop(topic, None)
343
- self._request({"type": "unsubscribe", "keyexpr": topic})
344
-
345
- def unsubscribe_topic(self, topic):
346
- for sub_id, t in list(self._subs.items()):
347
- if t == topic:
348
- self.undeclare_subscriber(sub_id)
349
-
350
- def unsubscribe_all(self):
351
- for sub_id in list(self._subs):
352
- self.undeclare_subscriber(sub_id)
353
-
354
- def get_sub_stats(self, sub_id):
355
- return {}
356
-
357
- def declare_queryable(self, keyexpr, handler):
358
- raise NotImplementedError("byte-proxy mode does not support declare_queryable")
359
-
360
- def undeclare_queryable_by_topic(self, keyexpr):
361
- pass
362
-
363
- # ZenohClient is a process-wide singleton whose __init__ eagerly
364
- # calls connect() on first construction. rainbow.rb_sdk.base
365
- # transitively imports rainbow.rb_log.log, which constructs one at
366
- # module level — so this patch must land before RBBaseSDK is
367
- # imported, or that eager construction wins with a real session.
368
- from rainbow.rb_zenoh.client import ZenohClient
369
-
370
- def _proxy_connect(self, *, config_pairs=None, sender=None, max_attempts=8, initial_delay_s=0.5, max_delay_s=3.0):
371
- if self._client is not None:
372
- return
373
- if sender is not None:
374
- self._sender = sender
375
- print(f"[muscat-pk] zenoh via host byte-proxy → {proxy_endpoint} (no local Zenoh session)", flush=True)
376
- self._client = _ProxyRawClient(_proxy_host, _proxy_port)
377
-
378
- ZenohClient.connect = _proxy_connect
379
-
380
- from rainbow.rb_sdk.base import RBBaseSDK
381
-
382
- async def _sdk_connect_via_proxy(self, *args, **kwargs):
383
- # No HTTP registration with common — host_agent already handles
384
- # that. Just make sure the (proxy-backed) shared ZenohClient exists.
385
- self._ensure_zenoh_client_alive()
386
- return None
387
-
388
- async def _sdk_disconnect_via_proxy(self, *args, **kwargs):
389
- return None
390
-
391
- RBBaseSDK.connect = _sdk_connect_via_proxy
392
- RBBaseSDK.disconnect = _sdk_disconnect_via_proxy
393
- else:
394
- # Fallback: no host byte-proxy — the container reverse-dials the robot itself.
395
- advertise_host = os.getenv("MUSCAT_ZENOH_ADVERTISE_HOST")
396
- zenoh_port = os.getenv("MUSCAT_ZENOH_PORT")
397
- from rainbow.rb_sdk.base import RBBaseSDK
398
- if advertise_host:
399
- RBBaseSDK._detect_local_ip = staticmethod(lambda host: advertise_host)
400
- if zenoh_port:
401
- _orig_connect = RBBaseSDK.connect
402
-
403
- async def _connect_with_dev_port(self, *args, **kwargs):
404
- kwargs.setdefault("zenoh_port", int(zenoh_port))
405
- return await _orig_connect(self, *args, **kwargs)
406
-
407
- RBBaseSDK.connect = _connect_with_dev_port
408
- except Exception as exc:
409
- print(f"[muscat-pk] SDK dev network patch skipped: {exc}", flush=True)
410
-
411
- import runpy
412
- runpy.run_path("/plugin/py/main.py", run_name="__main__")
413
- PY`]);
414
-
415
- if (pluginId) log.step(`RB_PLUGIN_ID=${pluginId}`);
416
- if (usesHostNetwork()) log.step("network: host (native Linux — direct robot/gateway access)");
417
- if (proxyEndpoint) log.step(`Zenoh via host byte-proxy → ${proxyEndpoint}`);
418
- else if (zenohAdvertiseHost && zenohPort) log.step(`Zenoh advertise → ${zenohAdvertiseHost}:${zenohPort}`);
419
- log.step("watchfiles → py/main.py (reload on any .py change in py/)");
420
- const child = spawn(
421
- "docker",
422
- ["exec", ...envFlags, container, "bash", "-lc",
423
- "python3 -m watchfiles --filter python --target-type command 'python3 /tmp/muscat-pk-python-dev-entry.py' py"],
424
- { cwd: root, stdio: ["inherit", "inherit", "pipe"] },
425
- );
426
- // watchfiles prints a KeyboardInterrupt traceback on SIGTERM — suppress it
427
- child.stderr.on("data", (buf) => {
428
- const text = buf.toString();
429
- if (!text.includes("KeyboardInterrupt") && !text.includes("raise_keyboard_interrupt")) {
430
- process.stderr.write(buf);
431
- }
432
- });
433
-
434
- let cleaned = false;
435
- const cleanupResources = () => {
436
- if (cleaned) return;
437
- cleaned = true;
438
- stopPortMonitor();
439
- if (!child.killed && child.exitCode === null) child.kill();
440
- spawnSync("docker", ["rm", "-f", container], { stdio: "ignore" });
441
- };
442
- const cleanup = () => {
443
- cleanupResources();
444
- exitAfterCleanup(0);
445
- };
446
- child.on("exit", (code) => {
447
- cleanupResources();
448
- exitAfterCleanup(code ?? 0);
449
- });
450
- process.once("SIGINT", cleanup);
451
- process.once("SIGTERM", cleanup);
452
- }
453
-
454
- // ── ros2 ───────────────────────────────────────────────────────────────────────
455
- async function runRos2(root, portRanges) {
456
- const manifest = readManifest(root);
457
- const isRclcpp = manifest.runtime?.rosClient === "rclcpp";
458
- // Independent of Docker readiness — must still happen if Docker isn't
459
- // installed/running, since that's exactly when local IDE tooling matters most.
460
- if (!isRclcpp) syncIdeVenv(root); // rclpy is plain Python — rclcpp is C++, no venv needed
461
-
462
- assertDockerReady();
463
- const pkg = ros2PackageName(root);
464
- if (!pkg) throw new Error("no ROS2 package found under ros2_ws/src.");
465
-
466
- await ensureImage();
467
-
468
- const pluginId = manifest.id ?? "";
469
- const container = `${ROS2_DEV_IMAGE}-run`;
470
- const robotIp = process.env.MUSCAT_ROBOT_HOST ?? "";
471
- const robotToken = process.env.MUSCAT_ROBOT_TOKEN ?? "";
472
- const net = backendNetwork(portRanges);
473
- const envFlags = [
474
- // Same rationale as runPython(): no -t on docker exec below means
475
- // rclpy's Python process stdout is block-buffered, not line-buffered.
476
- "-e", "PYTHONUNBUFFERED=1",
477
- // Same as the robot: ROS discovery stays on this host (allow a PC with ROS_STATIC_PEERS).
478
- "-e", "ROS_AUTOMATIC_DISCOVERY_RANGE=LOCALHOST",
479
- ...net.env,
480
- ...(pluginId ? ["-e", `RB_PLUGIN_ID=${pluginId}`] : []),
481
- ...(robotIp ? ["-e", `MUSCAT_ROBOT_HOST=${robotIp}`] : []),
482
- ...(robotToken ? ["-e", `MUSCAT_ROBOT_TOKEN=${robotToken}`] : []),
483
- ...portEnvFlags(portRanges),
484
- ];
485
- const docker = (args, opts = {}) =>
486
- spawnSync("docker", args, { cwd: root, stdio: "inherit", ...opts });
487
-
488
- // start a warm, long-lived container with the workspace mounted
489
- docker(["rm", "-f", container], { stdio: "ignore" });
490
- log.step("starting ROS2 dev container…");
491
- const up = docker(
492
- ["run", "-d", "--name", container, ...net.run, ...realtimeFlags(), ...dockerUserFlags(), "-e", "PIP_ROOT_USER_ACTION=ignore", "-v", `${root}:/plugin`, "-w", "/plugin", ROS2_DEV_IMAGE, "sleep", "infinity"],
493
- { stdio: "inherit" },
494
- );
495
- if (up.status !== 0) throw new Error("failed to start dev container.");
496
- const stopPortMonitor = startPortContractMonitor({ container, ranges: portRanges, log, allow: [process.env.MUSCAT_ZENOH_PORT] });
497
-
498
- // one-time deps + symlink build (edits to .py reflect without rebuilding)
499
- log.step("pip install + colcon build --symlink-install (once)…");
500
- docker(["exec", container, "bash", "-lc",
501
- "pip install -r requirements.txt --break-system-packages --ignore-installed -q || true; cd ros2_ws && colcon build --symlink-install"]);
502
-
503
- let child = null;
504
- const startNode = () => {
505
- if (child) child.kill();
506
- // killing the `docker exec` client doesn't stop the node inside the container
507
- spawnSync("docker", ["exec", container, "pkill", "-f", `${pkg}_node|${manifest.runtime?.launch || pkg}`], { stdio: "ignore" });
508
- const peers = readRosPeers(root);
509
- // Same command the robot runs: rclpy execs the console script, rclcpp uses `ros2 run`;
510
- // relative topics land under the plugin's namespace (rosNamespace).
511
- const launch = manifest.runtime?.launch || `${pkg}_node`;
512
- const nsArgs = PLUGIN_ID_RE.test(pluginId) ? ` --ros-args -r __ns:=${rosNamespace(pluginId)}` : "";
513
- const run = (isRclcpp ? `ros2 run ${pkg} ${launch}` : `exec ${launch}`) + nsArgs;
514
- log.step(run);
515
- child = spawn(
516
- "docker",
517
- ["exec", ...envFlags, ...(peers.length ? ["-e", `ROS_STATIC_PEERS=${peers.join(";")}`] : []), container, "bash", "-lc",
518
- `cd ros2_ws && source install/setup.bash && ${run}`],
519
- { cwd: root, stdio: "inherit" },
520
- );
521
- };
522
- startNode();
523
- serveRosPeers(root, pluginId, startNode);
524
-
525
- // restart node on source change. rclpy reflects .py edits live (symlink-install);
526
- // rclcpp (C++) and package-metadata changes require a colcon rebuild.
527
- let timer = null;
528
- const onChange = (_event, filename) => {
529
- clearTimeout(timer);
530
- timer = setTimeout(() => {
531
- const structural = filename && /(setup\.py|package\.xml|CMakeLists\.txt)$/.test(String(filename));
532
- if (structural || isRclcpp) {
533
- log.info(structural ? "package metadata changed — colcon rebuild…" : "source changed — colcon rebuild…");
534
- docker(["exec", container, "bash", "-lc", "cd ros2_ws && colcon build --symlink-install"]);
535
- } else {
536
- log.info("source changed — restarting node…");
537
- }
538
- startNode();
539
- }, 300);
540
- };
541
- for (const target of ["ros2_ws/src", "main.py"]) {
542
- const full = resolve(root, target);
543
- if (existsSync(full)) watch(full, { recursive: true }, onChange);
544
- }
545
-
546
- const cleanup = () => {
547
- stopPortMonitor();
548
- if (child) child.kill();
549
- spawnSync("docker", ["rm", "-f", container], { stdio: "ignore" });
550
- exitAfterCleanup(0);
551
- };
552
- process.on("SIGINT", cleanup);
553
- process.on("SIGTERM", cleanup);
554
- }
555
-
556
- // ── cpp ──────────────────────────────────────────────────────────────────────
557
- async function runCpp(root, portRanges) {
558
- cppAssertDockerReady();
559
- const manifest = readManifest(root);
560
- const entry = manifest.runtime?.entry;
561
- if (!entry) throw new Error("runtime.entry (the built executable name) is required for cpp.");
562
-
563
- await cppEnsureImage();
564
-
565
- const pluginId = manifest.id ?? "";
566
- const container = `${CPP_DEV_IMAGE}-run`;
567
- const robotIp = process.env.MUSCAT_ROBOT_HOST ?? "";
568
- const robotToken = process.env.MUSCAT_ROBOT_TOKEN ?? "";
569
- const net = backendNetwork(portRanges);
570
- const envFlags = [
571
- ...net.env,
572
- ...(pluginId ? ["-e", `RB_PLUGIN_ID=${pluginId}`] : []),
573
- ...(robotIp ? ["-e", `MUSCAT_ROBOT_HOST=${robotIp}`] : []),
574
- ...(robotToken ? ["-e", `MUSCAT_ROBOT_TOKEN=${robotToken}`] : []),
575
- ...portEnvFlags(portRanges),
576
- ];
577
- const docker = (args, opts = {}) =>
578
- spawnSync("docker", args, { cwd: root, stdio: "inherit", ...opts });
579
-
580
- // start a warm, long-lived container with the plugin mounted
581
- docker(["rm", "-f", container], { stdio: "ignore" });
582
- log.step("starting C++ dev container…");
583
- const up = docker(
584
- ["run", "-d", "--name", container, ...net.run, ...realtimeFlags(), ...dockerUserFlags(), "-v", `${root}:/plugin`, "-w", "/plugin", CPP_DEV_IMAGE, "sleep", "infinity"],
585
- { stdio: "inherit" },
586
- );
587
- if (up.status !== 0) throw new Error("failed to start dev container.");
588
- const stopPortMonitor = startPortContractMonitor({ container, ranges: portRanges, log, allow: [process.env.MUSCAT_ZENOH_PORT] });
589
-
590
- const build = () => {
591
- log.step("cmake build…");
592
- return docker(["exec", container, "bash", "-lc",
593
- "cmake -S cpp -B cpp/build -DCMAKE_BUILD_TYPE=Debug && cmake --build cpp/build -j"]);
594
- };
595
- build();
596
-
597
- let child = null;
598
- const startBin = () => {
599
- if (child) child.kill();
600
- log.step(`run cpp/build/${entry}`);
601
- child = spawn(
602
- "docker",
603
- ["exec", ...envFlags, container, "bash", "-lc", `./cpp/build/${entry}`],
604
- { cwd: root, stdio: "inherit" },
605
- );
606
- };
607
- startBin();
608
-
609
- // rebuild + restart on C++ source changes; ignore the build/ output dir.
610
- let timer = null;
611
- const onChange = (_event, filename) => {
612
- if (filename && String(filename).startsWith("build")) return;
613
- clearTimeout(timer);
614
- timer = setTimeout(() => {
615
- log.info("source changed — rebuilding…");
616
- const r = build();
617
- if (r.status === 0) startBin();
618
- else log.warn("build failed — keeping previous binary");
619
- }, 300);
620
- };
621
- const cppDir = resolve(root, "cpp");
622
- if (existsSync(cppDir)) watch(cppDir, { recursive: true }, onChange);
623
-
624
- const cleanup = () => {
625
- stopPortMonitor();
626
- if (child) child.kill();
627
- spawnSync("docker", ["rm", "-f", container], { stdio: "ignore" });
628
- exitAfterCleanup(0);
629
- };
630
- process.on("SIGINT", cleanup);
631
- process.on("SIGTERM", cleanup);
632
- }
633
-
634
- function warnManifest(root) {
635
- const manifest = readManifest(root);
636
- if (!manifest || Object.keys(manifest).length === 0) return;
637
- const errors = validateManifest(manifest);
638
- for (const e of errors) log.warn(`manifest.json: ${e}`);
639
- }
640
-
641
- export async function runDevBack(args = []) {
642
- const root = findPluginRoot();
643
- assertManifestVariables(root);
644
- warnManifest(root);
645
- const portRanges = assertManifestPorts(root);
646
- if (portRanges.length > 0 && process.env.MUSCAT_PK_PORTS_PRECHECKED !== "1")
647
- log.step(`plugin backend ports declared: ${formatPortRanges(portRanges)}`);
648
- ensureDocker();
649
- const { robotIp, robotToken } = await promptRobotConnection(args);
650
- if (robotIp) process.env.MUSCAT_ROBOT_HOST = robotIp;
651
- if (robotToken) process.env.MUSCAT_ROBOT_TOKEN = robotToken;
652
- // Standalone `dev:back` against a remote robot needs the Zenoh reverse-path
653
- // advertise host/port too (idempotent — `muscat-pk dev` already set these).
654
- // Byte-proxy mode (a host-side host_agent set MUSCAT_PROXY_ENDPOINT) makes
655
- // the container speak the proxy protocol instead, so it needs no reverse-dial.
656
- if (!process.env.MUSCAT_PROXY_ENDPOINT) await ensureRemoteZenohEnv(robotIp, { log });
657
- // Standalone against a local Muscat (muscat-dev, …): join its netns like `dev` does,
658
- // otherwise zenoh reaches nothing and `execute` never arrives.
659
- if (!process.env.MUSCAT_PK_MUSCAT_CONTAINER && !isRemoteRobot(robotIp)) {
660
- const muscat = localMuscatContainer();
661
- if (muscat) {
662
- log.step(`local Muscat container '${muscat}' found — backend joins its network`);
663
- process.env.MUSCAT_PK_MUSCAT_CONTAINER = muscat;
664
- }
665
- }
666
- const type = manifestRuntime(root);
667
- if (type === "ros2") return await runRos2(root, portRanges);
668
- if (type === "cpp") return await runCpp(root, portRanges);
669
- return await runPython(root, portRanges);
670
- }
1
+ // muscat-pk dev:back — run the plugin back end during development.
2
+ // Every backend runs in a warm Docker container that matches the robot
3
+ // (Ubuntu 24.04), so dev behaves like production.
4
+ // python : Python 3.12 container, pip install, run main.py with watchfiles
5
+ // hot-reload. Shares the local Muscat container's netns (localhost:10000).
6
+ // cpp : Ubuntu + cmake container, build cpp/, run the binary, rebuild +
7
+ // restart on source changes.
8
+ // ros2 : Jazzy + Python 3.12 container, colcon build, run the node, restart
9
+ // on source changes (rclcpp rebuilds on change).
10
+ import { spawn, spawnSync } from "node:child_process";
11
+ import { existsSync, mkdirSync, readFileSync, watch, writeFileSync } from "node:fs";
12
+ import { isIP } from "node:net";
13
+ import { dirname, resolve } from "node:path";
14
+ import { dockerUserFlags, ensureDocker, ensureIdeVenv, findPluginRoot, log, promptRobotConnection, realtimeFlags } from "./util.mjs";
15
+ import { ensureRemoteZenohEnv, getFreePort, isRemoteRobot, usesHostNetwork } from "./zenohDev.mjs";
16
+ import { GATEWAY_PORT, localMuscatContainer, robotHasMuscat } from "./muscatLocal.mjs";
17
+ import { PLUGIN_ID_RE, assertManifestPorts, assertManifestVariables, manifestRuntime, readManifest, rosNamespace, validateManifest } from "./manifest.mjs";
18
+ import {
19
+ portEnvFlags,
20
+ publishPortFlags,
21
+ startPortContractMonitor,
22
+ } from "./ports.mjs";
23
+ import {
24
+ PYTHON_DEV_IMAGE,
25
+ assertDockerReady as pyAssertDockerReady,
26
+ ensureImage as pyEnsureImage,
27
+ } from "./python.mjs";
28
+ import {
29
+ ROS2_DEV_IMAGE,
30
+ assertDockerReady,
31
+ ensureImage,
32
+ ros2PackageName,
33
+ } from "./ros2.mjs";
34
+ import {
35
+ CPP_DEV_IMAGE,
36
+ assertDockerReady as cppAssertDockerReady,
37
+ ensureImage as cppEnsureImage,
38
+ } from "./cpp.mjs";
39
+
40
+ const MANAGED_DEV_ENV = "MUSCAT_PK_MANAGED_DEV";
41
+
42
+ function exitAfterCleanup(code = 0) {
43
+ if (process.env[MANAGED_DEV_ENV] !== "1") process.exit(code ?? 0);
44
+ }
45
+
46
+ export function stopDevBackContainers() {
47
+ for (const container of [
48
+ `${PYTHON_DEV_IMAGE}-run`,
49
+ `${ROS2_DEV_IMAGE}-run`,
50
+ `${CPP_DEV_IMAGE}-run`,
51
+ ]) {
52
+ spawnSync("docker", ["rm", "-f", container], { stdio: "ignore" });
53
+ }
54
+ }
55
+
56
+ // Where the backend's 127.0.0.1 points. A local Muscat container (muscat-pk-muscat, muscat-dev):
57
+ // join its netns so the gateway and MediaMTX resolve like on the robot. Otherwise its own
58
+ // netns, gateway via the host.
59
+ // IS_DEV Muscat (muscat-dev): common is a zenoh-router client and opens no 7447 listener,
60
+ // so the plugin must join the router too or `execute` queries never reach it.
61
+ function muscatZenohEnv(container) {
62
+ const r = spawnSync("docker", ["inspect", "-f", "{{range .Config.Env}}{{println .}}{{end}}", container], { encoding: "utf8" });
63
+ return /^IS_DEV=(true|1)$/im.test(r.stdout || "")
64
+ ? ["-e", "ZENOH_PEER_CONNECT_ENDPOINTS=tcp/zenoh-router:7447"]
65
+ : [];
66
+ }
67
+
68
+ function backendNetwork(portRanges, robotGateway) {
69
+ const muscat = process.env.MUSCAT_PK_MUSCAT_CONTAINER;
70
+ if (muscat) return { run: ["--network", `container:${muscat}`], env: muscatZenohEnv(muscat), shared: true };
71
+ // The robot runs Muscat: its gateway, own netns (host network on Linux), declared ports published.
72
+ if (robotGateway) {
73
+ const run = usesHostNetwork() ? ["--network", "host"] : publishPortFlags(portRanges);
74
+ return { run, env: ["-e", `RB_PLUGIN_GATEWAY_URL=${robotGateway}`], shared: usesHostNetwork() };
75
+ }
76
+ if (usesHostNetwork()) return { run: ["--network", "host"], env: [], shared: true };
77
+ return {
78
+ // host.docker.internal is automatic on Docker Desktop; this makes it resolve on Linux too.
79
+ run: [...publishPortFlags(portRanges), "--add-host", "host.docker.internal:host-gateway"],
80
+ env: ["-e", "RB_PLUGIN_GATEWAY_URL=http://host.docker.internal:10000"],
81
+ shared: false,
82
+ };
83
+ }
84
+
85
+ // rb.rosPeers in dev: answer muscat/plugin/<id>/ros_peers/{get,set} on the gateway the way
86
+ // Muscat (common) does on the robot — keep the list in .muscat-dev/ and restart the node
87
+ // with ROS_STATIC_PEERS.
88
+ const ROS_PEERS_FILE = ".muscat-dev/ros-peers.json";
89
+
90
+ function readRosPeers(root) {
91
+ try {
92
+ return JSON.parse(readFileSync(resolve(root, ROS_PEERS_FILE), "utf8"));
93
+ } catch {
94
+ return [];
95
+ }
96
+ }
97
+
98
+ async function serveRosPeers(root, pluginId, restart) {
99
+ if (!pluginId) return;
100
+ const { RbWs } = await import("@rainbow-robotics/plugin-ui/ws");
101
+ const ws = await Promise.race([
102
+ RbWs.connect("http://127.0.0.1:10000", pluginId).catch(() => null),
103
+ new Promise((r) => setTimeout(() => r(null), 5000)),
104
+ ]);
105
+ if (!ws) return log.warn("rb.rosPeers unavailable in dev: plugin gateway not reachable on :10000");
106
+ const base = `/muscat/plugin/${pluginId}/ros_peers`;
107
+ await ws.serve(`${base}/get`, () => ({ peers: readRosPeers(root) }));
108
+ await ws.serve(`${base}/set`, (payload) => {
109
+ const peers = payload?.peers;
110
+ if (!Array.isArray(peers) || !peers.every((p) => typeof p === "string" && isIP(p))) {
111
+ return { ok: false, error: "peers must be a list of IP addresses" };
112
+ }
113
+ const file = resolve(root, ROS_PEERS_FILE);
114
+ mkdirSync(dirname(file), { recursive: true });
115
+ writeFileSync(file, JSON.stringify(peers) + "\n");
116
+ log.step(`ROS_STATIC_PEERS → ${peers.join(";") || "(none)"} — restarting node`);
117
+ restart();
118
+ return { ok: true, peers };
119
+ });
120
+ }
121
+
122
+ // ── IDE tooling venv (best-effort, non-blocking) ────────────────────────────
123
+ // `muscat-pk new` already creates this up front (see new.mjs); this just
124
+ // refreshes it on every `dev`/`dev:back` in case requirements.txt changed.
125
+ // Docker remains the actual execution environment — this never affects it.
126
+ // Runs in the background so it never delays `dev:back` starting, and any
127
+ // failure here (no local Python toolchain, offline, ...) is silent.
128
+ function syncIdeVenv(root) {
129
+ setImmediate(() => {
130
+ try {
131
+ ensureIdeVenv(root);
132
+ } catch {
133
+ /* best-effort only */
134
+ }
135
+ });
136
+ }
137
+
138
+ // ── python ────────────────────────────────────────────────────────────────────
139
+ // Runs in a warm Ubuntu 24.04 + Python 3.12 container (matches the robot).
140
+ // Under `muscat-pk dev`, it shares the local Muscat container's netns, so the SDK
141
+ // reaches the plugin gateway at the default localhost:10000.
142
+ async function runPython(root, portRanges, robotGateway) {
143
+ // Independent of Docker readiness — must still happen if Docker isn't
144
+ // installed/running, since that's exactly when local IDE tooling matters most.
145
+ syncIdeVenv(root);
146
+ pyAssertDockerReady();
147
+ await pyEnsureImage();
148
+
149
+ const manifest = readManifest(root);
150
+ const pluginId = manifest.id ?? "";
151
+ const container = `${PYTHON_DEV_IMAGE}-run`;
152
+ const robotIp = process.env.MUSCAT_ROBOT_HOST ?? "";
153
+ const robotToken = process.env.MUSCAT_ROBOT_TOKEN ?? "";
154
+ const zenohAdvertiseHost = process.env.MUSCAT_ZENOH_ADVERTISE_HOST ?? "";
155
+ const zenohPort = process.env.MUSCAT_ZENOH_PORT ?? "";
156
+ // See host_agent.py: a Docker Desktop container can't join the robot's
157
+ // Zenoh mesh, so it speaks a byte protocol to the host-side proxy instead.
158
+ const proxyEndpoint = process.env.MUSCAT_PROXY_ENDPOINT ?? "";
159
+ // Native Linux (not WSL, not Docker Desktop) has real host networking: the
160
+ // container shares the host's network stack, so it reaches the gateway at
161
+ // localhost:10000 and the robot's Zenoh router / reverse-dial path directly
162
+ // — no netns sharing, port publishing, host.docker.internal, or byte-proxy.
163
+ const net = backendNetwork(portRanges, robotGateway);
164
+ // Only the container's OWN reverse-dial path needs to publish a listen port.
165
+ // With a shared netns the port is already reachable; with the byte-proxy
166
+ // the container just connects out — neither needs -p.
167
+ const standaloneZenohPortFlags =
168
+ !net.shared && !proxyEndpoint && zenohPort ? ["-p", `${zenohPort}:${zenohPort}`] : [];
169
+ // C++ SDK (rainbow-rb-sdk >= 0.10) can't use the byte shim: its Zenoh session is native. It listens
170
+ // on this published port instead and the entry script registers it with the robot (see there).
171
+ const sdkPeerPort = proxyEndpoint && !net.shared && zenohPort ? await getFreePort(Number(zenohPort) + 1) : "";
172
+ const envFlags = [
173
+ // docker exec below has no -t (no pseudo-TTY), so Python defaults to
174
+ // block-buffered stdout — print() output wouldn't show up until the
175
+ // buffer fills or the process exits. Force unbuffered.
176
+ "-e", "PYTHONUNBUFFERED=1",
177
+ ...net.env,
178
+ ...(pluginId ? ["-e", `RB_PLUGIN_ID=${pluginId}`] : []),
179
+ ...(robotIp ? ["-e", `MUSCAT_ROBOT_HOST=${robotIp}`] : []),
180
+ ...(robotToken ? ["-e", `MUSCAT_ROBOT_TOKEN=${robotToken}`] : []),
181
+ // Byte-proxy mode: the container has no Zenoh session of its own — the
182
+ // entry script's shim speaks the raw query/put/subscribe protocol to the
183
+ // host_agent process at this address instead.
184
+ ...(proxyEndpoint ? ["-e", `MUSCAT_PROXY_ENDPOINT=${proxyEndpoint}`] : []),
185
+ ...(sdkPeerPort ? ["-e", `MUSCAT_SDK_PEER_PORT=${sdkPeerPort}`] : []),
186
+ // Fallback (no proxy): the container reverse-dials the robot itself.
187
+ ...(!proxyEndpoint && zenohAdvertiseHost ? ["-e", `MUSCAT_ZENOH_ADVERTISE_HOST=${zenohAdvertiseHost}`] : []),
188
+ ...(!proxyEndpoint && zenohPort ? ["-e", `MUSCAT_ZENOH_PORT=${zenohPort}`] : []),
189
+ ...portEnvFlags(portRanges),
190
+ ];
191
+ const docker = (args, opts = {}) =>
192
+ spawnSync("docker", args, { cwd: root, stdio: "inherit", ...opts });
193
+
194
+ // start a warm, long-lived container with the plugin mounted.
195
+ // 호스트 uid 로 돌린다(dockerUserFlags) — root 로 쓴 build/·install/ 은 사용자가 지우지 못한다.
196
+ docker(["rm", "-f", container], { stdio: "ignore" });
197
+ const up = docker(
198
+ ["run", "-d", "--name", container, ...net.run, ...standaloneZenohPortFlags, ...(sdkPeerPort ? ["-p", `${sdkPeerPort}:${sdkPeerPort}`] : []), ...realtimeFlags(), ...dockerUserFlags(),
199
+ "-e", "PIP_ROOT_USER_ACTION=ignore",
200
+ "-v", `${root}:/plugin`, "-w", "/plugin", PYTHON_DEV_IMAGE, "sleep", "infinity"],
201
+ { stdio: ["ignore", "ignore", "inherit"] }, // stdout is just the container id
202
+ );
203
+ if (up.status !== 0) throw new Error("failed to start dev container.");
204
+ const stopPortMonitor = startPortContractMonitor({ container, ranges: portRanges, log, allow: [zenohPort, sdkPeerPort] });
205
+
206
+ docker(["exec", container, "bash", "-lc",
207
+ "pip install --break-system-packages --ignore-installed -q watchfiles" +
208
+ " && { [ -f requirements.txt ] && pip install --break-system-packages --ignore-installed -q -r requirements.txt || true; }"]);
209
+
210
+ docker(["exec", container, "bash", "-lc", `cat > /tmp/muscat-pk-python-dev-entry.py <<'PY'
211
+ import os
212
+
213
+ try:
214
+ import glob
215
+ import importlib.util
216
+
217
+ proxy_endpoint = os.getenv("MUSCAT_PROXY_ENDPOINT")
218
+ # Located without importing: importing rainbow.rb_sdk opens the SDK's Zenoh session.
219
+ _rainbow = importlib.util.find_spec("rainbow")
220
+ cpp_sdk = bool(_rainbow and any(glob.glob(os.path.join(p, "rb_sdk", "rb_sdk_cpp*.so")) for p in _rainbow.submodule_search_locations))
221
+ if proxy_endpoint and cpp_sdk and os.getenv("MUSCAT_SDK_PEER_PORT"):
222
+ # C++ SDK (rainbow-rb-sdk >= 0.10): its Zenoh session is native, so the byte shim below
223
+ # can't reach it, and connect() would register the container's own unreachable IP.
224
+ # Listen on the published port and register the host's address for it instead, the
225
+ # same way host_agent registers itself; the robot then dials in through Docker.
226
+ import json
227
+ import threading
228
+ import time
229
+ import urllib.request
230
+
231
+ from rainbow.rb_sdk import rb_sdk_cpp
232
+
233
+ _port = int(os.environ["MUSCAT_SDK_PEER_PORT"])
234
+ os.environ["ZENOH_PEER_LISTEN_ENDPOINTS"] = f"tcp/0.0.0.0:{_port}"
235
+ for _cls in list(vars(rb_sdk_cpp).values()):
236
+ if isinstance(_cls, type) and issubclass(_cls, rb_sdk_cpp.RBBaseSDK) and "connect" in _cls.__dict__:
237
+ _cls.connect = lambda self, *args, **kwargs: None
238
+
239
+ def _peer_http(method, path, body=None):
240
+ headers = {"Content-Type": "application/json"}
241
+ if os.getenv("MUSCAT_ROBOT_TOKEN"):
242
+ headers["Authorization"] = "Bearer " + os.environ["MUSCAT_ROBOT_TOKEN"]
243
+ req = urllib.request.Request(f"http://{os.environ['MUSCAT_ROBOT_HOST']}:8000{path}",
244
+ data=json.dumps(body).encode() if body is not None else None,
245
+ headers=headers, method=method)
246
+ with urllib.request.urlopen(req, timeout=10) as resp:
247
+ raw = resp.read()
248
+ return json.loads(raw) if raw else {}
249
+
250
+ def _peer_keepalive():
251
+ advertise = proxy_endpoint.rsplit(":", 1)[0]
252
+ peer_id = None
253
+ while True:
254
+ try:
255
+ if peer_id is None:
256
+ peer_id = _peer_http("POST", "/zenoh/peer", {"ip": advertise, "port": _port, "ttl": 30})["peer_id"]
257
+ print(f"[muscat-pk] C++ SDK zenoh peer {advertise}:{_port} registered with the robot", flush=True)
258
+ elif not _peer_http("POST", f"/zenoh/peer/{peer_id}/heartbeat", {"ttl": 30}).get("ok"):
259
+ peer_id = None
260
+ continue
261
+ except Exception as exc:
262
+ print(f"[muscat-pk] zenoh peer registration failed ({exc}); retrying", flush=True)
263
+ peer_id = None
264
+ time.sleep(15 if peer_id else 3)
265
+
266
+ threading.Thread(target=_peer_keepalive, daemon=True).start()
267
+ elif proxy_endpoint:
268
+ # Byte-proxy mode: this container has no Zenoh session of its own.
269
+ # ZenohClient's C++ core is replaced with a shim that speaks a raw
270
+ # query/put/subscribe byte protocol to host_agent.py instead.
271
+ import json
272
+ import socket
273
+ import struct
274
+ import threading
275
+
276
+ _proxy_host, _proxy_port_s = proxy_endpoint.rsplit(":", 1)
277
+ _proxy_port = int(_proxy_port_s)
278
+
279
+ def _send_frame(conn, lock, obj):
280
+ body = json.dumps(obj).encode()
281
+ with lock:
282
+ conn.sendall(struct.pack(">I", len(body)) + body)
283
+
284
+ def _recv_frame(conn):
285
+ hdr = b""
286
+ while len(hdr) < 4:
287
+ chunk = conn.recv(4 - len(hdr))
288
+ if not chunk:
289
+ return None
290
+ hdr += chunk
291
+ n = struct.unpack(">I", hdr)[0]
292
+ buf = b""
293
+ while len(buf) < n:
294
+ chunk = conn.recv(n - len(buf))
295
+ if not chunk:
296
+ return None
297
+ buf += chunk
298
+ return json.loads(buf.decode())
299
+
300
+ class _ProxyRawClient:
301
+ """Drop-in replacement for ZenohClient's C++ core, backed by the
302
+ host_agent byte-proxy instead of a local Zenoh session."""
303
+
304
+ sender = "RRS"
305
+ sender_id = ""
306
+
307
+ def __init__(self, host, port):
308
+ self._conn = socket.create_connection((host, port), timeout=10)
309
+ # create_connection() leaves the connect timeout applied to
310
+ # the socket permanently; the background read loop below does
311
+ # a long-lived blocking recv() for async "sample" pushes, so
312
+ # it must not time out on an idle connection.
313
+ self._conn.settimeout(None)
314
+ self._send_lock = threading.Lock()
315
+ self._req_id = 0
316
+ self._req_lock = threading.Lock()
317
+ self._pending = {}
318
+ self._subs = {} # sub_id -> callback
319
+ self._sub_by_keyexpr = {}
320
+ threading.Thread(target=self._read_loop, daemon=True).start()
321
+
322
+ def _next_id(self):
323
+ with self._req_lock:
324
+ self._req_id += 1
325
+ return self._req_id
326
+
327
+ def _read_loop(self):
328
+ while True:
329
+ frame = _recv_frame(self._conn)
330
+ if frame is None:
331
+ return
332
+ ftype = frame.get("type")
333
+ if ftype == "sample":
334
+ cb = self._sub_by_keyexpr.get(frame["keyexpr"])
335
+ if cb is not None:
336
+ try:
337
+ cb(frame["key"], bytes.fromhex(frame["payload_hex"]), frame.get("att", ""))
338
+ except Exception as exc:
339
+ print(f"[muscat-pk] proxy subscriber callback error: {exc}", flush=True)
340
+ elif "id" in frame:
341
+ ev = self._pending.pop(frame["id"], None)
342
+ if ev is not None:
343
+ ev[1] = frame
344
+ ev[0].set()
345
+
346
+ def _request(self, obj, timeout=30):
347
+ req_id = self._next_id()
348
+ obj["id"] = req_id
349
+ ev = threading.Event()
350
+ slot = [ev, None]
351
+ self._pending[req_id] = slot
352
+ _send_frame(self._conn, self._send_lock, obj)
353
+ if not ev.wait(timeout):
354
+ self._pending.pop(req_id, None)
355
+ raise TimeoutError(f"proxy request timed out: {obj.get('type')} {obj.get('keyexpr')}")
356
+ return slot[1]
357
+
358
+ def query(self, keyexpr, payload, timeout_ms, target):
359
+ resp = self._request({
360
+ "type": "query", "keyexpr": keyexpr,
361
+ "payload_hex": payload.hex() if payload else None,
362
+ "timeout_ms": timeout_ms, "target": target,
363
+ }, timeout=(timeout_ms / 1000) + 5)
364
+ if not resp.get("ok"):
365
+ raise RuntimeError(resp.get("error", "proxy query failed"))
366
+ return [
367
+ (r["key"], bytes.fromhex(r["payload_hex"]), r["att"], r["encoding"], r["is_error"])
368
+ for r in resp["replies"]
369
+ ]
370
+
371
+ def query_all(self, keyexpr, payload, timeout_ms, target):
372
+ return self.query(keyexpr, payload, timeout_ms, target)
373
+
374
+ def publish(self, topic, payload, att, congestion_control, priority, express):
375
+ resp = self._request({
376
+ "type": "put", "keyexpr": topic,
377
+ "payload_hex": bytes(payload).hex() if payload else None,
378
+ })
379
+ if not resp.get("ok"):
380
+ raise RuntimeError(resp.get("error", "proxy put failed"))
381
+
382
+ def has_subscriber(self, topic):
383
+ return topic in self._sub_by_keyexpr
384
+
385
+ def declare_subscriber(self, topic, callback, opts=None):
386
+ resp = self._request({"type": "subscribe", "keyexpr": topic})
387
+ if not resp.get("ok"):
388
+ raise RuntimeError(resp.get("error", "proxy subscribe failed"))
389
+ sub_id = self._next_id()
390
+ self._subs[sub_id] = topic
391
+ self._sub_by_keyexpr[topic] = callback
392
+ return sub_id
393
+
394
+ def undeclare_subscriber(self, sub_id):
395
+ topic = self._subs.pop(sub_id, None)
396
+ if topic is None:
397
+ return
398
+ self._sub_by_keyexpr.pop(topic, None)
399
+ self._request({"type": "unsubscribe", "keyexpr": topic})
400
+
401
+ def unsubscribe_topic(self, topic):
402
+ for sub_id, t in list(self._subs.items()):
403
+ if t == topic:
404
+ self.undeclare_subscriber(sub_id)
405
+
406
+ def unsubscribe_all(self):
407
+ for sub_id in list(self._subs):
408
+ self.undeclare_subscriber(sub_id)
409
+
410
+ def get_sub_stats(self, sub_id):
411
+ return {}
412
+
413
+ def declare_queryable(self, keyexpr, handler):
414
+ raise NotImplementedError("byte-proxy mode does not support declare_queryable")
415
+
416
+ def undeclare_queryable_by_topic(self, keyexpr):
417
+ pass
418
+
419
+ # ZenohClient is a process-wide singleton whose __init__ eagerly
420
+ # calls connect() on first construction. rainbow.rb_sdk.base
421
+ # transitively imports rainbow.rb_log.log, which constructs one at
422
+ # module level — so this patch must land before RBBaseSDK is
423
+ # imported, or that eager construction wins with a real session.
424
+ from rainbow.rb_zenoh.client import ZenohClient
425
+
426
+ def _proxy_connect(self, *, config_pairs=None, sender=None, max_attempts=8, initial_delay_s=0.5, max_delay_s=3.0):
427
+ if self._client is not None:
428
+ return
429
+ if sender is not None:
430
+ self._sender = sender
431
+ print(f"[muscat-pk] zenoh via host byte-proxy → {proxy_endpoint} (no local Zenoh session)", flush=True)
432
+ self._client = _ProxyRawClient(_proxy_host, _proxy_port)
433
+
434
+ ZenohClient.connect = _proxy_connect
435
+
436
+ from rainbow.rb_sdk.base import RBBaseSDK
437
+
438
+ async def _sdk_connect_via_proxy(self, *args, **kwargs):
439
+ # No HTTP registration with common — host_agent already handles
440
+ # that. Just make sure the (proxy-backed) shared ZenohClient exists.
441
+ self._ensure_zenoh_client_alive()
442
+ return None
443
+
444
+ async def _sdk_disconnect_via_proxy(self, *args, **kwargs):
445
+ return None
446
+
447
+ RBBaseSDK.connect = _sdk_connect_via_proxy
448
+ RBBaseSDK.disconnect = _sdk_disconnect_via_proxy
449
+ else:
450
+ # Fallback: no host byte-proxy — the container reverse-dials the robot itself.
451
+ advertise_host = os.getenv("MUSCAT_ZENOH_ADVERTISE_HOST")
452
+ zenoh_port = os.getenv("MUSCAT_ZENOH_PORT")
453
+ from rainbow.rb_sdk.base import RBBaseSDK
454
+ if advertise_host:
455
+ RBBaseSDK._detect_local_ip = staticmethod(lambda host: advertise_host)
456
+ if zenoh_port:
457
+ _orig_connect = RBBaseSDK.connect
458
+
459
+ async def _connect_with_dev_port(self, *args, **kwargs):
460
+ kwargs.setdefault("zenoh_port", int(zenoh_port))
461
+ return await _orig_connect(self, *args, **kwargs)
462
+
463
+ RBBaseSDK.connect = _connect_with_dev_port
464
+ except Exception as exc:
465
+ print(f"[muscat-pk] SDK dev network patch skipped: {exc}", flush=True)
466
+
467
+ import runpy
468
+ runpy.run_path("/plugin/py/main.py", run_name="__main__")
469
+ PY`]);
470
+
471
+ if (usesHostNetwork()) log.step("network: host (native Linux — direct robot/gateway access)");
472
+ if (proxyEndpoint) log.step(`Zenoh via host byte-proxy → ${proxyEndpoint}`);
473
+ else if (zenohAdvertiseHost && zenohPort) log.step(`Zenoh advertise → ${zenohAdvertiseHost}:${zenohPort}`);
474
+ const child = spawn(
475
+ "docker",
476
+ ["exec", ...envFlags, container, "bash", "-lc",
477
+ "python3 -m watchfiles --filter python --target-type command 'python3 /tmp/muscat-pk-python-dev-entry.py' py"],
478
+ { cwd: root, stdio: ["inherit", "inherit", "pipe"] },
479
+ );
480
+ // watchfiles prints a KeyboardInterrupt traceback on SIGTERM — suppress it
481
+ child.stderr.on("data", (buf) => {
482
+ const text = buf.toString();
483
+ if (!text.includes("KeyboardInterrupt") && !text.includes("raise_keyboard_interrupt")) {
484
+ process.stderr.write(buf);
485
+ }
486
+ });
487
+
488
+ let cleaned = false;
489
+ const cleanupResources = () => {
490
+ if (cleaned) return;
491
+ cleaned = true;
492
+ stopPortMonitor();
493
+ if (!child.killed && child.exitCode === null) child.kill();
494
+ spawnSync("docker", ["rm", "-f", container], { stdio: "ignore" });
495
+ };
496
+ const cleanup = () => {
497
+ cleanupResources();
498
+ exitAfterCleanup(0);
499
+ };
500
+ child.on("exit", (code) => {
501
+ cleanupResources();
502
+ exitAfterCleanup(code ?? 0);
503
+ });
504
+ process.once("SIGINT", cleanup);
505
+ process.once("SIGTERM", cleanup);
506
+ process.once("SIGHUP", cleanup);
507
+ }
508
+
509
+ // ── ros2 ───────────────────────────────────────────────────────────────────────
510
+ async function runRos2(root, portRanges, robotGateway) {
511
+ const manifest = readManifest(root);
512
+ const isRclcpp = manifest.runtime?.rosClient === "rclcpp";
513
+ // Independent of Docker readiness — must still happen if Docker isn't
514
+ // installed/running, since that's exactly when local IDE tooling matters most.
515
+ if (!isRclcpp) syncIdeVenv(root); // rclpy is plain Python — rclcpp is C++, no venv needed
516
+
517
+ assertDockerReady();
518
+ const pkg = ros2PackageName(root);
519
+ if (!pkg) throw new Error("no ROS2 package found under ros2_ws/src.");
520
+
521
+ await ensureImage();
522
+
523
+ const pluginId = manifest.id ?? "";
524
+ const container = `${ROS2_DEV_IMAGE}-run`;
525
+ const robotIp = process.env.MUSCAT_ROBOT_HOST ?? "";
526
+ const robotToken = process.env.MUSCAT_ROBOT_TOKEN ?? "";
527
+ const net = backendNetwork(portRanges, robotGateway);
528
+ const envFlags = [
529
+ // Same rationale as runPython(): no -t on docker exec below means
530
+ // rclpy's Python process stdout is block-buffered, not line-buffered.
531
+ "-e", "PYTHONUNBUFFERED=1",
532
+ // Same as the robot: ROS discovery stays on this host (allow a PC with ROS_STATIC_PEERS).
533
+ "-e", "ROS_AUTOMATIC_DISCOVERY_RANGE=LOCALHOST",
534
+ ...net.env,
535
+ ...(pluginId ? ["-e", `RB_PLUGIN_ID=${pluginId}`] : []),
536
+ ...(robotIp ? ["-e", `MUSCAT_ROBOT_HOST=${robotIp}`] : []),
537
+ ...(robotToken ? ["-e", `MUSCAT_ROBOT_TOKEN=${robotToken}`] : []),
538
+ ...portEnvFlags(portRanges),
539
+ ];
540
+ const docker = (args, opts = {}) =>
541
+ spawnSync("docker", args, { cwd: root, stdio: "inherit", ...opts });
542
+
543
+ // start a warm, long-lived container with the workspace mounted
544
+ docker(["rm", "-f", container], { stdio: "ignore" });
545
+ const up = docker(
546
+ ["run", "-d", "--name", container, ...net.run, ...realtimeFlags(), ...dockerUserFlags(), "-e", "PIP_ROOT_USER_ACTION=ignore", "-v", `${root}:/plugin`, "-w", "/plugin", ROS2_DEV_IMAGE, "sleep", "infinity"],
547
+ { stdio: ["ignore", "ignore", "inherit"] }, // stdout is just the container id
548
+ );
549
+ if (up.status !== 0) throw new Error("failed to start dev container.");
550
+ const stopPortMonitor = startPortContractMonitor({ container, ranges: portRanges, log, allow: [process.env.MUSCAT_ZENOH_PORT] });
551
+
552
+ // one-time deps + symlink build (edits to .py reflect without rebuilding)
553
+ docker(["exec", container, "bash", "-lc",
554
+ "pip install -r requirements.txt --break-system-packages --ignore-installed -q || true; cd ros2_ws && colcon build --symlink-install"]);
555
+
556
+ let child = null;
557
+ const startNode = () => {
558
+ if (child) child.kill();
559
+ // killing the `docker exec` client doesn't stop the node inside the container
560
+ spawnSync("docker", ["exec", container, "pkill", "-f", `${pkg}_node|${manifest.runtime?.launch || pkg}`], { stdio: "ignore" });
561
+ const peers = readRosPeers(root);
562
+ // Same command the robot runs: rclpy execs the console script, rclcpp uses `ros2 run`;
563
+ // relative topics land under the plugin's namespace (rosNamespace).
564
+ const launch = manifest.runtime?.launch || `${pkg}_node`;
565
+ const nsArgs = PLUGIN_ID_RE.test(pluginId) ? ` --ros-args -r __ns:=${rosNamespace(pluginId)}` : "";
566
+ const run = (isRclcpp ? `ros2 run ${pkg} ${launch}` : `exec ${launch}`) + nsArgs;
567
+ child = spawn(
568
+ "docker",
569
+ ["exec", ...envFlags, ...(peers.length ? ["-e", `ROS_STATIC_PEERS=${peers.join(";")}`] : []), container, "bash", "-lc",
570
+ `cd ros2_ws && source install/setup.bash && ${run}`],
571
+ { cwd: root, stdio: "inherit" },
572
+ );
573
+ };
574
+ startNode();
575
+ serveRosPeers(root, pluginId, startNode);
576
+
577
+ // restart node on source change. rclpy reflects .py edits live (symlink-install);
578
+ // rclcpp (C++) and package-metadata changes require a colcon rebuild.
579
+ let timer = null;
580
+ const onChange = (_event, filename) => {
581
+ clearTimeout(timer);
582
+ timer = setTimeout(() => {
583
+ const structural = filename && /(setup\.py|package\.xml|CMakeLists\.txt)$/.test(String(filename));
584
+ if (structural || isRclcpp) {
585
+ log.info(structural ? "package metadata changed — colcon rebuild…" : "source changed — colcon rebuild…");
586
+ docker(["exec", container, "bash", "-lc", "cd ros2_ws && colcon build --symlink-install"]);
587
+ } else {
588
+ log.info("source changed — restarting node…");
589
+ }
590
+ startNode();
591
+ }, 300);
592
+ };
593
+ for (const target of ["ros2_ws/src", "main.py"]) {
594
+ const full = resolve(root, target);
595
+ if (existsSync(full)) watch(full, { recursive: true }, onChange);
596
+ }
597
+
598
+ const cleanup = () => {
599
+ stopPortMonitor();
600
+ if (child) child.kill();
601
+ spawnSync("docker", ["rm", "-f", container], { stdio: "ignore" });
602
+ exitAfterCleanup(0);
603
+ };
604
+ process.on("SIGINT", cleanup);
605
+ process.on("SIGTERM", cleanup);
606
+ process.on("SIGHUP", cleanup);
607
+ }
608
+
609
+ // ── cpp ──────────────────────────────────────────────────────────────────────
610
+ async function runCpp(root, portRanges, robotGateway) {
611
+ cppAssertDockerReady();
612
+ const manifest = readManifest(root);
613
+ const entry = manifest.runtime?.entry;
614
+ if (!entry) throw new Error("runtime.entry (the built executable name) is required for cpp.");
615
+
616
+ await cppEnsureImage();
617
+
618
+ const pluginId = manifest.id ?? "";
619
+ const container = `${CPP_DEV_IMAGE}-run`;
620
+ const robotIp = process.env.MUSCAT_ROBOT_HOST ?? "";
621
+ const robotToken = process.env.MUSCAT_ROBOT_TOKEN ?? "";
622
+ const net = backendNetwork(portRanges, robotGateway);
623
+ const envFlags = [
624
+ ...net.env,
625
+ ...(pluginId ? ["-e", `RB_PLUGIN_ID=${pluginId}`] : []),
626
+ ...(robotIp ? ["-e", `MUSCAT_ROBOT_HOST=${robotIp}`] : []),
627
+ ...(robotToken ? ["-e", `MUSCAT_ROBOT_TOKEN=${robotToken}`] : []),
628
+ ...portEnvFlags(portRanges),
629
+ ];
630
+ const docker = (args, opts = {}) =>
631
+ spawnSync("docker", args, { cwd: root, stdio: "inherit", ...opts });
632
+
633
+ // start a warm, long-lived container with the plugin mounted
634
+ docker(["rm", "-f", container], { stdio: "ignore" });
635
+ const up = docker(
636
+ ["run", "-d", "--name", container, ...net.run, ...realtimeFlags(), ...dockerUserFlags(), "-v", `${root}:/plugin`, "-w", "/plugin", CPP_DEV_IMAGE, "sleep", "infinity"],
637
+ { stdio: ["ignore", "ignore", "inherit"] }, // stdout is just the container id
638
+ );
639
+ if (up.status !== 0) throw new Error("failed to start dev container.");
640
+ const stopPortMonitor = startPortContractMonitor({ container, ranges: portRanges, log, allow: [process.env.MUSCAT_ZENOH_PORT] });
641
+
642
+ const build = () => {
643
+ return docker(["exec", container, "bash", "-lc",
644
+ "cmake -S cpp -B cpp/build -DCMAKE_BUILD_TYPE=Debug && cmake --build cpp/build -j"]);
645
+ };
646
+ build();
647
+
648
+ let child = null;
649
+ const startBin = () => {
650
+ if (child) child.kill();
651
+ child = spawn(
652
+ "docker",
653
+ ["exec", ...envFlags, container, "bash", "-lc", `./cpp/build/${entry}`],
654
+ { cwd: root, stdio: "inherit" },
655
+ );
656
+ };
657
+ startBin();
658
+
659
+ // rebuild + restart on C++ source changes; ignore the build/ output dir.
660
+ let timer = null;
661
+ const onChange = (_event, filename) => {
662
+ if (filename && String(filename).startsWith("build")) return;
663
+ clearTimeout(timer);
664
+ timer = setTimeout(() => {
665
+ log.info("source changed — rebuilding…");
666
+ const r = build();
667
+ if (r.status === 0) startBin();
668
+ else log.warn("build failed — keeping previous binary");
669
+ }, 300);
670
+ };
671
+ const cppDir = resolve(root, "cpp");
672
+ if (existsSync(cppDir)) watch(cppDir, { recursive: true }, onChange);
673
+
674
+ const cleanup = () => {
675
+ stopPortMonitor();
676
+ if (child) child.kill();
677
+ spawnSync("docker", ["rm", "-f", container], { stdio: "ignore" });
678
+ exitAfterCleanup(0);
679
+ };
680
+ process.on("SIGINT", cleanup);
681
+ process.on("SIGTERM", cleanup);
682
+ process.on("SIGHUP", cleanup);
683
+ }
684
+
685
+ function warnManifest(root) {
686
+ const manifest = readManifest(root);
687
+ if (!manifest || Object.keys(manifest).length === 0) return;
688
+ const errors = validateManifest(manifest);
689
+ for (const e of errors) log.warn(`manifest.json: ${e}`);
690
+ }
691
+
692
+ export async function runDevBack(args = []) {
693
+ const root = findPluginRoot();
694
+ assertManifestVariables(root);
695
+ warnManifest(root);
696
+ const portRanges = assertManifestPorts(root);
697
+ ensureDocker();
698
+ const { robotIp, robotToken } = await promptRobotConnection(args);
699
+ if (robotIp) process.env.MUSCAT_ROBOT_HOST = robotIp;
700
+ if (robotToken) process.env.MUSCAT_ROBOT_TOKEN = robotToken;
701
+ // Standalone `dev:back` against a remote robot needs the Zenoh reverse-path
702
+ // advertise host/port too (idempotent — `muscat-pk dev` already set these).
703
+ // Byte-proxy mode (a host-side host_agent set MUSCAT_PROXY_ENDPOINT) makes
704
+ // the container speak the proxy protocol instead, so it needs no reverse-dial.
705
+ if (!process.env.MUSCAT_PROXY_ENDPOINT) await ensureRemoteZenohEnv(robotIp, { log });
706
+ // Standalone against a local Muscat (muscat-dev, …): join its netns like `dev` does,
707
+ // otherwise zenoh reaches nothing and `execute` never arrives.
708
+ if (!process.env.MUSCAT_PK_MUSCAT_CONTAINER && !isRemoteRobot(robotIp)) {
709
+ const muscat = localMuscatContainer();
710
+ if (muscat) {
711
+ process.env.MUSCAT_PK_MUSCAT_CONTAINER = muscat;
712
+ }
713
+ }
714
+ // Stopped while still setting up (image build, pip install, first cmake build): the runtime's own
715
+ // cleanup is not registered yet, so remove the container here. Once it is, it handles the signal.
716
+ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"])
717
+ process.once(signal, () => {
718
+ if (process.listenerCount(signal)) return;
719
+ stopDevBackContainers();
720
+ process.exit(130);
721
+ });
722
+ const robotGateway = (await robotHasMuscat(robotIp)) ? `http://${robotIp}:${GATEWAY_PORT}` : "";
723
+ const type = manifestRuntime(root);
724
+ if (type === "ros2") return await runRos2(root, portRanges, robotGateway);
725
+ if (type === "cpp") return await runCpp(root, portRanges, robotGateway);
726
+ return await runPython(root, portRanges, robotGateway);
727
+ }