commit 521d11ffa6258a2c84173ace2bfc0a1247bff704 Author: chyoo Date: Thu Jul 23 09:12:00 2026 +0900 Initial commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3ba89e2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +node_modules +.git +.agents +.codex +.env +bun.lockb +npm-debug.log +Dockerfile +docker-compose.yml +.dockerignore diff --git a/.env b/.env new file mode 100644 index 0000000..3b6a92e --- /dev/null +++ b/.env @@ -0,0 +1,14 @@ +HOST=0.0.0.0 +PORT=3100 +REMOTE_DISPLAY=:2 +REMOTE_DISPLAY_SCREEN=1600x900x24 +PLATFORM_WORKSPACE_ROOT=/home/ubuntu/.caches +CAMERA_VIDEO_DEVICE=/dev/video0 +CAMERA_SIGNALING_URL=ws://ec2-13-125-135-255.ap-northeast-2.compute.amazonaws.com:3000 +CAMERA_ROOM_ID=jetson-1 +KASMVNC_DISPLAY=:1 +KASMVNC_WEB_PORT=6901 +KASMVNC_PROTOCOL=http +KASMVNC_INTERFACE=0.0.0.0 +KASMVNC_GEOMETRY=1600x900 +KASMVNC_DESKTOP=XFCE diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..daa2792 --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +HOST=0.0.0.0 +PORT=3100 +PLATFORM_PROVISION_URL=https://portal-api.autonomystudio.net/api/provision +REMOTE_DISPLAY=:2 +REMOTE_DISPLAY_SCREEN=1600x900x24 +PLATFORM_WORKSPACE_ROOT=/home/ubuntu/.caches +CAMERA_VIDEO_DEVICE=/dev/video0 +CAMERA_SIGNALING_URL=ws://127.0.0.1:3000 +CAMERA_ROOM_ID=jetson-1 +KASMVNC_DISPLAY=:1 +KASMVNC_WEB_PORT=6901 +KASMVNC_PROTOCOL=http +KASMVNC_INTERFACE=0.0.0.0 +KASMVNC_GEOMETRY=1600x900 +KASMVNC_DESKTOP=XFCE diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..03c73e7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules +dist +.codex +.agents + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..80d119b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,41 @@ +FROM oven/bun:1.2.23-debian + +ENV NODE_ENV=production +ENV HOST=0.0.0.0 +ENV PORT=3100 +ENV AGENT_CONFIG=/app/agent.config.json +ENV WORKSPACE_ROOT=/workspace + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + wget \ + sudo \ + python3 \ + python3-pip \ + python3-gi \ + gir1.2-gstreamer-1.0 \ + gir1.2-gst-plugins-base-1.0 \ + gstreamer1.0-tools \ + gstreamer1.0-plugins-base \ + gstreamer1.0-plugins-good \ + gstreamer1.0-plugins-bad \ + gstreamer1.0-plugins-ugly \ + v4l-utils \ + && python3 -m pip install --break-system-packages --no-cache-dir websockets \ + && rm -rf /var/lib/apt/lists/* + +COPY package.json bun.lock ./ +RUN bun install --production --frozen-lockfile + +COPY agent.config.json ./ +COPY src ./src +COPY public ./public +COPY scripts ./scripts +COPY ros_bridge ./ros_bridge + +EXPOSE 3100 + +CMD ["bun", "run", "start"] diff --git a/PLATFORM_AGENT_RUNBOOK.md b/PLATFORM_AGENT_RUNBOOK.md new file mode 100644 index 0000000..fec9502 --- /dev/null +++ b/PLATFORM_AGENT_RUNBOOK.md @@ -0,0 +1,231 @@ +# Platform Agent Runbook + +This runbook covers running the Platform Agent on a remote device and connecting a KasmVNC-based remote desktop. + +## Role + +The agent provides these capabilities on the remote platform: + +- Start, stop, and inspect a KasmVNC XFCE remote desktop +- Browse, read, and save workspace files +- List, start, stop, log, and connect terminals to Docker modules +- Start and stop the camera WebRTC sender +- List `/dev/video*` camera devices + +## Recommended Runtime + +Host execution is currently recommended over Docker. + +Reasons: + +- KasmVNC/XFCE is simpler when attached to the host desktop environment +- Camera and GStreamer stacks are commonly validated on the host +- Adding GStreamer and desktop packages to the Docker image increases image size + +## Required Host Environment + +```text +bun +KasmVNC +XFCE +python3 +python3-gi +websockets +GStreamer webrtcbin +v4l-utils +Docker +``` + +Check commands: + +```bash +bun --version +command -v vncserver || command -v kasmvncserver +python3 -c "import websockets, gi" +gst-inspect-1.0 webrtcbin +v4l2-ctl --list-devices +docker ps +``` + +## Install + +```bash +cd /home/chyoo/tmp/agent_v2 +bun install +``` + +`bun install` installs only JavaScript dependencies. System packages such as KasmVNC, XFCE, and GStreamer must be installed separately. + +## Run + +```bash +cd /home/chyoo/tmp/agent_v2 +HOST=0.0.0.0 PORT=3100 bun run start +``` + +Development run: + +```bash +cd /home/chyoo/tmp/agent_v2 +HOST=0.0.0.0 PORT=3100 bun run dev +``` + +Use this URL from the Electron app or browser: + +```text +http://:3100 +``` + +## Main Environment Values + +```bash +HOST=0.0.0.0 +PORT=3100 +WORKSPACE_ROOT=/home/ubuntu/.caches + +KASMVNC_DISPLAY=:1 +KASMVNC_WEB_PORT=6901 +KASMVNC_PROTOCOL=http +KASMVNC_INTERFACE=0.0.0.0 +KASMVNC_GEOMETRY=1600x900 +KASMVNC_DESKTOP=XFCE + +CAMERA_ENABLED=true +CAMERA_SIGNALING_URL=ws://SIGNALING_HOST:3000 +CAMERA_ROOM_ID=jetson-1 +CAMERA_VIDEO_DEVICE=/dev/video0 +``` + +## KasmVNC API + +```bash +curl http://127.0.0.1:3100/api/kasmvnc/status +curl -X POST http://127.0.0.1:3100/api/kasmvnc/start +curl -X POST http://127.0.0.1:3100/api/kasmvnc/stop +``` + +Default command run by the agent: + +```bash +vncserver :1 -select-de XFCE -geometry 1600x900 -depth 24 -websocketPort 6901 -interface 0.0.0.0 +``` + +Default KasmVNC web URL: + +```text +http://:6901 +``` + +## API Checks + +Health and config: + +```bash +curl http://127.0.0.1:3100/health +curl http://127.0.0.1:3100/api/config +``` + +Workspace: + +```bash +curl http://127.0.0.1:3100/api/workspace/summary +curl http://127.0.0.1:3100/api/workspace/tree +``` + +Camera: + +```bash +curl http://127.0.0.1:3100/api/camera/devices +curl http://127.0.0.1:3100/api/camera/status +curl -X POST http://127.0.0.1:3100/api/camera/start +curl -X POST http://127.0.0.1:3100/api/camera/stop +``` + +## systemd Example + +```ini +[Unit] +Description=Platform Agent +After=network-online.target + +[Service] +WorkingDirectory=/home/ubuntu/tmp/agent_v2 +ExecStart=/home/ubuntu/.bun/bin/bun run start +Restart=always +Environment=HOST=0.0.0.0 +Environment=PORT=3100 +Environment=WORKSPACE_ROOT=/home/ubuntu/.caches +Environment=KASMVNC_DISPLAY=:1 +Environment=KASMVNC_WEB_PORT=6901 +Environment=KASMVNC_PROTOCOL=http +Environment=KASMVNC_INTERFACE=0.0.0.0 +Environment=KASMVNC_GEOMETRY=1600x900 +Environment=KASMVNC_DESKTOP=XFCE +Environment=CAMERA_ENABLED=true +Environment=CAMERA_SIGNALING_URL=ws://SIGNALING_HOST:3000 +Environment=CAMERA_ROOM_ID=jetson-1 +Environment=CAMERA_VIDEO_DEVICE=/dev/video0 + +[Install] +WantedBy=multi-user.target +``` + +Register the service: + +```bash +sudo cp platform-agent.service /etc/systemd/system/platform-agent.service +sudo systemctl daemon-reload +sudo systemctl enable platform-agent +sudo systemctl start platform-agent +sudo systemctl status platform-agent +``` + +Logs: + +```bash +journalctl -u platform-agent -f +``` + +## Docker Run + +Docker compose is available but optional. + +```bash +cd /home/chyoo/tmp/agent_v2 +cp .env.example .env +docker compose up -d --build +``` + +Notes: + +- Running KasmVNC/XFCE inside a container requires separate desktop session setup +- If KasmVNC and camera integration are already validated on the host, host execution is usually lighter + +## Troubleshooting + +Check ports: + +```bash +ss -ltnp | grep -E '3100|6901' +``` + +Check KasmVNC: + +```bash +command -v vncserver || command -v kasmvncserver +vncserver -list +``` + +Check camera devices: + +```bash +ls -l /dev/video* +v4l2-ctl --list-devices +``` + +Check GStreamer: + +```bash +gst-inspect-1.0 webrtcbin +gst-inspect-1.0 nvv4l2h264enc +``` diff --git a/README.md b/README.md new file mode 100644 index 0000000..e1aca97 --- /dev/null +++ b/README.md @@ -0,0 +1,254 @@ +# Platform Agent + +An integrated agent for Jetson and Mobility PC devices that connects Platform IDE to local desktop, workspace, camera, Docker modules, and ROS bridge services. + +This project combines the previous `platform-agent`, `autoware-back`, and `nav2-back` roles into one process and one configuration file. + +## Features + +- KasmVNC remote desktop status, start, and stop +- Workspace file browsing, reading, saving, and image preview +- Docker-based Autoware/Nav2 module listing, logs, start, and stop +- Browser terminal WebSocket +- Camera device listing, MJPEG, and WebRTC sender control +- Unified Autoware/Nav2 ROS bridge control API +- Web settings UI + +## Run + +```bash +bun install +bun run start +``` + +Default URL: + +```text +http://127.0.0.1:3100 +``` + +Settings UI: + +```text +http://:3100 +``` + +KasmVNC iframe: + +```text +http://:3100/remote +``` + +## Install + +curl -fsSL https://pub-ca72c40797144462bdb7d200c215d361.r2.dev/studio-files/public/platform-agent/install.sh | sh + +Install from a local checkout: + +```bash +cd /path/to/agent_v2 +sh scripts/install.sh +``` + +For remote `curl | sh` installs, publish an architecture-specific installer archive and a small bootstrap script. The TUI binary must be built for both `amd64` and `arm64`. + +```bash +curl -fsSL https://pub-ca72c40797144462bdb7d200c215d361.r2.dev/studio-files/public/platform-agent/install.sh | sh +``` + +The default install path is `/opt/platform-agent`. + +```bash +PLATFORM_AGENT_DIR=/opt/platform-agent PLATFORM_AGENT_USER=ubuntu sh scripts/install.sh +``` + +Tailscale settings can be provisioned from your backend instead of asking the installer user to enter auth keys. + +```bash +sh scripts/install.sh +``` + +The default provisioning endpoint is `https://portal-api.autonomystudio.net/api/provision`. Override it with `PLATFORM_PROVISION_URL` when the backend runs somewhere else. + +```bash +PLATFORM_PROVISION_URL=https://your-domain/api/provision sh scripts/install.sh +``` + +In Launcher, open **Platform management** and select **Register** to create a one-time six-digit registration code. Enter the Autonomy Studio account email and that code in the installer. The code expires after five minutes, can be used only once, and is not written to the final install configuration file. + +```json +{ + "hostname": "device-hostname", + "os": "linux", + "arch": "arm64", + "kernel": "5.15.0-tegra", + "distro": "Ubuntu 22.04.5 LTS", + "device": { + "hostname": "device-hostname", + "os": "linux", + "arch": "arm64", + "kernel": "5.15.0-tegra", + "distro": "Ubuntu 22.04.5 LTS" + } +} +``` + +The backend response supports these keys. Both camelCase and snake_case are accepted. + +```json +{ + "installTailscale": true, + "tailscaleAuthKey": "tskey-auth-test-autonomy-studio", + "tailscaleLoginServer": "https://headscale.example.com", + "tailscaleHostname": "device-hostname", + "tailscaleExtraArgs": "--accept-routes" +} +``` + +If `tailscaleAuthKey` is present, `installTailscale` is treated as `true`. + +Interactive installs prefer the Bubble Tea TUI. If Go is available on the device, the installer can run the source below with `go run`; otherwise it falls back to shell prompts. + +```text +scripts/installer-tui/ +``` + +When shipping prebuilt TUI binaries in a release: + +```bash +PLATFORM_INSTALLER_TUI_URL=https://your-domain/platform-agent-installer-tui-linux-{arch} \ +sh scripts/install.sh +``` + +Example TUI binary builds: + +```bash +cd scripts/installer-tui +go mod tidy +GOOS=linux GOARCH=amd64 go build -o installer-tui-linux-amd64 . +GOOS=linux GOARCH=arm64 go build -o installer-tui-linux-arm64 . +``` + +For a noninteractive environment-only install: + +```bash +PLATFORM_AGENT_INTERACTIVE=false \ +PLATFORM_AGENT_DIR=/opt/platform-agent \ +WORKSPACE_ROOT=/home/ubuntu/.caches \ +ROS_DOMAIN_ID=0 \ +sh scripts/install.sh +``` + +If you need to force Tailscale settings in a noninteractive install, the existing environment variables still work. + +```bash +PLATFORM_AGENT_INTERACTIVE=false \ +INSTALL_TAILSCALE=true \ +TAILSCALE_AUTH_KEY=tskey-auth-... \ +sh scripts/install.sh +``` + +Install steps are printed with progress output. + +Autoware/Nav2 is not selected during install. The unified bridge supports both, and the active target can be changed after install in the Settings UI `ROS Bridge` section or by choosing which ROS bridge module to run. Use `ROBOT_STACK=autoware|nav2` only when you need to force the initial value in a noninteractive install. + +## Unified Config + +The default config file is `agent.config.json`. + +Main sections: + +- `server`: agent host, port, and public URL +- `workspace`: workspace root exposed to Platform IDE +- `desktop`: KasmVNC display, web port, and resolution +- `camera`: camera device, signaling URL, and resolution +- `ros`: Autoware/Nav2 stack, ROS_DOMAIN_ID, and bridge URL +- `modules`: module run script paths + +Use `AGENT_CONFIG` to override the config file path. + +## Platform IDE Compatible API + +```text +GET /health +GET /api/config +PUT /api/config + +GET /api/workspace/summary +GET /api/workspace/tree +GET /api/workspace/file +GET /api/workspace/image +POST /api/workspace/file + +GET /api/modules/workspaces +GET /api/modules/docker-statuses +GET /api/modules/logs +POST /api/modules/start +POST /api/modules/stop +WS /api/modules/terminal + +GET /api/camera/status +GET /api/camera/devices +POST /api/camera/start +POST /api/camera/stop +GET /api/camera/mjpeg + +GET /api/kasmvnc/status +POST /api/kasmvnc/start +POST /api/kasmvnc/stop + +GET /api/bridge/status +GET /api/bridge/stack +POST /api/bridge/clear +POST /api/bridge/stop +POST /api/bridge/reset +``` + +Autoware/Nav2 viewer compatible API: + +```text +POST /plan +POST /drive +POST /clear +POST /stop +POST /reset +``` + +## ROS Bridge + +The unified bridge is `ros_bridge/bridge.py`. It uses `ROBOT_STACK=autoware|nav2` or `agent.config.json` `ros.stack` to choose the active target behavior. + +Direct run example: + +```bash +source /opt/ros/humble/setup.bash +pip install -r ros_bridge/requirements.txt +ROBOT_STACK=autoware ROS_DOMAIN_ID=0 python3 ros_bridge/bridge.py +``` + +Docker image build examples: + +```bash +docker build -f ros_bridge/Dockerfile.autoware -t autoware-ros-bridge ros_bridge +docker build -f ros_bridge/Dockerfile.nav2 -t nav2-ros-bridge ros_bridge +``` + +## Host Dependencies + +Install the minimum agent first, then add optional host dependencies for the features used on each device. + +- Common: Bun, Docker +- Remote desktop: KasmVNC, XFCE +- Camera: GStreamer, v4l-utils, python3-gi, websockets +- ROS bridge: ROS2 Humble and message packages for the active stack +- NVIDIA GPU containers: nvidia-container-toolkit or device-specific GPU runtime + +## Verify + +```bash +bun run typecheck +curl http://127.0.0.1:3100/health +curl http://127.0.0.1:3100/api/config +curl http://127.0.0.1:3100/api/kasmvnc/status +curl http://127.0.0.1:3100/api/bridge/status +``` diff --git a/agent.config.json b/agent.config.json new file mode 100644 index 0000000..862ab5a --- /dev/null +++ b/agent.config.json @@ -0,0 +1,102 @@ +{ + "server": { + "host": "0.0.0.0", + "port": 3100, + "publicBaseUrl": "http://127.0.0.1:3100" + }, + "security": { + "gatewayToken": "" + }, + "iframe": { + "title": "KasmVNC", + "allowOrigins": [ + "file:", + "app://platform-ide", + "http://localhost:*", + "http://127.0.0.1:*" + ] + }, + "desktop": { + "provider": "kasmvnc", + "display": ":1", + "webPort": 6901, + "protocol": "http", + "interface": "0.0.0.0", + "geometry": "1600x900", + "desktop": "XFCE" + }, + "camera": { + "enabled": false, + "autoStart": true, + "streamName": "platform-camera", + "signalingUrl": "ws://127.0.0.1:3000", + "roomId": "jetson-1", + "videoDevice": "/dev/video0", + "width": 1920, + "height": 1080, + "framerate": 30, + "bitrate": 4000000, + "senderCommand": "python3", + "senderArgs": [ + "scripts/jetson_webrtc_sender.py" + ], + "pipelineDesc": "" + }, + "workspace": { + "root": "~/.caches", + "maxFileSize": 10485760, + "editableExtensions": [ + ".bash", + ".c", + ".cc", + ".cfg", + ".cmake", + ".conf", + ".cpp", + ".css", + ".csv", + ".h", + ".hpp", + ".html", + ".ini", + ".js", + ".json", + ".jsx", + ".launch", + ".log", + ".lua", + ".md", + ".msg", + ".py", + ".rs", + ".rviz", + ".sh", + ".srv", + ".toml", + ".ts", + ".tsx", + ".txt", + ".urdf", + ".xml", + ".yaml", + ".yml" + ] + }, + "ros": { + "stack": "autoware", + "domainId": "0", + "bridgeHost": "127.0.0.1", + "bridgePort": 8765, + "bridgeUrl": "http://127.0.0.1:8765", + "autowareImage": "autoware-ros-bridge", + "nav2Image": "nav2-ros-bridge", + "routeInput": "rviz_topic", + "routePrefix": "/api/routing", + "mapPointStride": 8, + "mapPointLimit": 80000 + }, + "modules": { + "autowareRunScript": "docker/docker_run_autoware-universe:humble-20240301-cuda.sh", + "nav2RunScript": "docker/docker_run_future-nav2:humble.sh" + } +} diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..edcb335 --- /dev/null +++ b/bun.lock @@ -0,0 +1,176 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "platform-agent", + "dependencies": { + "@tomoxv/gstwebrtc-api": "^2.0.0", + "hono": "^4.10.7", + }, + "devDependencies": { + "@types/bun": "^1.3.2", + "typescript": "^5.9.3", + }, + }, + }, + "packages": { + "@tomoxv/gstwebrtc-api": ["@tomoxv/gstwebrtc-api@2.0.0", "", { "dependencies": { "patch-package": "8.0.0", "webrtc-adapter": "8.2.3" } }, "sha512-JWjKrwVyGWc1HpXGAOo6Nz7HpQOMSRragn2dJeq8oQKOJcZ6Vaq8KPmZTC9aFoouzVpFvlAdzrAhpAif7IGSug=="], + + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + + "@types/node": ["@types/node@25.9.0", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-AOQwYUNolgy3VosiRqXrACUXTN8nJUtPl7FJXMqZVyxiiCLhQuG3jXKvCS1ALr+Y2OmZhzzLVlYPEqJaiqkaJQ=="], + + "@yarnpkg/lockfile": ["@yarnpkg/lockfile@1.1.0", "", {}, "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ=="], + + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "at-least-node": ["at-least-node@1.0.0", "", {}, "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg=="], + + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], + + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + + "call-bind": ["call-bind@1.0.9", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" } }, "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + + "find-yarn-workspace-root": ["find-yarn-workspace-root@2.0.0", "", { "dependencies": { "micromatch": "^4.0.2" } }, "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ=="], + + "fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], + + "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + + "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="], + + "hono": ["hono@4.12.19", "", {}, "sha512-xa3eYXYXx68XTT4hZ7dRzsXBhaq85ToSrlUJNoR0gwz/1Ap/CNwX47wfvV7pc/xWhjKVVkLT7zBJy8chhNguqQ=="], + + "inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], + + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + + "is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], + + "isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "json-stable-stringify": ["json-stable-stringify@1.3.0", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "isarray": "^2.0.5", "jsonify": "^0.0.1", "object-keys": "^1.1.1" } }, "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg=="], + + "jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], + + "jsonify": ["jsonify@0.0.1", "", {}, "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg=="], + + "klaw-sync": ["klaw-sync@6.0.0", "", { "dependencies": { "graceful-fs": "^4.1.11" } }, "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + + "minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + + "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "open": ["open@7.4.2", "", { "dependencies": { "is-docker": "^2.0.0", "is-wsl": "^2.1.1" } }, "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q=="], + + "os-tmpdir": ["os-tmpdir@1.0.2", "", {}, "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g=="], + + "patch-package": ["patch-package@8.0.0", "", { "dependencies": { "@yarnpkg/lockfile": "^1.1.0", "chalk": "^4.1.2", "ci-info": "^3.7.0", "cross-spawn": "^7.0.3", "find-yarn-workspace-root": "^2.0.0", "fs-extra": "^9.0.0", "json-stable-stringify": "^1.0.2", "klaw-sync": "^6.0.0", "minimist": "^1.2.6", "open": "^7.4.2", "rimraf": "^2.6.3", "semver": "^7.5.3", "slash": "^2.0.0", "tmp": "^0.0.33", "yaml": "^2.2.2" }, "bin": { "patch-package": "index.js" } }, "sha512-da8BVIhzjtgScwDJ2TtKsfT5JFWz1hYoBl9rUQ1f38MC2HwnEIkK8VN3dKMKcP7P7bvvgzNDbfNHtx3MsQb5vA=="], + + "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + + "rimraf": ["rimraf@2.7.1", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "./bin.js" } }, "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w=="], + + "sdp": ["sdp@3.2.2", "", {}, "sha512-xZocWwfyp4hkbN4hLWxMjmv2Q8aNa9MhmOZ7L9aCZPT+dZsgRr6wZRrSYE3HTdyk/2pZKPSgqI7ns7Een1xMSA=="], + + "semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="], + + "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "slash": ["slash@2.0.0", "", {}, "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A=="], + + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + + "tmp": ["tmp@0.0.33", "", { "dependencies": { "os-tmpdir": "~1.0.2" } }, "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw=="], + + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + + "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], + + "webrtc-adapter": ["webrtc-adapter@8.2.3", "", { "dependencies": { "sdp": "^3.2.0" } }, "sha512-gnmRz++suzmvxtp3ehQts6s2JtAGPuDPjA1F3a9ckNpG1kYdYuHWYpazoAnL9FS5/B21tKlhkorbdCXat0+4xQ=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..59b24fb --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,28 @@ +services: + platform-agent: + build: + context: . + dockerfile: Dockerfile + image: platform-agent:latest + container_name: platform-agent + restart: unless-stopped + network_mode: host + privileged: true + environment: + HOST: 0.0.0.0 + PORT: 3100 + AGENT_CONFIG: /app/agent.config.json + WORKSPACE_ROOT: /workspace + ROS_BRIDGE_URL: ${ROS_BRIDGE_URL:-http://127.0.0.1:8765} + ROS_DOMAIN_ID: ${ROS_DOMAIN_ID:-0} + ROBOT_STACK: ${ROBOT_STACK:-autoware} + CAMERA_ENABLED: ${CAMERA_ENABLED:-false} + CAMERA_SIGNALING_URL: ${CAMERA_SIGNALING_URL:-ws://127.0.0.1:3000} + CAMERA_ROOM_ID: ${CAMERA_ROOM_ID:-jetson-1} + CAMERA_VIDEO_DEVICE: ${CAMERA_VIDEO_DEVICE:-/dev/video0} + volumes: + - ${PLATFORM_WORKSPACE_ROOT:-/home/ubuntu/.caches}:/workspace:rw + - /dev:/dev + - /tmp:/tmp + devices: + - ${CAMERA_VIDEO_DEVICE:-/dev/video0}:${CAMERA_VIDEO_DEVICE:-/dev/video0} diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..e613b20 --- /dev/null +++ b/install.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env sh +set -eu + +# Public base URL containing platform-agent-linux-amd64.tar.gz and +# platform-agent-linux-arm64.tar.gz. Keep this bootstrap free of secrets. +RELEASE_BASE_URL="${PLATFORM_AGENT_RELEASE_BASE_URL:-https://pub-ca72c40797144462bdb7d200c215d361.r2.dev/studio-files/public/platform-agent}" + +case "$(uname -m 2>/dev/null || true)" in + x86_64|amd64) ARCH="amd64" ;; + aarch64|arm64) ARCH="arm64" ;; + *) + printf 'Unsupported CPU architecture: %s\n' "$(uname -m 2>/dev/null || printf unknown)" >&2 + exit 1 + ;; +esac + +if command -v curl >/dev/null 2>&1; then + download() { curl -fsSL "$1" -o "$2"; } +elif command -v wget >/dev/null 2>&1; then + download() { wget -qO "$2" "$1"; } +else + printf '%s\n' 'curl or wget is required to install Platform Agent.' >&2 + exit 1 +fi + +TMP_DIR="$(mktemp -d)" +cleanup() { rm -rf "$TMP_DIR"; } +trap cleanup EXIT INT TERM + +ARCHIVE_URL="${RELEASE_BASE_URL%/}/platform-agent-linux-${ARCH}.tar.gz" +ARCHIVE_PATH="$TMP_DIR/platform-agent.tar.gz" + +printf 'Downloading Platform Agent for linux-%s...\n' "$ARCH" +download "$ARCHIVE_URL" "$ARCHIVE_PATH" +tar -xzf "$ARCHIVE_PATH" -C "$TMP_DIR" + +PACKAGE_DIR="$TMP_DIR/platform-agent" +if [ ! -x "$PACKAGE_DIR/scripts/install.sh" ]; then + PACKAGE_DIR="$(find "$TMP_DIR" -type f -path '*/scripts/install.sh' -print -quit | sed 's#/scripts/install.sh##')" +fi + +if [ -z "${PACKAGE_DIR:-}" ] || [ ! -x "$PACKAGE_DIR/scripts/install.sh" ]; then + printf '%s\n' 'Downloaded archive does not contain scripts/install.sh.' >&2 + exit 1 +fi + +exec "$PACKAGE_DIR/scripts/install.sh" diff --git a/package.json b/package.json new file mode 100644 index 0000000..6685638 --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "name": "platform-agent", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "bun --watch src/server.tsx", + "start": "bun src/server.tsx", + "build": "bun build src/server.tsx --outdir dist --target bun", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@tomoxv/gstwebrtc-api": "^2.0.0", + "hono": "^4.10.7" + }, + "devDependencies": { + "@types/bun": "^1.3.2", + "typescript": "^5.9.3" + } +} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..3bbfc46 --- /dev/null +++ b/public/app.js @@ -0,0 +1,107 @@ +const form = document.querySelector("[data-config-form]"); +const statusGrid = document.querySelector("[data-status-grid]"); +const message = document.querySelector("[data-message]"); +const refreshButton = document.querySelector("[data-refresh]"); + +function getPath(object, path) { + return path.split(".").reduce((current, key) => current?.[key], object); +} + +function setPath(object, path, value) { + const parts = path.split("."); + let cursor = object; + for (const part of parts.slice(0, -1)) { + cursor[part] ??= {}; + cursor = cursor[part]; + } + cursor[parts.at(-1)] = value; +} + +function coerceInputValue(input) { + if (input.type === "checkbox") return input.checked; + if (input.type === "number") return Number(input.value); + return input.value.trim(); +} + +function renderStatus(config, bridge, kasm, camera) { + const items = [ + ["Agent", config?.server?.publicBaseUrl ?? "online"], + ["Workspace", config?.workspace?.root ?? "-"], + ["ROS", `${config?.ros?.stack ?? "-"} · ${bridge?.stack?.stack ?? bridge?.health?.stack ?? "offline"}`], + ["KasmVNC", kasm?.running ? `running · ${kasm.url}` : kasm?.installed ? "stopped" : "not installed"], + ["Camera", camera?.enabled ? `${camera.videoDevice} · enabled` : "disabled"], + ]; + + statusGrid.innerHTML = items.map(([label, value]) => ` +
+ ${label} + ${value} +
+ `).join(""); +} + +function fillForm(config) { + for (const input of form.elements) { + if (!input.name) continue; + const value = getPath(config, input.name); + if (input.type === "checkbox") { + input.checked = Boolean(value); + } else if (value !== undefined) { + input.value = String(value); + } + } +} + +function readFormPatch() { + const patch = {}; + for (const input of form.elements) { + if (!input.name) continue; + setPath(patch, input.name, coerceInputValue(input)); + } + return patch; +} + +async function json(path, init) { + const response = await fetch(path, init); + const data = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(data.message || data.detail || `HTTP ${response.status}`); + return data; +} + +async function refresh() { + message.textContent = "Loading..."; + const [config, bridge, kasm, camera] = await Promise.all([ + json("/api/config"), + json("/api/bridge/status").catch((error) => ({ ok: false, health: { error: error.message } })), + json("/api/kasmvnc/status").catch((error) => ({ ok: false, message: error.message })), + json("/api/camera/status").catch((error) => ({ enabled: false, error: error.message })), + ]); + fillForm(config); + renderStatus(config, bridge, kasm, camera); + message.textContent = ""; +} + +form?.addEventListener("submit", async (event) => { + event.preventDefault(); + message.textContent = "Saving..."; + try { + const config = await json("/api/config", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(readFormPatch()), + }); + fillForm(config); + await refresh(); + message.textContent = "Saved."; + } catch (error) { + message.textContent = error instanceof Error ? error.message : "Save failed."; + } +}); + +refreshButton?.addEventListener("click", () => refresh().catch((error) => { + message.textContent = error instanceof Error ? error.message : "Refresh failed."; +})); + +refresh().catch((error) => { + message.textContent = error instanceof Error ? error.message : "Load failed."; +}); diff --git a/public/camera.js b/public/camera.js new file mode 100644 index 0000000..d66f884 --- /dev/null +++ b/public/camera.js @@ -0,0 +1,190 @@ +const state = document.querySelector("[data-state]"); +const video = document.querySelector("[data-video]"); +const image = document.querySelector("[data-mjpeg]"); +const logs = document.querySelector("[data-logs]"); +const modeSelect = document.querySelector("[data-mode]"); +const startButton = document.querySelector("[data-start]"); +const stopButton = document.querySelector("[data-stop]"); + +let ws = null; +let pc = null; +let statsTimer = null; + +function setState(message) { + if (state) state.textContent = message; +} + +function renderLogs(items = []) { + if (!logs) return; + logs.textContent = items.slice(-30).join("\n"); + logs.scrollTop = logs.scrollHeight; +} + +async function getStatus() { + const response = await fetch("/api/camera/status"); + const status = await response.json(); + renderLogs(status.logs); + return status; +} + +function normalizeOfferDescription(description) { + if (description.type !== "offer" || !description.sdp || description.sdp.includes("a=msid:")) return description; + + const lines = description.sdp.replace(/\r\n/g, "\n").split("\n"); + const normalized = []; + let hasMsidSemantic = false; + let insertedVideoMsid = false; + + for (const line of lines) { + if (line.startsWith("a=msid-semantic:")) hasMsidSemantic = true; + if (line.startsWith("m=video") && !hasMsidSemantic) { + normalized.push("a=msid-semantic: WMS jetson-camera"); + hasMsidSemantic = true; + } + normalized.push(line); + if (!insertedVideoMsid && line.startsWith("a=mid:")) { + normalized.push("a=msid:jetson-camera video0"); + insertedVideoMsid = true; + } + } + + return { ...description, sdp: normalized.join("\r\n") }; +} + +function stopViewerOnly() { + if (statsTimer) window.clearInterval(statsTimer); + statsTimer = null; + if (image) image.removeAttribute("src"); + if (video) video.srcObject = null; + if (ws && ws.readyState === WebSocket.OPEN) ws.close(); + if (ws && ws.readyState === WebSocket.CONNECTING) ws.onopen = () => ws.close(); + if (pc) pc.close(); + ws = null; + pc = null; +} + +function showMjpeg(status) { + if (!image || !video) return; + video.style.display = "none"; + image.style.display = "block"; + const params = new URLSearchParams({ + device: status.videoDevice || "/dev/video0", + width: String(status.width || 1920), + height: String(status.height || 1080), + framerate: String(status.framerate || 30), + t: String(Date.now()), + }); + image.src = `/api/camera/mjpeg?${params.toString()}`; + image.onload = () => setState("MJPEG streaming"); + image.onerror = () => setState("MJPEG stream failed"); +} + +async function startWebRtc(status) { + if (!video || !image) return; + image.style.display = "none"; + video.style.display = "block"; + + pc = new RTCPeerConnection(); + ws = new WebSocket(status.signalingUrl); + + pc.ontrack = (event) => { + const stream = event.streams[0]; + console.debug("[camera-web] track received", event.track.kind, event.track.readyState, event.track.muted); + if (!stream) return; + video.srcObject = stream; + video.play().catch((error) => console.error("[camera-web] video play failed", error)); + setState("WebRTC streaming"); + }; + + pc.onicecandidate = (event) => { + if (!event.candidate || !ws || ws.readyState !== WebSocket.OPEN) return; + ws.send(JSON.stringify({ + type: "candidate", + roomId: status.roomId || "jetson-1", + role: "viewer", + candidate: event.candidate, + })); + }; + + pc.onconnectionstatechange = () => { + console.debug("[camera-web] connection state", pc.connectionState); + if (pc.connectionState === "connected") setState("WebRTC connected"); + if (pc.connectionState === "failed" || pc.connectionState === "disconnected") setState("WebRTC failed"); + }; + + ws.onmessage = async (event) => { + const data = JSON.parse(event.data); + if (data.type === "offer") { + console.debug("[camera-web] offer received"); + await pc.setRemoteDescription(normalizeOfferDescription(data.offer)); + const answer = await pc.createAnswer(); + await pc.setLocalDescription(answer); + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ + type: "answer", + roomId: status.roomId || "jetson-1", + role: "viewer", + answer, + })); + } + } + if (data.type === "candidate" && data.candidate) { + await pc.addIceCandidate(data.candidate); + } + }; + + await new Promise((resolve, reject) => { + ws.onopen = () => { + ws.send(JSON.stringify({ type: "join", roomId: status.roomId || "jetson-1", role: "viewer" })); + resolve(); + }; + ws.onerror = () => reject(new Error("signaling connection failed")); + }); + + setState("starting WebRTC sender"); + await fetch("/api/camera/start", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ roomId: status.roomId || "jetson-1" }), + }); + setState("waiting for WebRTC offer"); +} + +async function startCamera() { + stopViewerOnly(); + setState("starting"); + await fetch("/api/camera/stop", { method: "POST" }).catch(() => undefined); + const status = await getStatus(); + if (!status.enabled) { + setState("camera disabled"); + return; + } + + const mode = modeSelect?.value || "mjpeg"; + if (mode === "webrtc") { + await startWebRtc(status); + } else { + setState("starting MJPEG stream"); + showMjpeg(status); + } +} + +async function stopCamera() { + stopViewerOnly(); + await fetch("/api/camera/stop", { method: "POST" }).catch(() => undefined); + setState("stopped"); + await getStatus(); +} + +startButton?.addEventListener("click", () => startCamera().catch((error) => setState(error.message))); +stopButton?.addEventListener("click", () => stopCamera().catch((error) => setState(error.message))); +modeSelect?.addEventListener("change", () => startCamera().catch((error) => setState(error.message))); + +getStatus().then((status) => { + if (!status.enabled) setState("camera disabled"); + else startCamera().catch((error) => setState(error.message)); +}); + +setInterval(() => { + getStatus().catch(() => undefined); +}, 3000); diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..587e414 --- /dev/null +++ b/public/styles.css @@ -0,0 +1,308 @@ +:root { + color-scheme: light dark; + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: #05070a; + color: #f8fafc; +} + +* { + box-sizing: border-box; +} + +html, +body { + height: 100%; + margin: 0; + background: #05070a; +} + +body { + overflow: hidden; + background: #05070a; +} + +body:has(.admin-shell) { + overflow: auto; +} + +a { + color: inherit; + text-decoration: none; +} + +.shell { + height: 100vh; + min-width: 320px; + background: #05070a; +} + +iframe { + display: block; + width: 100%; + height: 100%; + border: 0; + background: #05070a; + color-scheme: dark; +} + +.setup { + display: grid; + min-height: 100%; + place-content: center; + gap: 10px; + padding: 24px; + text-align: center; +} + +.setup h1 { + margin: 0; + font-size: 18px; +} + +.setup p { + margin: 0; + color: #cbd5e1; + font-size: 14px; +} + +.setup a { + justify-self: center; + padding: 8px 12px; + border: 1px solid rgba(148, 163, 184, 0.32); + border-radius: 6px; + background: #1f2937; +} + +.camera-shell { + display: grid; + grid-template-rows: minmax(0, 1fr) auto; + height: 100vh; + background: #020617; +} + +.camera-shell video, +.camera-shell img { + width: 100%; + height: 100%; + object-fit: contain; + background: #000; +} + +.camera-shell video { + display: none; +} + +.camera-panel { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px 12px; + align-items: center; + padding: 8px 10px; + border-top: 1px solid rgba(148, 163, 184, 0.22); + background: #111827; +} + +.camera-panel strong { + overflow: hidden; + font-size: 13px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.camera-panel button { + min-height: 30px; + padding: 6px 10px; + border: 1px solid rgba(148, 163, 184, 0.35); + border-radius: 6px; + background: #1f2937; + color: #f8fafc; +} + +.camera-panel pre { + grid-column: 1 / -1; + max-height: 96px; + margin: 0; + overflow: auto; + color: #cbd5e1; + font-size: 11px; + white-space: pre-wrap; +} + +.admin-shell { + min-height: 100vh; + padding: 24px; + background: #0b0d10; + color: #eef2f7; +} + +.admin-header { + display: flex; + gap: 16px; + align-items: flex-start; + justify-content: space-between; + max-width: 1180px; + margin: 0 auto 18px; +} + +.admin-header h1 { + margin: 0; + font-size: 22px; + letter-spacing: 0; +} + +.admin-header p { + max-width: 760px; + margin: 6px 0 0; + color: rgba(238, 242, 247, 0.58); + font-size: 13px; + line-height: 1.5; +} + +.admin-header nav { + display: flex; + gap: 8px; +} + +.admin-header nav a, +.config-form button { + min-height: 32px; + padding: 0 12px; + border: 1px solid rgba(148, 163, 184, 0.22); + border-radius: 6px; + background: #1a2029; + color: #eef2f7; + font-size: 12px; + font-weight: 700; +} + +.status-grid, +.config-form { + max-width: 1180px; + margin: 0 auto; +} + +.status-grid { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 8px; + margin-bottom: 14px; +} + +.status-grid article, +.config-form section { + border: 1px solid rgba(148, 163, 184, 0.16); + border-radius: 6px; + background: #13171e; +} + +.status-grid article { + min-height: 70px; + padding: 12px; +} + +.status-grid span { + display: block; + margin-bottom: 7px; + color: rgba(238, 242, 247, 0.42); + font-size: 11px; + font-weight: 800; + text-transform: uppercase; +} + +.status-grid strong { + display: block; + overflow-wrap: anywhere; + color: rgba(238, 242, 247, 0.86); + font-size: 13px; + line-height: 1.35; +} + +.config-form { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + padding-bottom: 28px; +} + +.config-form section { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + align-content: start; + padding: 14px; +} + +.config-form h2 { + grid-column: 1 / -1; + margin: 0 0 2px; + color: rgba(238, 242, 247, 0.84); + font-size: 14px; +} + +.config-form label { + display: grid; + gap: 5px; + min-width: 0; + color: rgba(238, 242, 247, 0.55); + font-size: 11px; + font-weight: 750; +} + +.config-form label.inline { + display: flex; + align-items: center; + gap: 8px; +} + +.config-form input, +.config-form select { + min-width: 0; + height: 32px; + border: 1px solid rgba(148, 163, 184, 0.18); + border-radius: 5px; + background: #0d1117; + color: rgba(238, 242, 247, 0.88); + padding: 0 9px; + font: inherit; +} + +.config-form input[type="checkbox"] { + width: 16px; + height: 16px; + padding: 0; +} + +.config-form footer { + position: sticky; + bottom: 0; + grid-column: 1 / -1; + display: flex; + gap: 8px; + align-items: center; + justify-content: flex-end; + padding: 10px 0 0; + background: #0b0d10; +} + +.config-form footer span { + min-width: 180px; + color: rgba(238, 242, 247, 0.58); + font-size: 12px; +} + +@media (max-width: 920px) { + .admin-shell { + padding: 14px; + } + + .admin-header { + flex-direction: column; + } + + .status-grid, + .config-form, + .config-form section { + grid-template-columns: 1fr; + } +} diff --git a/ros_bridge/Dockerfile.autoware b/ros_bridge/Dockerfile.autoware new file mode 100644 index 0000000..019e6d0 --- /dev/null +++ b/ros_bridge/Dockerfile.autoware @@ -0,0 +1,23 @@ +FROM ghcr.io/autowarefoundation/autoware-universe:humble-20240301-cuda + +WORKDIR /ros_bridge + +RUN curl -fsSL https://raw.githubusercontent.com/ros/rosdistro/master/ros.key \ + -o /usr/share/keyrings/ros-archive-keyring.gpg \ + && printf "deb [arch=%s signed-by=/usr/share/keyrings/ros-archive-keyring.gpg] http://packages.ros.org/ros2/ubuntu %s main\n" \ + "$(dpkg --print-architecture)" "$(. /etc/os-release && echo "$UBUNTU_CODENAME")" \ + > /etc/apt/sources.list.d/ros2.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + ros-humble-autoware-adapi-v1-msgs \ + ros-humble-autoware-planning-msgs \ + ros-humble-autoware-vehicle-msgs \ + ros-humble-nav2-msgs \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt /ros_bridge/requirements.txt +RUN pip3 install --no-cache-dir -r /ros_bridge/requirements.txt + +COPY bridge.py /ros_bridge/bridge.py + +CMD bash -c "source /opt/ros/humble/setup.bash && if [ -f /autoware_ws/install/setup.bash ]; then source /autoware_ws/install/setup.bash; fi && python3 /ros_bridge/bridge.py" diff --git a/ros_bridge/Dockerfile.nav2 b/ros_bridge/Dockerfile.nav2 new file mode 100644 index 0000000..1396aa0 --- /dev/null +++ b/ros_bridge/Dockerfile.nav2 @@ -0,0 +1,11 @@ +FROM future-nav2:humble-jetson_orin + +WORKDIR /ros_bridge + +COPY requirements.txt . +RUN pip3 install --no-cache-dir -r requirements.txt + +COPY bridge.py . + +CMD ["python3", "/ros_bridge/bridge.py"] + diff --git a/ros_bridge/bridge.py b/ros_bridge/bridge.py new file mode 100644 index 0000000..5f6cc9e --- /dev/null +++ b/ros_bridge/bridge.py @@ -0,0 +1,1963 @@ +#!/usr/bin/env python3 +""" +ROS2 bridge for Autoware/Nav2 web backend. + +- /get_behavior_path : long predicted path +- /get_trajectory : short control trajectory +- /get_kinematic_state : current vehicle state +""" + +import asyncio +import logging +import math +import os +import threading +import time +import traceback +from contextlib import asynccontextmanager +from typing import Optional + +logging.basicConfig(level=logging.INFO) + +import rclpy +from rclpy.node import Node +from rclpy.executors import SingleThreadedExecutor +from rclpy.qos import QoSProfile, ReliabilityPolicy, DurabilityPolicy +from rclpy.action import ActionClient + +from geometry_msgs.msg import PoseStamped, PoseWithCovarianceStamped +from lifecycle_msgs.srv import GetState +from nav_msgs.msg import OccupancyGrid, Odometry, Path as NavPath +from nav_msgs.srv import GetMap +from sensor_msgs.msg import PointCloud2 +from visualization_msgs.msg import MarkerArray +import sensor_msgs_py.point_cloud2 as point_cloud2 + +try: + from nav2_msgs.action import ComputePathToPose, NavigateToPose + HAS_NAV2 = True +except ImportError: + ComputePathToPose = None + NavigateToPose = None + HAS_NAV2 = False + +try: + from autoware_adapi_v1_msgs.srv import SetRoutePoints, ClearRoute + HAS_ROUTING = True +except ImportError: + SetRoutePoints = None + ClearRoute = None + HAS_ROUTING = False + +try: + from autoware_auto_planning_msgs.msg import Trajectory, Path + HAS_PLANNING = True +except ImportError: + try: + from autoware_planning_msgs.msg import Trajectory, Path + HAS_PLANNING = True + except ImportError: + Trajectory = object + Path = object + HAS_PLANNING = False + +try: + from autoware_adapi_v1_msgs.srv import ChangeOperationMode + from autoware_adapi_v1_msgs.msg import OperationModeState + USE_OPERATION_MODE = True + HAS_OPERATION = True +except ImportError: + try: + from tier4_external_api_msgs.srv import Engage as EngageSrv + USE_OPERATION_MODE = False + HAS_OPERATION = True + except ImportError: + ChangeOperationMode = None + OperationModeState = None + EngageSrv = None + USE_OPERATION_MODE = False + HAS_OPERATION = False + +try: + from autoware_auto_vehicle_msgs.msg import SteeringReport + HAS_STEERING = True +except ImportError: + try: + from autoware_vehicle_msgs.msg import SteeringReport + HAS_STEERING = True + except ImportError: + HAS_STEERING = False + +try: + from autoware_auto_vehicle_msgs.msg import TurnIndicatorsReport + HAS_TURN = True +except ImportError: + try: + from autoware_vehicle_msgs.msg import TurnIndicatorsReport + HAS_TURN = True + except ImportError: + HAS_TURN = False + +from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel +import uvicorn + +# Coordinate conversion constants (must match backend .env CENTER_X/CENTER_Y) +_CENTER_X = float(os.environ.get("CENTER_X", "3763.558837890625")) +_CENTER_Y = float(os.environ.get("CENTER_Y", "73675.99609375")) +_MAP_POINT_STRIDE = max(1, int(os.environ.get("MAP_POINT_STRIDE", "8"))) +_MAP_POINT_LIMIT = max(1000, int(os.environ.get("MAP_POINT_LIMIT", "80000"))) +_ROBOT_STACK = os.environ.get("ROBOT_STACK", "autoware").strip().lower() +_AUTOWARE_ROUTE_PREFIX = os.environ.get( + "AUTOWARE_ROUTE_PREFIX", + "/api/routing", +).rstrip("/") +_AUTOWARE_ROUTE_INPUT = os.environ.get("AUTOWARE_ROUTE_INPUT", "rviz_topic").strip().lower() +if _ROBOT_STACK not in ("autoware", "nav2"): + logging.warning("Unsupported ROBOT_STACK=%s; falling back to autoware", _ROBOT_STACK) + _ROBOT_STACK = "autoware" +if _AUTOWARE_ROUTE_INPUT not in ("rviz_topic", "service"): + logging.warning("Unsupported AUTOWARE_ROUTE_INPUT=%s; falling back to rviz_topic", _AUTOWARE_ROUTE_INPUT) + _AUTOWARE_ROUTE_INPUT = "rviz_topic" + + +def _map_to_three(mx: float, my: float, mz: float) -> dict: + return {"x": mx - _CENTER_X, "y": mz, "z": -(my - _CENTER_Y)} + + +def _center_from_bounds(x_min: float, x_max: float, y_min: float, y_max: float) -> dict: + if not all(math.isfinite(v) for v in (x_min, x_max, y_min, y_max)): + return {"x": _CENTER_X, "y": _CENTER_Y} + return {"x": (x_min + x_max) / 2.0, "y": (y_min + y_max) / 2.0} + + +def _quat_to_three_heading(qx: float, qy: float, qz: float, qw: float) -> float: + """ROS2 quaternion → Three.js heading in radians (0=north, CW+).""" + yaw = math.atan2(2.0 * (qw * qz + qx * qy), 1.0 - 2.0 * (qy * qy + qz * qz)) + return math.pi / 2.0 - yaw + + +_svc_lock = threading.Lock() + + +class SubscriberNode(Node): + def __init__(self): + super().__init__(f"{_ROBOT_STACK}_web_bridge") + + best_effort_qos = QoSProfile( + depth=1, + reliability=ReliabilityPolicy.BEST_EFFORT, + durability=DurabilityPolicy.VOLATILE, + ) + + reliable_qos = QoSProfile( + depth=1, + reliability=ReliabilityPolicy.RELIABLE, + durability=DurabilityPolicy.VOLATILE, + ) + + map_qos = QoSProfile( + depth=1, + reliability=ReliabilityPolicy.RELIABLE, + durability=DurabilityPolicy.TRANSIENT_LOCAL, + ) + volatile_map_qos = QoSProfile( + depth=1, + reliability=ReliabilityPolicy.RELIABLE, + durability=DurabilityPolicy.VOLATILE, + ) + best_effort_map_qos = QoSProfile( + depth=1, + reliability=ReliabilityPolicy.BEST_EFFORT, + durability=DurabilityPolicy.VOLATILE, + ) + + self._pointcloud_map_payload = None + self._pointcloud_map_revision = 0 + self._pointcloud_map_lock = threading.Lock() + + self._vector_map_payload = None + self._vector_map_revision = 0 + self._vector_map_lock = threading.Lock() + + self._occupancy_map_payload = None + self._occupancy_map_revision = 0 + self._occupancy_map_lock = threading.Lock() + self._subscriptions = [] + + self._nav2_path_payload = None + self._nav2_path_revision = 0 + self._nav2_path_lock = threading.Lock() + + self._nav2_pose_payload = None + self._nav2_pose_revision = 0 + self._nav2_pose_lock = threading.Lock() + + if _ROBOT_STACK == "autoware": + self._subscriptions.append(self.create_subscription( + PointCloud2, + "/map/pointcloud_map", + self._on_pointcloud_map, + map_qos, + )) + self._subscriptions.append(self.create_subscription( + MarkerArray, + "/map/vector_map_marker", + self._on_vector_map_marker, + map_qos, + )) + else: + self._subscriptions.append(self.create_subscription( + OccupancyGrid, + "/map", + self._on_occupancy_map, + map_qos, + )) + self._subscriptions.append(self.create_subscription( + OccupancyGrid, + "/map", + self._on_occupancy_map, + volatile_map_qos, + )) + self._subscriptions.append(self.create_subscription( + OccupancyGrid, + "/map", + self._on_occupancy_map, + best_effort_map_qos, + )) + + self.initial_pose_pub = self.create_publisher( + PoseWithCovarianceStamped, + "/initialpose", + 10, + ) + self.rviz_rough_goal_pub = self.create_publisher( + PoseStamped, + "/rviz/routing/rough_goal", + 10, + ) + + # Short control trajectory / long predicted path + self._trajectory = None + self._trajectory_event = threading.Event() + self._behavior_path = None + self._behavior_path_event = threading.Event() + if _ROBOT_STACK == "autoware" and HAS_PLANNING: + self._subscriptions.append(self.create_subscription( + Trajectory, + "/planning/scenario_planning/trajectory", + self._on_trajectory, + best_effort_qos, + )) + self._subscriptions.append(self.create_subscription( + Path, + "/planning/scenario_planning/lane_driving/behavior_planning/path", + self._on_behavior_path, + best_effort_qos, + )) + elif _ROBOT_STACK == "nav2": + self._subscriptions.append(self.create_subscription( + NavPath, + "/plan", + self._on_nav2_plan, + reliable_qos, + )) + + # Current vehicle state + self._kinematic_state: Optional[Odometry] = None + self._kinematic_lock = threading.Lock() + if _ROBOT_STACK == "autoware": + self._subscriptions.append(self.create_subscription( + Odometry, + "/localization/kinematic_state", + self._on_kinematic_state, + reliable_qos, + )) + else: + self._subscriptions.append(self.create_subscription( + PoseWithCovarianceStamped, + "/amcl_pose", + self._on_amcl_pose, + reliable_qos, + )) + self._subscriptions.append(self.create_subscription( + Odometry, + "/odom", + self._on_odom, + reliable_qos, + )) + self._nav2_goal_active = False + self._pending_nav2_goal = None + + # Steering angle + self._steering_angle: float = 0.0 + self._steering_lock = threading.Lock() + if _ROBOT_STACK == "autoware" and HAS_STEERING: + self._subscriptions.append(self.create_subscription( + SteeringReport, + "/vehicle/status/steering_status", + self._on_steering, + best_effort_qos, + )) + + # operation mode (1=STOP, 2=AUTONOMOUS, 3=LOCAL, 4=REMOTE) + self._operation_mode: int = 0 + self._operation_mode_lock = threading.Lock() + if _ROBOT_STACK == "autoware" and USE_OPERATION_MODE and HAS_OPERATION: + self._subscriptions.append(self.create_subscription( + OperationModeState, + "/api/operation_mode/state", + self._on_operation_mode, + reliable_qos, + )) + + # Turn indicators (1=off, 2=left, 3=right) + self._turn_indicator: int = 1 + self._turn_lock = threading.Lock() + if _ROBOT_STACK == "autoware" and HAS_TURN: + self._subscriptions.append(self.create_subscription( + TurnIndicatorsReport, + "/vehicle/status/turn_indicators_status", + self._on_turn, + best_effort_qos, + )) + + # Service clients + self._svc_lock = threading.Lock() + self._cli_clear_route = None + self._cli_set_route = None + self._cli_enable_ctrl = None + self._cli_autonomous = None + self._cli_stop = None + self._cli_engage = None + + self._nav2_action_client = None + self._nav2_compute_path_client = None + self._nav2_goal_handle = None + self._nav2_get_map_client = None + self._nav2_lifecycle_clients = [] + + if _ROBOT_STACK == "autoware" and HAS_ROUTING: + self._cli_clear_route = self.create_client( + ClearRoute, + f"{_AUTOWARE_ROUTE_PREFIX}/clear_route", + ) + self._cli_set_route = self.create_client( + SetRoutePoints, + f"{_AUTOWARE_ROUTE_PREFIX}/set_route_points", + ) + + if _ROBOT_STACK == "autoware" and USE_OPERATION_MODE and HAS_OPERATION: + self._cli_enable_ctrl = self.create_client( + ChangeOperationMode, + "/api/operation_mode/enable_autoware_control", + ) + self._cli_autonomous = self.create_client( + ChangeOperationMode, + "/api/operation_mode/change_to_autonomous", + ) + self._cli_stop = self.create_client( + ChangeOperationMode, + "/api/operation_mode/change_to_stop", + ) + elif _ROBOT_STACK == "autoware" and HAS_OPERATION: + self._cli_engage = self.create_client( + EngageSrv, + "/api/operation/engage", + ) + elif _ROBOT_STACK == "nav2" and HAS_NAV2: + self._nav2_action_client = ActionClient( + self, + NavigateToPose, + "/navigate_to_pose", + ) + self._nav2_compute_path_client = ActionClient( + self, + ComputePathToPose, + "/compute_path_to_pose", + ) + self._nav2_get_map_client = self.create_client( + GetMap, + "/map_server/map", + ) + self._nav2_lifecycle_clients = [ + self.create_client(GetState, "/bt_navigator/get_state"), + self.create_client(GetState, "/planner_server/get_state"), + ] + + self.get_logger().info(f"SubscriberNode started stack={_ROBOT_STACK}") + if _ROBOT_STACK == "nav2": + self._start_nav2_map_service_fetcher() + + def _on_trajectory(self, msg: Trajectory): + if len(msg.points) > 0: + self._trajectory = msg + self._trajectory_event.set() + + def _on_behavior_path(self, msg: Path): + if len(msg.points) > 0: + self._behavior_path = msg + self._behavior_path_event.set() + + def _on_nav2_plan(self, msg: NavPath): + if len(msg.poses) > 0: + self._behavior_path = msg + self._trajectory = msg + self._behavior_path_event.set() + self._trajectory_event.set() + with self._nav2_path_lock: + self._nav2_path_payload = self._nav_path_to_payload(msg) + self._nav2_path_revision += 1 + + def _on_kinematic_state(self, msg: Odometry): + with self._kinematic_lock: + self._kinematic_state = msg + + def _on_amcl_pose(self, msg: PoseWithCovarianceStamped): + odom = Odometry() + odom.header = msg.header + odom.pose = msg.pose + with self._kinematic_lock: + current = self._kinematic_state + if current is not None: + odom.twist = current.twist + self._kinematic_state = odom + with self._nav2_pose_lock: + self._nav2_pose_payload = self._pose_to_nav2_payload(msg.pose.pose, msg.header) + self._nav2_pose_revision += 1 + + def _on_odom(self, msg: Odometry): + with self._kinematic_lock: + if self._kinematic_state is None: + self._kinematic_state = msg + else: + self._kinematic_state.twist = msg.twist + + def _on_steering(self, msg): + with self._steering_lock: + self._steering_angle = msg.steering_tire_angle + + def get_steering_angle(self) -> float: + with self._steering_lock: + return self._steering_angle + + def _on_turn(self, msg): + with self._turn_lock: + self._turn_indicator = msg.report + + def get_turn_indicator(self) -> int: + with self._turn_lock: + return self._turn_indicator + + def _on_operation_mode(self, msg): + with self._operation_mode_lock: + self._operation_mode = msg.mode + + def _start_nav2_map_service_fetcher(self): + def worker(): + while rclpy.ok(): + try: + with self._occupancy_map_lock: + if self._occupancy_map_payload is not None: + return + if self.fetch_nav2_map_from_service(timeout=15.0): + return + except Exception as e: + self.get_logger().warning(f"map service fetch skipped: {e}") + time.sleep(2.0) + + threading.Thread(target=worker, daemon=True).start() + + def fetch_nav2_map_from_service(self, timeout=15.0) -> bool: + client = self._nav2_get_map_client + if client is None: + return False + if not client.wait_for_service(timeout_sec=timeout): + raise RuntimeError("/map_server/map service not available") + + future = client.call_async(GetMap.Request()) + start = time.time() + while not future.done(): + if time.time() - start > timeout: + raise RuntimeError("/map_server/map call timed out") + time.sleep(0.05) + + result = future.result() + if result is None: + raise RuntimeError("/map_server/map returned no result") + + self._on_occupancy_map(result.map) + self.get_logger().info( + f"occupancy map fetched via /map_server/map: {result.map.info.width}x{result.map.info.height}" + ) + return True + + def get_operation_mode(self) -> int: + if _ROBOT_STACK == "nav2": + return 2 if getattr(self, "_nav2_goal_active", False) else 1 + with self._operation_mode_lock: + return self._operation_mode + + def is_autonomous(self) -> bool: + if _ROBOT_STACK == "nav2": + return True + return self.get_operation_mode() == 2 + + def set_initial_pose(self, x, y, z, qx, qy, qz, qw): + msg = PoseWithCovarianceStamped() + msg.header.frame_id = "map" + msg.header.stamp = self.get_clock().now().to_msg() + msg.pose.pose.position.x = x + msg.pose.pose.position.y = y + msg.pose.pose.position.z = z + msg.pose.pose.orientation.x = qx + msg.pose.pose.orientation.y = qy + msg.pose.pose.orientation.z = qz + msg.pose.pose.orientation.w = qw + msg.pose.covariance[0] = 0.25 + msg.pose.covariance[7] = 0.25 + msg.pose.covariance[35] = 0.06853892326654787 + self.initial_pose_pub.publish(msg) + + def publish_rviz_rough_goal(self, x, y, z, qx, qy, qz, qw): + msg = PoseStamped() + msg.header.frame_id = "map" + msg.header.stamp = self.get_clock().now().to_msg() + msg.pose.position.x = x + msg.pose.position.y = y + msg.pose.position.z = z + msg.pose.orientation.x = qx + msg.pose.orientation.y = qy + msg.pose.orientation.z = qz + msg.pose.orientation.w = qw + self.rviz_rough_goal_pub.publish(msg) + + def get_trajectory(self, timeout=30.0): + if _ROBOT_STACK == "nav2": + self._trajectory_event.wait(timeout=min(timeout, 1.0)) + path = self._trajectory + if path is None and self._pending_nav2_goal is not None: + return self._pending_nav2_goal_to_points() + if path is None: + raise RuntimeError("Nav2 plan not received and no pending goal exists") + return self._nav_path_to_points(path) + if not HAS_PLANNING: + raise RuntimeError("Planning message packages are not available in this bridge image") + self._trajectory = None + self._trajectory_event.clear() + + if not self._trajectory_event.wait(timeout=timeout): + raise RuntimeError("Trajectory not received within timeout") + + traj = self._trajectory + + return [ + { + "x": p.pose.position.x, + "y": p.pose.position.y, + "z": p.pose.position.z, + "t": round(i * 0.1, 3), + } + for i, p in enumerate(traj.points) + ] + + def _planning_points_to_dicts(self, points): + return [ + { + "x": p.pose.position.x, + "y": p.pose.position.y, + "z": p.pose.position.z, + "t": round(i * 0.1, 3), + } + for i, p in enumerate(points) + ] + + def _points_reach_goal(self, points, goal_x, goal_y, max_distance=12.0): + if not points: + return False + max_distance_sq = max_distance * max_distance + tail = points[-80:] if len(points) > 80 else points + return any( + ((p["x"] - goal_x) * (p["x"] - goal_x) + (p["y"] - goal_y) * (p["y"] - goal_y)) <= max_distance_sq + for p in tail + ) + + def wait_behavior_path_for_goal(self, goal_x, goal_y, timeout=30.0): + if _ROBOT_STACK == "nav2": + return self.get_behavior_path(timeout=timeout) + if not HAS_PLANNING: + raise RuntimeError("Planning message packages are not available in this bridge image") + + deadline = time.time() + timeout + last_distance = None + while time.time() < deadline: + remaining = max(0.1, deadline - time.time()) + self._behavior_path_event.clear() + if not self._behavior_path_event.wait(timeout=min(1.0, remaining)): + continue + path = self._behavior_path + if path is None: + continue + points = self._planning_points_to_dicts(path.points) + if points: + last_distance = min(math.hypot(point["x"] - goal_x, point["y"] - goal_y) for point in points[-80:]) + if self._points_reach_goal(points, goal_x, goal_y): + return points + + detail = f"; nearest tail distance={last_distance:.2f}m" if last_distance is not None else "" + raise RuntimeError(f"Behavior path for requested goal not received within timeout{detail}") + + def wait_trajectory_for_goal(self, goal_x, goal_y, timeout=30.0): + if _ROBOT_STACK == "nav2": + return self.get_trajectory(timeout=timeout) + if not HAS_PLANNING: + raise RuntimeError("Planning message packages are not available in this bridge image") + + deadline = time.time() + timeout + last_points = None + while time.time() < deadline: + remaining = max(0.1, deadline - time.time()) + self._trajectory_event.clear() + if not self._trajectory_event.wait(timeout=min(1.0, remaining)): + continue + traj = self._trajectory + if traj is None: + continue + points = self._planning_points_to_dicts(traj.points) + if points: + last_points = points + if self._points_reach_goal(points, goal_x, goal_y, max_distance=25.0): + return points + + if last_points: + logging.warning("Trajectory for requested goal was not received; returning latest trajectory") + return last_points + raise RuntimeError("Trajectory not received within timeout") + + def get_behavior_path(self, timeout=30.0): + if _ROBOT_STACK == "nav2": + self._behavior_path_event.wait(timeout=min(timeout, 1.0)) + path = self._behavior_path + if path is None and self._pending_nav2_goal is not None: + return self._pending_nav2_goal_to_points() + if path is None: + raise RuntimeError("Nav2 plan not received and no pending goal exists") + return self._nav_path_to_points(path) + if not HAS_PLANNING: + raise RuntimeError("Planning message packages are not available in this bridge image") + self._behavior_path = None + self._behavior_path_event.clear() + + if not self._behavior_path_event.wait(timeout=timeout): + raise RuntimeError("Behavior path not received within timeout") + + path = self._behavior_path + + return [ + { + "x": p.pose.position.x, + "y": p.pose.position.y, + "z": p.pose.position.z, + "t": round(i * 0.1, 3), + } + for i, p in enumerate(path.points) + ] + + def get_kinematic_state(self): + with self._kinematic_lock: + return self._kinematic_state + + def _nav_path_to_points(self, path: NavPath): + return [ + { + "x": p.pose.position.x, + "y": p.pose.position.y, + "z": p.pose.position.z, + "t": round(i * 0.1, 3), + "v": 0.0, + } + for i, p in enumerate(path.poses) + ] + + def _nav_path_to_payload(self, path: NavPath): + poses = [] + x_min = math.inf + x_max = -math.inf + y_min = math.inf + y_max = -math.inf + + for pose_stamped in path.poses: + pos = pose_stamped.pose.position + ori = pose_stamped.pose.orientation + yaw = math.atan2( + 2.0 * (ori.w * ori.z + ori.x * ori.y), + 1.0 - 2.0 * (ori.y * ori.y + ori.z * ori.z), + ) + x = float(pos.x) + y = float(pos.y) + poses.append({ + "x": round(x, 3), + "y": round(y, 3), + "z": round(float(pos.z), 3), + "yaw": round(yaw, 5), + }) + x_min = min(x_min, x) + x_max = max(x_max, x) + y_min = min(y_min, y) + y_max = max(y_max, y) + + if not math.isfinite(x_min): + x_min = x_max = y_min = y_max = 0.0 + + return { + "type": "nav2_path", + "topic": "/plan", + "frame_id": path.header.frame_id, + "count": len(poses), + "bounds": { + "x_min": round(x_min, 3), + "x_max": round(x_max, 3), + "y_min": round(y_min, 3), + "y_max": round(y_max, 3), + }, + "poses": poses, + } + + def _pose_to_nav2_payload(self, pose, header): + pos = pose.position + ori = pose.orientation + yaw = math.atan2( + 2.0 * (ori.w * ori.z + ori.x * ori.y), + 1.0 - 2.0 * (ori.y * ori.y + ori.z * ori.z), + ) + return { + "type": "nav2_pose", + "topic": "/amcl_pose", + "frame_id": header.frame_id, + "x": round(float(pos.x), 3), + "y": round(float(pos.y), 3), + "z": round(float(pos.z), 3), + "yaw": round(yaw, 5), + "orientation": { + "x": round(float(ori.x), 6), + "y": round(float(ori.y), 6), + "z": round(float(ori.z), 6), + "w": round(float(ori.w), 6), + }, + "t": header.stamp.sec + header.stamp.nanosec * 1e-9, + } + + def _pending_nav2_goal_to_points(self): + goal = self._pending_nav2_goal + if goal is None: + return [] + state = self.get_kinematic_state() + start_pos = state.pose.pose.position if state is not None else None + if start_pos is None: + return [{ + "x": goal["x"], + "y": goal["y"], + "z": goal["z"], + "t": 0.0, + "v": 0.0, + }] + return [ + { + "x": start_pos.x, + "y": start_pos.y, + "z": start_pos.z, + "t": 0.0, + "v": 0.0, + }, + { + "x": goal["x"], + "y": goal["y"], + "z": goal["z"], + "t": 0.1, + "v": 0.0, + }, + ] + + + def _on_pointcloud_map(self, msg: PointCloud2): + try: + payload = self._pointcloud_to_payload(msg) + with self._pointcloud_map_lock: + self._pointcloud_map_payload = payload + self._pointcloud_map_revision += 1 + self.get_logger().info( + f"pointcloud map cached: {payload['count']} / {payload['source_count']} points" + ) + except Exception as e: + self.get_logger().error(f"pointcloud map conversion failed: {e}") + + def _on_vector_map_marker(self, msg: MarkerArray): + try: + payload = self._marker_array_to_payload(msg) + with self._vector_map_lock: + self._vector_map_payload = payload + self._vector_map_revision += 1 + # A vector map update means the active map may have changed. Drop any + # old latched pointcloud until a fresh /map/pointcloud_map arrives. + with self._pointcloud_map_lock: + self._pointcloud_map_payload = None + self._pointcloud_map_revision += 1 + self.get_logger().info( + f"vector map cached: {payload['count']} markers; pointcloud cache cleared" + ) + except Exception as e: + self.get_logger().error(f"vector map conversion failed: {e}") + + def _on_occupancy_map(self, msg: OccupancyGrid): + try: + payload = self._occupancy_grid_to_nav2_payload(msg) if _ROBOT_STACK == "nav2" else self._occupancy_grid_to_payload(msg) + with self._occupancy_map_lock: + self._occupancy_map_payload = payload + self._occupancy_map_revision += 1 + self.get_logger().info( + f"occupancy map cached: {payload['width']}x{payload['height']} res={payload['resolution']}" + ) + except Exception as e: + self.get_logger().error(f"occupancy map conversion failed: {e}") + + def _pointcloud_to_payload(self, msg: PointCloud2): + points = [] + source_count = int(msg.width * msg.height) + stride = max(1, _MAP_POINT_STRIDE) + limit = max(1000, _MAP_POINT_LIMIT) + x_min = math.inf + x_max = -math.inf + y_min = math.inf + y_max = -math.inf + z_min = math.inf + z_max = -math.inf + + for i, p in enumerate(point_cloud2.read_points( + msg, + field_names=("x", "y", "z"), + skip_nans=True, + )): + if i % stride != 0: + continue + try: + mx, my, mz = float(p[0]), float(p[1]), float(p[2]) + except Exception: + mx, my, mz = float(p["x"]), float(p["y"]), float(p["z"]) + points.append([round(mx, 3), round(my, 3), round(mz, 3)]) + x_min = min(x_min, mx) + x_max = max(x_max, mx) + y_min = min(y_min, my) + y_max = max(y_max, my) + z_min = min(z_min, mz) + z_max = max(z_max, mz) + if len(points) >= limit: + break + + if not math.isfinite(z_min): + x_min = x_max = _CENTER_X + y_min = y_max = _CENTER_Y + z_min = z_max = 0.0 + + center = _center_from_bounds(x_min, x_max, y_min, y_max) + + return { + "type": "pointcloud", + "topic": "/map/pointcloud_map", + "coordinate_frame": "map", + "frame_id": msg.header.frame_id, + "source_count": source_count, + "count": len(points), + "stride": stride, + "center": {"x": round(center["x"], 3), "y": round(center["y"], 3)}, + "bounds": { + "x_min": round(x_min, 3), + "x_max": round(x_max, 3), + "y_min": round(y_min, 3), + "y_max": round(y_max, 3), + "z_min": round(z_min, 3), + "z_max": round(z_max, 3), + }, + "z_min": round(z_min, 3), + "z_max": round(z_max, 3), + "points": points, + } + + def _occupancy_grid_to_payload(self, msg: OccupancyGrid): + width = int(msg.info.width) + height = int(msg.info.height) + resolution = float(msg.info.resolution) + origin = msg.info.origin.position + x_min = float(origin.x) + y_min = float(origin.y) + x_max = x_min + width * resolution + y_max = y_min + height * resolution + z = float(origin.z) + center = _center_from_bounds(x_min, x_max, y_min, y_max) + data = [int(v) for v in msg.data] + + return { + "type": "occupancy_grid", + "topic": "/map", + "coordinate_frame": "map", + "frame_id": msg.header.frame_id, + "width": width, + "height": height, + "resolution": resolution, + "origin": { + "x": round(x_min, 3), + "y": round(y_min, 3), + "z": round(z, 3), + }, + "center": {"x": round(center["x"], 3), "y": round(center["y"], 3)}, + "bounds": { + "x_min": round(x_min, 3), + "x_max": round(x_max, 3), + "y_min": round(y_min, 3), + "y_max": round(y_max, 3), + "z_min": round(z, 3), + "z_max": round(z, 3), + }, + "data": data, + } + + def _occupancy_grid_to_nav2_payload(self, msg: OccupancyGrid): + payload = self._occupancy_grid_to_payload(msg) + payload["type"] = "nav2_map" + return payload + + def _marker_array_to_payload(self, msg: MarkerArray): + markers = [] + x_min = math.inf + x_max = -math.inf + y_min = math.inf + y_max = -math.inf + z_min = math.inf + z_max = -math.inf + + for marker in msg.markers: + if marker.action == 2: # DELETE + markers.append({ + "ns": marker.ns, + "id": marker.id, + "action": marker.action, + "points": [], + }) + continue + + points = [] + for p in marker.points: + mx, my, mz = float(p.x), float(p.y), float(p.z) + points.append([round(mx, 3), round(my, 3), round(mz, 3)]) + x_min = min(x_min, mx) + x_max = max(x_max, mx) + y_min = min(y_min, my) + y_max = max(y_max, my) + z_min = min(z_min, mz) + z_max = max(z_max, mz) + + pose = marker.pose + color = marker.color + markers.append({ + "ns": marker.ns, + "id": marker.id, + "type": marker.type, + "action": marker.action, + "frame_id": marker.header.frame_id, + "pose": { + "position": { + "x": pose.position.x, + "y": pose.position.y, + "z": pose.position.z, + }, + "orientation": { + "x": pose.orientation.x, + "y": pose.orientation.y, + "z": pose.orientation.z, + "w": pose.orientation.w, + }, + }, + "scale": { + "x": marker.scale.x, + "y": marker.scale.y, + "z": marker.scale.z, + }, + "color": [color.r, color.g, color.b, color.a], + "points": points, + }) + + if not math.isfinite(x_min): + x_min = x_max = _CENTER_X + y_min = y_max = _CENTER_Y + z_min = z_max = 0.0 + + center = _center_from_bounds(x_min, x_max, y_min, y_max) + + return { + "type": "vector_map", + "topic": "/map/vector_map_marker", + "coordinate_frame": "map", + "center": {"x": round(center["x"], 3), "y": round(center["y"], 3)}, + "bounds": { + "x_min": round(x_min, 3), + "x_max": round(x_max, 3), + "y_min": round(y_min, 3), + "y_max": round(y_max, 3), + "z_min": round(z_min, 3), + "z_max": round(z_max, 3), + }, + "count": len(markers), + "markers": markers, + } + + def get_map_snapshot(self): + with self._pointcloud_map_lock: + pointcloud = self._pointcloud_map_payload + pointcloud_revision = self._pointcloud_map_revision + with self._vector_map_lock: + vector = self._vector_map_payload + vector_revision = self._vector_map_revision + with self._occupancy_map_lock: + occupancy = self._occupancy_map_payload + occupancy_revision = self._occupancy_map_revision + return { + "pointcloud": pointcloud, + "pointcloud_revision": pointcloud_revision, + "vector": vector, + "vector_revision": vector_revision, + "occupancy": occupancy, + "occupancy_revision": occupancy_revision, + } + + def get_nav2_visualization_snapshot(self): + with self._occupancy_map_lock: + occupancy = self._occupancy_map_payload + occupancy_revision = self._occupancy_map_revision + with self._nav2_pose_lock: + pose = self._nav2_pose_payload + pose_revision = self._nav2_pose_revision + with self._nav2_path_lock: + path = self._nav2_path_payload + path_revision = self._nav2_path_revision + return { + "map": occupancy, + "map_revision": occupancy_revision, + "pose": pose, + "pose_revision": pose_revision, + "path": path, + "path_revision": path_revision, + } + + def call_client(self, client, request, timeout=15.0): + if client is None: + raise RuntimeError("ROS service client is not available for this stack") + with self._svc_lock: + if not client.wait_for_service(timeout_sec=5.0): + raise RuntimeError(f"{client.srv_name} not available") + + future = client.call_async(request) + start = time.time() + + while not future.done(): + if time.time() - start > timeout: + raise RuntimeError( + f"{client.srv_name} call timed out ({timeout}s)" + ) + time.sleep(0.05) + + return future.result() + + def wait_nav2_active(self, timeout=30.0): + if _ROBOT_STACK != "nav2": + return + clients = getattr(self, "_nav2_lifecycle_clients", []) + if not clients: + return + + deadline = time.time() + timeout + while time.time() < deadline: + all_active = True + states = [] + for client in clients: + remaining = max(0.1, min(1.0, deadline - time.time())) + if not client.wait_for_service(timeout_sec=remaining): + all_active = False + states.append(f"{client.srv_name}:unavailable") + break + + future = client.call_async(GetState.Request()) + wait_start = time.time() + while not future.done() and time.time() - wait_start < remaining: + time.sleep(0.05) + + if not future.done(): + all_active = False + states.append(f"{client.srv_name}:timeout") + break + + result = future.result() + label = getattr(getattr(result, "current_state", None), "label", "") + states.append(f"{client.srv_name}:{label}") + if label != "active": + all_active = False + + if all_active: + return + time.sleep(0.25) + + raise RuntimeError(f"Nav2 lifecycle is not active: {', '.join(states)}") + + def send_nav2_goal(self, x, y, z, qx, qy, qz, qw, timeout=10.0): + if not HAS_NAV2 or self._nav2_action_client is None: + raise RuntimeError("nav2_msgs NavigateToPose action is not available in this bridge image") + self.wait_nav2_active(timeout=max(30.0, timeout)) + if not self._nav2_action_client.wait_for_server(timeout_sec=timeout): + raise RuntimeError("/navigate_to_pose action server not available") + + goal = NavigateToPose.Goal() + goal.pose = PoseStamped() + goal.pose.header.frame_id = "map" + goal.pose.header.stamp = self.get_clock().now().to_msg() + goal.pose.pose.position.x = x + goal.pose.pose.position.y = y + goal.pose.pose.position.z = z + goal.pose.pose.orientation.x = qx + goal.pose.pose.orientation.y = qy + goal.pose.pose.orientation.z = qz + goal.pose.pose.orientation.w = qw + + self._behavior_path = None + self._trajectory = None + self._behavior_path_event.clear() + self._trajectory_event.clear() + + future = self._nav2_action_client.send_goal_async(goal) + start = time.time() + while not future.done(): + if time.time() - start > timeout: + raise RuntimeError("NavigateToPose goal send timed out") + time.sleep(0.05) + + goal_handle = future.result() + if not goal_handle or not goal_handle.accepted: + return {"success": False, "code": 1, "message": "NavigateToPose goal rejected"} + + self._nav2_goal_handle = goal_handle + self._nav2_goal_active = True + return {"success": True, "code": 0, "message": "NavigateToPose goal accepted"} + + def compute_nav2_path(self, x, y, z, qx, qy, qz, qw, timeout=10.0): + if not HAS_NAV2 or self._nav2_compute_path_client is None: + raise RuntimeError("nav2_msgs ComputePathToPose action is not available in this bridge image") + if not self._nav2_compute_path_client.wait_for_server(timeout_sec=timeout): + raise RuntimeError("/compute_path_to_pose action server not available") + + goal = ComputePathToPose.Goal() + goal.goal = PoseStamped() + goal.goal.header.frame_id = "map" + goal.goal.header.stamp = self.get_clock().now().to_msg() + goal.goal.pose.position.x = x + goal.goal.pose.position.y = y + goal.goal.pose.position.z = z + goal.goal.pose.orientation.x = qx + goal.goal.pose.orientation.y = qy + goal.goal.pose.orientation.z = qz + goal.goal.pose.orientation.w = qw + goal.use_start = False + goal.planner_id = "" + + future = self._nav2_compute_path_client.send_goal_async(goal) + start = time.time() + while not future.done(): + if time.time() - start > timeout: + raise RuntimeError("ComputePathToPose goal send timed out") + time.sleep(0.05) + + goal_handle = future.result() + if not goal_handle or not goal_handle.accepted: + return {"success": False, "code": 1, "message": "ComputePathToPose goal rejected"} + + result_future = goal_handle.get_result_async() + start = time.time() + while not result_future.done(): + if time.time() - start > timeout: + raise RuntimeError("ComputePathToPose result timed out") + time.sleep(0.05) + + result = result_future.result() + path = getattr(getattr(result, "result", None), "path", None) + if path is None or len(path.poses) == 0: + return {"success": False, "code": 2, "message": "ComputePathToPose returned empty path"} + + self._behavior_path = path + self._trajectory = path + self._behavior_path_event.set() + self._trajectory_event.set() + with self._nav2_path_lock: + self._nav2_path_payload = self._nav_path_to_payload(path) + self._nav2_path_revision += 1 + + return { + "success": True, + "code": 0, + "message": f"ComputePathToPose path accepted ({len(path.poses)} poses)", + } + + def set_pending_nav2_goal(self, x, y, z, qx, qy, qz, qw): + self._pending_nav2_goal = { + "x": x, + "y": y, + "z": z, + "qx": qx, + "qy": qy, + "qz": qz, + "qw": qw, + } + self._behavior_path = None + self._trajectory = None + self._behavior_path_event.clear() + self._trajectory_event.clear() + return self.send_nav2_goal(x, y, z, qx, qy, qz, qw) + + def start_pending_nav2_goal(self): + goal = self._pending_nav2_goal + if goal is None: + raise RuntimeError("No pending Nav2 goal") + return self.send_nav2_goal( + goal["x"], + goal["y"], + goal["z"], + goal["qx"], + goal["qy"], + goal["qz"], + goal["qw"], + ) + + def cancel_nav2_goal(self, timeout=5.0): + self._nav2_goal_active = False + goal_handle = getattr(self, "_nav2_goal_handle", None) + if goal_handle is None: + return {"ok": True} + future = goal_handle.cancel_goal_async() + start = time.time() + while not future.done(): + if time.time() - start > timeout: + raise RuntimeError("NavigateToPose cancel timed out") + time.sleep(0.05) + self._nav2_goal_handle = None + return {"ok": True} + + +sub_node: Optional[SubscriberNode] = None +_spin_thread: Optional[threading.Thread] = None + + +@asynccontextmanager +async def lifespan(app: FastAPI): + global sub_node, _spin_thread + + rclpy.init() + sub_node = SubscriberNode() + + def spin(): + executor = SingleThreadedExecutor() + executor.add_node(sub_node) + try: + executor.spin() + except Exception: + pass + + _spin_thread = threading.Thread(target=spin, daemon=True) + _spin_thread.start() + + yield + + if rclpy.ok(): + rclpy.shutdown() + + +app = FastAPI(title="Autoware ROS2 Bridge", lifespan=lifespan) +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + + +class PosePayload(BaseModel): + x: float + y: float + z: float + qx: float + qy: float + qz: float + qw: float + + +class EngagePayload(BaseModel): + engage: bool = True + + +@app.get("/health") +def health(): + return { + "ok": True, + "stack": _ROBOT_STACK, + "route_prefix": _AUTOWARE_ROUTE_PREFIX, + "route_input": _AUTOWARE_ROUTE_INPUT, + } + + +@app.get("/stack") +def get_stack(): + return { + "stack": _ROBOT_STACK, + "route_prefix": _AUTOWARE_ROUTE_PREFIX, + "route_input": _AUTOWARE_ROUTE_INPUT, + "supported": ["autoware", "nav2"], + } + + +@app.get("/operation_mode") +def get_operation_mode(): + mode = sub_node.get_operation_mode() if sub_node else 0 + return {"mode": mode, "autonomous": mode == 2} + + +@app.websocket("/ws/operation_mode") +async def ws_operation_mode(websocket: WebSocket): + await websocket.accept() + last_mode = -1 + try: + while True: + mode = sub_node.get_operation_mode() if sub_node else 0 + if mode != last_mode: + last_mode = mode + await websocket.send_json({"mode": mode, "autonomous": mode == 2}) + await asyncio.sleep(0.5) + except (WebSocketDisconnect, Exception): + pass + + + +@app.get("/map/status") +def map_status(): + snap = sub_node.get_map_snapshot() if sub_node else {} + publishers = [] + try: + if sub_node: + publishers = [] + for info in sub_node.get_publishers_info_by_topic("/map"): + qos = info.qos_profile + publishers.append({ + "node_name": info.node_name, + "node_namespace": info.node_namespace, + "topic_type": info.topic_type, + "qos": { + "reliability": str(qos.reliability), + "durability": str(qos.durability), + "history": str(qos.history), + "depth": qos.depth, + }, + }) + except Exception as e: + publishers = [{"error": str(e)}] + return { + "pointcloud": bool(snap.get("pointcloud")), + "pointcloud_revision": snap.get("pointcloud_revision", 0), + "vector": bool(snap.get("vector")), + "vector_revision": snap.get("vector_revision", 0), + "occupancy": bool(snap.get("occupancy")), + "occupancy_revision": snap.get("occupancy_revision", 0), + "map_publishers": publishers, + } + + +@app.websocket("/ws/map") +async def ws_map(websocket: WebSocket, stack: Optional[str] = None): + await websocket.accept() + if stack and stack.lower() != _ROBOT_STACK: + await websocket.send_json({ + "type": "stack_mismatch", + "requested": stack.lower(), + "active": _ROBOT_STACK, + "message": "Restart bridge with ROBOT_STACK to change ROS adapter", + }) + last_pointcloud_revision = -1 + last_vector_revision = -1 + last_occupancy_revision = -1 + try: + while True: + if sub_node: + snap = sub_node.get_map_snapshot() + pointcloud_revision = snap["pointcloud_revision"] + vector_revision = snap["vector_revision"] + occupancy_revision = snap["occupancy_revision"] + + if snap["vector"] and vector_revision != last_vector_revision: + last_vector_revision = vector_revision + await websocket.send_json({ + "revision": vector_revision, + **snap["vector"], + }) + + if snap["pointcloud"] and pointcloud_revision != last_pointcloud_revision: + last_pointcloud_revision = pointcloud_revision + await websocket.send_json({ + "revision": pointcloud_revision, + **snap["pointcloud"], + }) + + if snap["occupancy"] and occupancy_revision != last_occupancy_revision: + last_occupancy_revision = occupancy_revision + await websocket.send_json({ + "revision": occupancy_revision, + **snap["occupancy"], + }) + + await asyncio.sleep(0.25) + except (WebSocketDisconnect, Exception): + pass + + +@app.websocket("/ws/nav2") +async def ws_nav2(websocket: WebSocket): + """Stream Nav2 2D visualization payloads without changing Autoware streams.""" + await websocket.accept() + if _ROBOT_STACK != "nav2": + await websocket.send_json({ + "type": "stack_mismatch", + "requested": "nav2", + "active": _ROBOT_STACK, + "message": "Restart bridge with ROBOT_STACK=nav2", + }) + await websocket.close() + return + + last_map_revision = -1 + last_pose_revision = -1 + last_path_revision = -1 + try: + while True: + if sub_node: + snap = sub_node.get_nav2_visualization_snapshot() + + if snap["map"] and snap["map_revision"] != last_map_revision: + last_map_revision = snap["map_revision"] + await websocket.send_json({ + "revision": last_map_revision, + **snap["map"], + }) + + if snap["pose"] and snap["pose_revision"] != last_pose_revision: + last_pose_revision = snap["pose_revision"] + await websocket.send_json({ + "revision": last_pose_revision, + **snap["pose"], + }) + + if snap["path"] and snap["path_revision"] != last_path_revision: + last_path_revision = snap["path_revision"] + await websocket.send_json({ + "revision": last_path_revision, + **snap["path"], + }) + + await asyncio.sleep(0.1) + except (WebSocketDisconnect, Exception): + pass + + +@app.post("/set_initial_pose") +def set_initial_pose(payload: PosePayload): + try: + sub_node.set_initial_pose( + payload.x, + payload.y, + payload.z, + payload.qx, + payload.qy, + payload.qz, + payload.qw, + ) + return {"ok": True} + except Exception as e: + logging.error(traceback.format_exc()) + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/set_route") +def set_route(payload: PosePayload): + try: + if _ROBOT_STACK == "nav2": + result = sub_node.set_pending_nav2_goal( + payload.x, + payload.y, + payload.z, + payload.qx, + payload.qy, + payload.qz, + payload.qw, + ) + return { + "ok": True, + "success": result["success"], + "code": result["code"], + "message": result["message"], + } + + if not HAS_ROUTING or SetRoutePoints is None: + raise HTTPException( + status_code=503, + detail="Autoware routing message package is not available in this bridge image", + ) + + if _AUTOWARE_ROUTE_INPUT == "rviz_topic": + sub_node.publish_rviz_rough_goal( + payload.x, + payload.y, + payload.z, + payload.qx, + payload.qy, + payload.qz, + payload.qw, + ) + logging.info( + f"set_route via /rviz/routing/rough_goal: x={payload.x:.2f} y={payload.y:.2f}" + ) + return { + "ok": True, + "success": True, + "code": 0, + "message": "published /rviz/routing/rough_goal", + } + + try: + sub_node.call_client( + sub_node._cli_clear_route, + ClearRoute.Request(), + timeout=5.0, + ) + logging.info("Route cleared") + except Exception as e: + logging.warning(f"clear_route skipped: {e}") + + from geometry_msgs.msg import Pose + + req = SetRoutePoints.Request() + req.header.frame_id = "map" + + req.goal = Pose() + req.goal.position.x = payload.x + req.goal.position.y = payload.y + req.goal.position.z = payload.z + req.goal.orientation.x = payload.qx + req.goal.orientation.y = payload.qy + req.goal.orientation.z = payload.qz + req.goal.orientation.w = payload.qw + + logging.info(f"set_route: x={payload.x:.2f} y={payload.y:.2f}") + + result = None + + for attempt in range(20): + try: + result = sub_node.call_client( + sub_node._cli_set_route, + req, + timeout=15.0, + ) + + logging.info( + f"set_route attempt={attempt + 1} " + f"success={result.status.success} " + f"code={result.status.code} " + f"msg={result.status.message}" + ) + + if result.status.success or result.status.code != 50001: + break + + logging.warning("Routing not ready (50001), retrying in 3s...") + + except Exception as e: + logging.warning( + f"set_route attempt={attempt + 1} failed: {e}, retrying in 3s..." + ) + + time.sleep(3) + + if result is None: + raise HTTPException( + status_code=503, + detail="Routing service not available after retries", + ) + + return { + "ok": True, + "success": result.status.success, + "code": result.status.code, + "message": result.status.message, + } + + except Exception as e: + logging.error(traceback.format_exc()) + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/plan") +def plan(payload: PosePayload): + try: + route = set_route(payload) + if not route.get("ok"): + return route + + if _ROBOT_STACK == "nav2": + path_points = sub_node.get_behavior_path(timeout=5.0) + return {"ok": True, "planned": path_points, "plannedTrajectory": path_points} + + path_points = sub_node.wait_behavior_path_for_goal(payload.x, payload.y, timeout=30.0) + trajectory_points = sub_node.wait_trajectory_for_goal(payload.x, payload.y, timeout=10.0) + return { + "ok": True, + "planned": path_points, + "plannedTrajectory": trajectory_points, + } + except HTTPException: + raise + except Exception as e: + logging.error(traceback.format_exc()) + raise HTTPException(status_code=504, detail=str(e)) + + +@app.post("/disengage") +def disengage(): + try: + if _ROBOT_STACK == "nav2": + return sub_node.cancel_nav2_goal() + + if not HAS_OPERATION: + logging.warning("disengage skipped: operation message package is not available") + return {"ok": True, "skipped": "operation message package is not available"} + + try: + if USE_OPERATION_MODE: + sub_node.call_client( + sub_node._cli_stop, + sub_node._cli_stop.srv_type.Request(), + timeout=5.0, + ) + else: + req = sub_node._cli_engage.srv_type.Request() + req.engage = False + sub_node.call_client( + sub_node._cli_engage, + req, + timeout=5.0, + ) + except Exception as e: + logging.warning(f"change_to_stop skipped: {e}") + + try: + sub_node.call_client( + sub_node._cli_clear_route, + ClearRoute.Request(), + timeout=5.0, + ) + except Exception as e: + logging.warning(f"clear_route skipped: {e}") + + logging.info("Disengaged and route cleared") + return {"ok": True} + + except Exception as e: + logging.error(traceback.format_exc()) + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/reset") +def reset(): + """ + Autoware soft reset: + 1. disengage (stop mode) + 2. clear route + 3. republish /initialpose from the current kinematic state to reinitialize planning/control modules + """ + try: + if _ROBOT_STACK == "nav2": + try: + sub_node.cancel_nav2_goal() + except Exception as e: + logging.warning(f"nav2 cancel skipped: {e}") + state = sub_node.get_kinematic_state() + if state is not None: + pos = state.pose.pose.position + ori = state.pose.pose.orientation + sub_node.set_initial_pose( + pos.x, pos.y, pos.z, + ori.x, ori.y, ori.z, ori.w, + ) + return {"ok": True} + + has_stop_client = ( + (USE_OPERATION_MODE and sub_node._cli_stop is not None) + or ((not USE_OPERATION_MODE) and sub_node._cli_engage is not None) + ) + + # 1. stop + if has_stop_client: + try: + if USE_OPERATION_MODE: + sub_node.call_client( + sub_node._cli_stop, + sub_node._cli_stop.srv_type.Request(), + timeout=5.0, + ) + else: + req = sub_node._cli_engage.srv_type.Request() + req.engage = False + sub_node.call_client(sub_node._cli_engage, req, timeout=5.0) + except Exception as e: + logging.warning(f"stop skipped: {e}") + else: + logging.warning("stop skipped: operation client is not available") + + # 2. clear route + if HAS_ROUTING and ClearRoute is not None and sub_node._cli_clear_route is not None: + try: + sub_node.call_client(sub_node._cli_clear_route, ClearRoute.Request(), timeout=5.0) + except Exception as e: + logging.warning(f"clear_route skipped: {e}") + else: + logging.warning("clear_route skipped: routing client is not available") + + # 3. Republish /initialpose from the current position + state = sub_node.get_kinematic_state() + if state is not None: + pos = state.pose.pose.position + ori = state.pose.pose.orientation + sub_node.set_initial_pose( + pos.x, pos.y, pos.z, + ori.x, ori.y, ori.z, ori.w, + ) + logging.info(f"reset: republished initialpose at x={pos.x:.2f} y={pos.y:.2f}") + + # Wait for localization to converge again, up to 5 seconds + deadline = time.time() + 5.0 + while time.time() < deadline: + s = sub_node.get_kinematic_state() + if s is not None: + dx = s.pose.pose.position.x - pos.x + dy = s.pose.pose.position.y - pos.y + if math.sqrt(dx*dx + dy*dy) < 2.0: + break + time.sleep(0.2) + else: + logging.warning("reset: no kinematic state available, skipping initialpose") + + return {"ok": True} + + except Exception as e: + logging.error(traceback.format_exc()) + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/clear_route") +def clear_route(): + try: + if _ROBOT_STACK == "nav2": + return sub_node.cancel_nav2_goal() + + if not HAS_ROUTING or ClearRoute is None or sub_node._cli_clear_route is None: + logging.warning("clear_route skipped: routing message package is not available") + return {"ok": True, "skipped": "routing message package is not available"} + + sub_node.call_client( + sub_node._cli_clear_route, + ClearRoute.Request(), + timeout=5.0, + ) + logging.info("Route cleared explicitly") + return {"ok": True} + except Exception as e: + logging.warning(f"clear_route skipped: {e}") + return {"ok": True, "skipped": str(e)} + + +@app.get("/get_trajectory") +def get_trajectory(): + try: + points = sub_node.get_trajectory(timeout=30.0) + return {"points": points} + except Exception as e: + logging.error(traceback.format_exc()) + raise HTTPException(status_code=504, detail=str(e)) + + +@app.get("/get_behavior_path") +def get_behavior_path(): + try: + points = sub_node.get_behavior_path(timeout=30.0) + return {"points": points} + except Exception as e: + logging.error(traceback.format_exc()) + raise HTTPException(status_code=504, detail=str(e)) + + +@app.post("/engage") +def engage(payload: EngagePayload): + try: + if _ROBOT_STACK == "nav2": + result = sub_node.start_pending_nav2_goal() + return { + "ok": True, + "success": result["success"], + "code": result["code"], + "message": result["message"], + } + + if not HAS_OPERATION: + raise HTTPException( + status_code=503, + detail="Autoware operation message package is not available in this bridge image", + ) + + if USE_OPERATION_MODE: + try: + r1 = sub_node.call_client( + sub_node._cli_enable_ctrl, + sub_node._cli_enable_ctrl.srv_type.Request(), + timeout=5.0, + ) + logging.info( + f"enable_autoware_control: " + f"success={r1.status.success} " + f"msg={r1.status.message!r}" + ) + except Exception as e: + logging.warning(f"enable_autoware_control skipped: {e}") + + result = None + try: + result = sub_node.call_client( + sub_node._cli_autonomous, + sub_node._cli_autonomous.srv_type.Request(), + timeout=3.0, + ) + + logging.info( + f"change_to_autonomous: " + f"success={result.status.success} " + f"msg={result.status.message!r} " + f"code={result.status.code}" + ) + except Exception as e: + logging.warning(f"change_to_autonomous response skipped: {e}") + + deadline = time.time() + 8.0 + while time.time() < deadline: + if sub_node.get_operation_mode() == 2: + return {"ok": True} + time.sleep(0.1) + + if result is not None and not result.status.success: + raise HTTPException( + status_code=503, + detail=( + f"change_to_autonomous failed: " + f"[{result.status.code}] {result.status.message}" + ), + ) + + raise HTTPException( + status_code=503, + detail="change_to_autonomous requested but operation mode did not become autonomous", + ) + else: + req = sub_node._cli_engage.srv_type.Request() + req.engage = True + sub_node.call_client( + sub_node._cli_engage, + req, + timeout=10.0, + ) + + return {"ok": True} + + except HTTPException: + raise + except Exception as e: + logging.error(traceback.format_exc()) + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/get_kinematic_state") +def get_kinematic_state(): + state = sub_node.get_kinematic_state() + + if state is None: + raise HTTPException(status_code=503, detail="No kinematic state yet") + + pos = state.pose.pose.position + vel = state.twist.twist.linear + + return { + "x": pos.x, + "y": pos.y, + "z": pos.z, + "vx": vel.x, + "vy": vel.y, + "vz": vel.z, + "t": state.header.stamp.sec + state.header.stamp.nanosec * 1e-9, + } + + +@app.websocket("/ws/kinematic") +async def ws_kinematic(websocket: WebSocket, stack: Optional[str] = None): + """Stream kinematic state at ~10 Hz, only while autonomous.""" + await websocket.accept() + if stack and stack.lower() != _ROBOT_STACK: + await websocket.send_json({ + "type": "stack_mismatch", + "requested": stack.lower(), + "active": _ROBOT_STACK, + "message": "Restart bridge with ROBOT_STACK to change ROS adapter", + }) + try: + while True: + if sub_node and sub_node.is_autonomous(): + state = sub_node.get_kinematic_state() + if state is not None: + pos = state.pose.pose.position + ori = state.pose.pose.orientation + vel = state.twist.twist.linear + three = _map_to_three(pos.x, pos.y, pos.z) + heading = _quat_to_three_heading(ori.x, ori.y, ori.z, ori.w) + steer_rad = sub_node.get_steering_angle() + turn = sub_node.get_turn_indicator() + await websocket.send_json({ + "x": round(three["x"], 3), + "y": round(three["y"], 3), + "z": round(three["z"], 3), + "map_x": round(pos.x, 3), + "map_y": round(pos.y, 3), + "map_z": round(pos.z, 3), + "heading": round(heading, 5), + "speed": round(vel.x, 3), + "steer": round(math.degrees(steer_rad), 2), + "turn": turn, + "t": state.header.stamp.sec + state.header.stamp.nanosec * 1e-9, + }) + await asyncio.sleep(0.1) + except (WebSocketDisconnect, Exception): + pass + + +if __name__ == "__main__": + uvicorn.run( + app, + host="0.0.0.0", + port=8765, + log_level="warning", + ) diff --git a/ros_bridge/requirements.txt b/ros_bridge/requirements.txt new file mode 100644 index 0000000..a09e97f --- /dev/null +++ b/ros_bridge/requirements.txt @@ -0,0 +1,3 @@ +fastapi>=0.110.0 +uvicorn[standard]>=0.29.0 +pydantic>=2.0.0 diff --git a/scripts/build-release.sh b/scripts/build-release.sh new file mode 100755 index 0000000..5eba18b --- /dev/null +++ b/scripts/build-release.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env sh +set -eu + +ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +OUTPUT_DIR="${PLATFORM_AGENT_RELEASE_DIR:-$ROOT_DIR/dist/release}" +STAGE_DIR="$(mktemp -d)" + +cleanup() { rm -rf "$STAGE_DIR"; } +trap cleanup EXIT INT TERM + +require_file() { + if [ ! -e "$ROOT_DIR/$1" ]; then + printf 'Missing required file: %s\n' "$1" >&2 + exit 1 + fi +} + +for file in package.json bun.lock tsconfig.json agent.config.json src scripts/install.sh scripts/setting.sh scripts/uninstall.sh scripts/installer-tui/main.go; do + require_file "$file" +done + +build_tui() { + arch="$1" + output="$ROOT_DIR/scripts/installer-tui/installer-tui-linux-$arch" + + if command -v go >/dev/null 2>&1; then + ( + cd "$ROOT_DIR/scripts/installer-tui" + GOOS=linux GOARCH="$arch" go build -o "$output" . + ) + return + fi + + if command -v docker >/dev/null 2>&1; then + docker run --rm \ + --user "$(id -u):$(id -g)" \ + -e GOCACHE=/tmp/go-build-cache \ + -e GOOS=linux \ + -e GOARCH="$arch" \ + -v "$ROOT_DIR/scripts/installer-tui:/src" \ + -w /src \ + golang:1.22-alpine \ + sh -c "go build -o installer-tui-linux-$arch ." + return + fi + + printf '%s\n' 'Go or Docker is required to build installer binaries.' >&2 + exit 1 +} + +package_archive() { + arch="$1" + package_dir="$STAGE_DIR/platform-agent" + archive="$OUTPUT_DIR/platform-agent-linux-$arch.tar.gz" + + rm -rf "$package_dir" + mkdir -p "$package_dir/scripts/installer-tui" + + cp "$ROOT_DIR/package.json" "$ROOT_DIR/bun.lock" "$ROOT_DIR/tsconfig.json" "$ROOT_DIR/agent.config.json" "$package_dir/" + cp -R "$ROOT_DIR/src" "$package_dir/src" + cp -R "$ROOT_DIR/scripts/." "$package_dir/scripts/" + + rm -f "$package_dir/scripts/installer-tui/installer-tui" \ + "$package_dir/scripts/installer-tui/installer-tui-linux-amd64" \ + "$package_dir/scripts/installer-tui/installer-tui-linux-arm64" + cp "$ROOT_DIR/scripts/installer-tui/installer-tui-linux-$arch" "$package_dir/scripts/installer-tui/" + chmod +x "$package_dir/scripts/install.sh" "$package_dir/scripts/setting.sh" "$package_dir/scripts/uninstall.sh" \ + "$package_dir/scripts/installer-tui/installer-tui-linux-$arch" + + tar -C "$STAGE_DIR" -czf "$archive" platform-agent + printf 'Created %s\n' "$archive" +} + +mkdir -p "$OUTPUT_DIR" +build_tui amd64 +build_tui arm64 +package_archive amd64 +package_archive arm64 + +if command -v sha256sum >/dev/null 2>&1; then + ( + cd "$OUTPUT_DIR" + sha256sum platform-agent-linux-amd64.tar.gz platform-agent-linux-arm64.tar.gz > checksums.txt + ) + printf 'Created %s/checksums.txt\n' "$OUTPUT_DIR" +fi + diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..e0ad0fc --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,778 @@ +#!/usr/bin/env sh +set -eu + +INSTALL_DIR="${PLATFORM_AGENT_DIR:-/opt/platform-agent}" +SERVICE_NAME="${PLATFORM_AGENT_SERVICE:-platform-agent}" +REPO_URL="${PLATFORM_AGENT_REPO_URL:-}" +RUN_USER="${PLATFORM_AGENT_USER:-$(id -un)}" +INSTALL_TAILSCALE="${INSTALL_TAILSCALE:-false}" +INSTALL_OPENSSH_SERVER="${INSTALL_OPENSSH_SERVER:-true}" +INSTALL_GIT="${INSTALL_GIT:-true}" +TAILSCALE_AUTH_KEY="${TAILSCALE_AUTH_KEY:-}" +TAILSCALE_LOGIN_SERVER="${TAILSCALE_LOGIN_SERVER:-}" +TAILSCALE_HOSTNAME="${TAILSCALE_HOSTNAME:-}" +TAILSCALE_EXTRA_ARGS="${TAILSCALE_EXTRA_ARGS:-}" +PLATFORM_PROVISION_URL="${PLATFORM_PROVISION_URL:-https://portal-api.autonomystudio.net/api/provision}" +PLATFORM_ACCOUNT="${PLATFORM_ACCOUNT:-}" +PLATFORM_REGISTRATION_CODE="${PLATFORM_REGISTRATION_CODE:-}" +WORKSPACE_ROOT="${WORKSPACE_ROOT:-~/autonomy-studio/workspaces}" +export WORKSPACE_ROOT +ROBOT_STACK="${ROBOT_STACK:-autoware}" +ROS_DOMAIN_ID="${ROS_DOMAIN_ID:-0}" +CAMERA_ENABLED="${CAMERA_ENABLED:-false}" +CAMERA_VIDEO_DEVICE="${CAMERA_VIDEO_DEVICE:-/dev/video0}" +CAMERA_SIGNALING_URL="${CAMERA_SIGNALING_URL:-ws://127.0.0.1:3000}" +KASMVNC_GEOMETRY="${KASMVNC_GEOMETRY:-1600x900}" +AGENT_GATEWAY_TOKEN="${AGENT_GATEWAY_TOKEN:-}" +PLATFORM_AGENT_INTERACTIVE="${PLATFORM_AGENT_INTERACTIVE:-auto}" +PLATFORM_INSTALLER_TUI_URL="${PLATFORM_INSTALLER_TUI_URL:-}" +PLATFORM_SKIP_TUI="${PLATFORM_SKIP_TUI:-false}" +PLATFORM_AGENT_REINSTALL="${PLATFORM_AGENT_REINSTALL:-false}" +PLATFORM_AGENT_SETTINGS_ONLY="${PLATFORM_AGENT_SETTINGS_ONLY:-false}" +STEP_CURRENT=0 +STEP_TOTAL=8 +TUI_SKIP_REASON="" + +log() { + printf '%s\n' "$*" +} + +need_cmd() { + command -v "$1" >/dev/null 2>&1 +} + +has_tty() { + [ -t 0 ] || { [ -r /dev/tty ] && [ -w /dev/tty ]; } +} + +tty_available() { + ( : < /dev/tty > /dev/tty ) 2>/dev/null +} + +progress_bar() { + current="$1" + total="$2" + label="$3" + width=24 + filled=$((current * width / total)) + empty=$((width - filled)) + bar="" + i=0 + while [ "$i" -lt "$filled" ]; do bar="${bar}#"; i=$((i + 1)); done + i=0 + while [ "$i" -lt "$empty" ]; do bar="${bar}-"; i=$((i + 1)); done + printf '[%s] %s/%s %s\n' "$bar" "$current" "$total" "$label" +} + +run_step() { + STEP_CURRENT=$((STEP_CURRENT + 1)) + progress_bar "$STEP_CURRENT" "$STEP_TOTAL" "$1" + shift + "$@" +} + +as_root() { + if [ "$(id -u)" -eq 0 ]; then + "$@" + elif need_cmd sudo; then + sudo "$@" + else + log "Root privileges are required: $*" + exit 1 + fi +} + +prompt_yes_no() { + label="$1" + default="${2:-no}" + suffix="[y/N]" + [ "$default" = "yes" ] && suffix="[Y/n]" + if tty_available; then + printf '%s %s ' "$label" "$suffix" > /dev/tty + IFS= read -r answer < /dev/tty || answer="" + else + printf '%s %s ' "$label" "$suffix" + IFS= read -r answer || answer="" + fi + answer="$(printf '%s' "$answer" | tr '[:upper:]' '[:lower:]')" + if [ -z "$answer" ]; then + [ "$default" = "yes" ] + return + fi + [ "$answer" = "y" ] || [ "$answer" = "yes" ] +} + +agent_already_installed() { + [ -f "/etc/systemd/system/${SERVICE_NAME}.service" ] || [ -f "${INSTALL_DIR}/agent.config.json" ] +} + +handle_existing_install() { + case "$PLATFORM_AGENT_REINSTALL" in + true|1|yes|y|on) + return + ;; + esac + case "$PLATFORM_AGENT_SETTINGS_ONLY" in + true|1|yes|y|on) + if [ -x "${INSTALL_DIR}/scripts/setting.sh" ]; then + exec "${INSTALL_DIR}/scripts/setting.sh" + fi + exec "$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)/setting.sh" + ;; + esac + if ! agent_already_installed; then + return + fi + + log "Autonomy Studio Platform Agent is already installed." + log "Install dir : ${INSTALL_DIR}" + log "Service : ${SERVICE_NAME}" + log "" + + if ! interactive_allowed; then + log "Set PLATFORM_AGENT_REINSTALL=true to reinstall, or PLATFORM_AGENT_SETTINGS_ONLY=true to edit settings." + exit 0 + fi + + log "1. Open settings" + log "2. Reinstall / update" + log "3. Cancel" + log "" + if tty_available; then + printf 'Select [1]: ' > /dev/tty + IFS= read -r choice < /dev/tty || choice="" + else + printf 'Select [1]: ' + IFS= read -r choice || choice="" + fi + case "${choice:-1}" in + 1) + if [ -x "${INSTALL_DIR}/scripts/setting.sh" ]; then + exec "${INSTALL_DIR}/scripts/setting.sh" + fi + exec "$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)/setting.sh" + ;; + 2) + if prompt_yes_no "Continue reinstall/update?" "no"; then + return + fi + log "Canceled." + exit 0 + ;; + *) + log "Canceled." + exit 0 + ;; + esac +} + +install_bun() { + if need_cmd bun; then + return + fi + log "Installing Bun." + curl -fsSL https://bun.sh/install | bash + export PATH="$HOME/.bun/bin:$PATH" +} + +interactive_allowed() { + case "$PLATFORM_AGENT_INTERACTIVE" in + false|0|no|n|off) + return 1 + ;; + true|1|yes|y|on) + has_tty + return + ;; + *) + has_tty + return + ;; + esac +} + +prompt_value() { + var_name="$1" + label="$2" + current_value="$3" + secret="${4:-false}" + if [ "$secret" = "true" ]; then + if tty_available; then + printf '%s [%s]: ' "$label" "hidden" > /dev/tty + stty -echo < /dev/tty || true + IFS= read -r answer < /dev/tty || answer="" + stty echo < /dev/tty || true + printf '\n' > /dev/tty + else + printf '%s [%s]: ' "$label" "hidden" + stty -echo || true + IFS= read -r answer || answer="" + stty echo || true + printf '\n' + fi + else + if tty_available; then + printf '%s [%s]: ' "$label" "$current_value" > /dev/tty + IFS= read -r answer < /dev/tty || answer="" + else + printf '%s [%s]: ' "$label" "$current_value" + IFS= read -r answer || answer="" + fi + fi + if [ -n "$answer" ]; then + eval "$var_name=\"\$answer\"" + fi +} + +run_shell_tui() { + if ! interactive_allowed; then + return 1 + fi + + clear + log "┌────────────────────────────────────────────────────────────────────┐" + log "│ Autonomy Studio Platform Agent Installer │" + log "└────────────────────────────────────────────────────────────────────┘" + log "" + + prompt_value PLATFORM_ACCOUNT "Autonomy Studio account" "$PLATFORM_ACCOUNT" + prompt_value PLATFORM_REGISTRATION_CODE "6-digit registration code" "$PLATFORM_REGISTRATION_CODE" + prompt_value WORKSPACE_ROOT "Workspace root" "$WORKSPACE_ROOT" + prompt_value ROS_DOMAIN_ID "ROS_DOMAIN_ID" "$ROS_DOMAIN_ID" + prompt_value CAMERA_ENABLED "Enable camera true/false" "$CAMERA_ENABLED" + prompt_value CAMERA_VIDEO_DEVICE "Camera device" "$CAMERA_VIDEO_DEVICE" + prompt_value CAMERA_SIGNALING_URL "Camera signaling URL" "$CAMERA_SIGNALING_URL" + prompt_value KASMVNC_GEOMETRY "KasmVNC resolution" "$KASMVNC_GEOMETRY" + provision_tailscale_from_backend + return 0 +} + +run_bubbletea_tui() { + case "$PLATFORM_SKIP_TUI" in + true|1|yes|y|on) + TUI_SKIP_REASON="PLATFORM_SKIP_TUI is enabled." + return 1 + ;; + esac + if ! interactive_allowed; then + TUI_SKIP_REASON="Interactive terminal is not available or PLATFORM_AGENT_INTERACTIVE=false." + return 1 + fi + + env_file="$(mktemp)" + tui_bin="" + current_dir="$(CDPATH= cd -- "$(dirname -- "$0")/.." 2>/dev/null && pwd || printf '')" + tui_arch="" + case "$(uname -m 2>/dev/null || true)" in + x86_64|amd64) tui_arch="amd64" ;; + aarch64|arm64) tui_arch="arm64" ;; + esac + + if [ -n "$PLATFORM_INSTALLER_TUI_URL" ]; then + tui_url="$PLATFORM_INSTALLER_TUI_URL" + if [ -n "$tui_arch" ]; then + tui_url="$(printf '%s' "$tui_url" | sed "s/{arch}/$tui_arch/g")" + fi + tui_bin="$(mktemp)" + curl -fsSL "$tui_url" -o "$tui_bin" + chmod +x "$tui_bin" + set +e + if tty_available; then + "$tui_bin" "$env_file" < /dev/tty > /dev/tty + else + "$tui_bin" "$env_file" + fi + status=$? + set -e + if [ "$status" -ne 0 ]; then + if [ "$status" -eq 130 ]; then + rm -f "$env_file" "$tui_bin" + exit 130 + fi + TUI_SKIP_REASON="Failed to run the TUI downloaded from PLATFORM_INSTALLER_TUI_URL." + rm -f "$env_file" "$tui_bin" + return 1 + fi + elif [ -n "$current_dir" ] && [ -n "$tui_arch" ] && [ -x "$current_dir/scripts/installer-tui/installer-tui-linux-$tui_arch" ]; then + set +e + if tty_available; then + "$current_dir/scripts/installer-tui/installer-tui-linux-$tui_arch" "$env_file" < /dev/tty > /dev/tty + else + "$current_dir/scripts/installer-tui/installer-tui-linux-$tui_arch" "$env_file" + fi + status=$? + set -e + if [ "$status" -ne 0 ]; then + if [ "$status" -eq 130 ]; then + rm -f "$env_file" + exit 130 + fi + TUI_SKIP_REASON="Failed to run the local installer-tui binary." + rm -f "$env_file" + return 1 + fi + elif [ -n "$current_dir" ] && [ -x "$current_dir/scripts/installer-tui/installer-tui" ]; then + # Compatibility with existing single-architecture installer packages. + set +e + if tty_available; then + "$current_dir/scripts/installer-tui/installer-tui" "$env_file" < /dev/tty > /dev/tty + else + "$current_dir/scripts/installer-tui/installer-tui" "$env_file" + fi + status=$? + set -e + if [ "$status" -ne 0 ]; then + if [ "$status" -eq 130 ]; then + rm -f "$env_file" + exit 130 + fi + TUI_SKIP_REASON="Failed to run the local installer-tui binary." + rm -f "$env_file" + return 1 + fi + elif [ -n "$current_dir" ] && [ -f "$current_dir/scripts/installer-tui/main.go" ] && need_cmd go; then + set +e + if tty_available; then + (cd "$current_dir/scripts/installer-tui" && go run . "$env_file") < /dev/tty > /dev/tty + else + (cd "$current_dir/scripts/installer-tui" && go run . "$env_file") + fi + status=$? + set -e + if [ "$status" -ne 0 ]; then + if [ "$status" -eq 130 ]; then + rm -f "$env_file" + exit 130 + fi + TUI_SKIP_REASON="Failed to run the Bubble Tea TUI with go run." + rm -f "$env_file" + return 1 + fi + else + TUI_SKIP_REASON="No TUI binary or Go runtime found. curl | sh installs require PLATFORM_INSTALLER_TUI_URL." + rm -f "$env_file" + return 1 + fi + + # shellcheck disable=SC1090 + . "$env_file" + INSTALL_DIR="${PLATFORM_AGENT_DIR:-$INSTALL_DIR}" + SERVICE_NAME="${PLATFORM_AGENT_SERVICE:-$SERVICE_NAME}" + REPO_URL="${PLATFORM_AGENT_REPO_URL:-$REPO_URL}" + RUN_USER="${PLATFORM_AGENT_USER:-$RUN_USER}" + rm -f "$env_file" + [ -n "${tui_bin:-}" ] && rm -f "$tui_bin" + return 0 +} + +collect_install_config() { + if run_bubbletea_tui; then + return + fi + if run_shell_tui; then + return + fi + if ! interactive_allowed; then + return + fi + + log "Bubble Tea TUI is unavailable. Falling back to shell prompts." + if [ -n "$TUI_SKIP_REASON" ]; then + log "Reason: $TUI_SKIP_REASON" + fi + prompt_value PLATFORM_ACCOUNT "Autonomy Studio account" "$PLATFORM_ACCOUNT" + prompt_value PLATFORM_REGISTRATION_CODE "6-digit registration code" "$PLATFORM_REGISTRATION_CODE" + prompt_value WORKSPACE_ROOT "Workspace root" "$WORKSPACE_ROOT" + prompt_value ROS_DOMAIN_ID "ROS_DOMAIN_ID" "$ROS_DOMAIN_ID" + prompt_value CAMERA_ENABLED "Enable camera true/false" "$CAMERA_ENABLED" + prompt_value CAMERA_VIDEO_DEVICE "Camera device" "$CAMERA_VIDEO_DEVICE" + prompt_value CAMERA_SIGNALING_URL "Camera signaling URL" "$CAMERA_SIGNALING_URL" + prompt_value KASMVNC_GEOMETRY "KasmVNC resolution" "$KASMVNC_GEOMETRY" + provision_tailscale_from_backend +} + +provision_tailscale_from_backend() { + if [ -z "$PLATFORM_ACCOUNT" ] || [ -z "$PLATFORM_REGISTRATION_CODE" ]; then + log "Provisioning requires both account and registration code." + exit 1 + fi + if [ -z "$PLATFORM_PROVISION_URL" ]; then + log "PLATFORM_PROVISION_URL is required for account provisioning." + exit 1 + fi + if ! need_cmd curl; then + log "Provisioning requires curl." + exit 1 + fi + if ! need_cmd python3; then + log "Shell fallback provisioning requires python3. Use the TUI binary or pass environment variables directly." + exit 1 + fi + + body_file="$(mktemp)" + resp_file="$(mktemp)" + env_file="$(mktemp)" + legacy_body_file="$(mktemp)" + chmod 600 "$body_file" "$legacy_body_file" "$resp_file" "$env_file" + PLATFORM_ACCOUNT="$PLATFORM_ACCOUNT" PLATFORM_REGISTRATION_CODE="$PLATFORM_REGISTRATION_CODE" RUN_USER="$RUN_USER" WORKSPACE_ROOT="$WORKSPACE_ROOT" python3 -c 'import json, os, platform +hostname = os.uname().nodename +distro = platform.platform() +ssh_user = os.environ.get("RUN_USER") or os.environ.get("USER") or "" +workspace_root = os.environ.get("WORKSPACE_ROOT") or "~/autonomy-studio/workspaces" +try: + with open("/etc/os-release", "r", encoding="utf-8") as fp: + for line in fp: + if line.startswith("PRETTY_NAME="): + distro = line.split("=", 1)[1].strip().strip("\"") + break +except OSError: + pass +device = { + "hostname": hostname, + "os": os.uname().sysname.lower(), + "arch": {"x86_64": "amd64", "AMD64": "amd64", "aarch64": "arm64", "armv8": "arm64"}.get(platform.machine(), platform.machine()), + "kernel": os.uname().release, + "distro": distro, + "sshUser": ssh_user, + "workspaceRoot": workspace_root, +} +print(json.dumps({ + "account": os.environ["PLATFORM_ACCOUNT"], + "userCode": os.environ["PLATFORM_REGISTRATION_CODE"], + "hostname": hostname, + "os": device["os"], + "arch": device["arch"], + "kernel": device["kernel"], + "distro": device["distro"], + "sshUser": ssh_user, + "workspaceRoot": workspace_root, + "device": device, +}))' > "$body_file" + PLATFORM_ACCOUNT="$PLATFORM_ACCOUNT" PLATFORM_REGISTRATION_CODE="$PLATFORM_REGISTRATION_CODE" RUN_USER="$RUN_USER" WORKSPACE_ROOT="$WORKSPACE_ROOT" python3 -c 'import json, os +hostname = os.uname().nodename +print(json.dumps({ + "account": os.environ["PLATFORM_ACCOUNT"], + "userCode": os.environ["PLATFORM_REGISTRATION_CODE"], + "hostname": hostname, + "sshUser": os.environ.get("RUN_USER") or os.environ.get("USER") or "", + "workspaceRoot": os.environ.get("WORKSPACE_ROOT") or "~/autonomy-studio/workspaces", +}))' > "$legacy_body_file" + http_status="$(curl -sS -H "Content-Type: application/json" --data-binary "@$body_file" "$PLATFORM_PROVISION_URL/device-authorizations/consume" -o "$resp_file" -w "%{http_code}" || printf '000')" + if [ "$http_status" = "400" ]; then + http_status="$(curl -sS -H "Content-Type: application/json" --data-binary "@$legacy_body_file" "$PLATFORM_PROVISION_URL/device-authorizations/consume" -o "$resp_file" -w "%{http_code}" || printf '000')" + fi + if [ "$http_status" -lt 200 ] || [ "$http_status" -ge 300 ]; then + rm -f "$body_file" "$legacy_body_file" "$resp_file" "$env_file" + log "Provisioning request failed." + exit 1 + fi + python3 - "$resp_file" > "$env_file" <<'PY' +import json +import shlex +import sys + +with open(sys.argv[1], "r", encoding="utf-8") as fp: + data = json.load(fp) + +def first(*keys): + for key in keys: + value = data.get(key) + if value is not None and value != "": + if isinstance(value, bool): + return "true" if value else "false" + return str(value) + return "" + +values = { + "INSTALL_TAILSCALE": first("installTailscale", "install_tailscale", "tailscaleEnabled", "tailscale_enabled"), + "TAILSCALE_AUTH_KEY": first("tailscaleAuthKey", "tailscale_auth_key", "authKey", "auth_key"), + "TAILSCALE_LOGIN_SERVER": first("tailscaleLoginServer", "tailscale_login_server", "loginServer", "login_server"), + "TAILSCALE_HOSTNAME": first("tailscaleHostname", "tailscale_hostname", "hostname"), + "TAILSCALE_EXTRA_ARGS": first("tailscaleExtraArgs", "tailscale_extra_args", "extraArgs", "extra_args"), + "AGENT_GATEWAY_TOKEN": first("agentGatewayToken", "agent_gateway_token", "gatewayToken", "gateway_token"), +} +if not values["INSTALL_TAILSCALE"] and values["TAILSCALE_AUTH_KEY"]: + values["INSTALL_TAILSCALE"] = "true" +for key, value in values.items(): + if value: + print(f"{key}={shlex.quote(value)}") +PY + # shellcheck disable=SC1090 + . "$env_file" + rm -f "$body_file" "$legacy_body_file" "$resp_file" "$env_file" +} + +install_tailscale() { + case "$INSTALL_TAILSCALE" in + true|1|yes|y|on) ;; + *) + return + ;; + esac + + if [ "$(uname -s)" != "Linux" ]; then + log "Automatic Tailscale installation is supported only on Linux. Skipping." + return + fi + + if ! need_cmd tailscale; then + log "Installing Tailscale." + curl -fsSL https://tailscale.com/install.sh | sh + fi + + if need_cmd systemctl; then + as_root systemctl enable --now tailscaled + elif need_cmd service; then + as_root service tailscaled start || true + fi + + if [ -n "$TAILSCALE_AUTH_KEY" ]; then + log "Joining the Tailscale network." + set -- up --auth-key "$TAILSCALE_AUTH_KEY" + if [ -n "$TAILSCALE_LOGIN_SERVER" ]; then + set -- "$@" --login-server "$TAILSCALE_LOGIN_SERVER" + fi + if [ -n "$TAILSCALE_HOSTNAME" ]; then + set -- "$@" --hostname "$TAILSCALE_HOSTNAME" + fi + if [ -n "$TAILSCALE_EXTRA_ARGS" ]; then + # shellcheck disable=SC2086 + set -- "$@" $TAILSCALE_EXTRA_ARGS + fi + tailscale_error_file="$(mktemp)" + if ! as_root tailscale "$@" 2>"$tailscale_error_file"; then + tailscale_error="$(cat "$tailscale_error_file" 2>/dev/null || true)" + rm -f "$tailscale_error_file" + case "$tailscale_error" in + *"requires mentioning all"*|*"--reset"*) + log "Existing Tailscale settings detected. Retrying with --reset." + set -- "$@" --reset + as_root tailscale "$@" + ;; + *) + printf '%s\n' "$tailscale_error" >&2 + exit 1 + ;; + esac + else + rm -f "$tailscale_error_file" + fi + else + log "TAILSCALE_AUTH_KEY is empty. tailscaled was enabled but not joined." + fi + + if [ "$RUN_USER" != "root" ]; then + log "Allowing ${RUN_USER} to run Tailscale without root." + as_root tailscale set --operator="$RUN_USER" || log "Failed to set Tailscale operator for ${RUN_USER}." + fi +} + +install_openssh_server() { + case "$INSTALL_OPENSSH_SERVER" in + true|1|yes|y|on) ;; + *) + return + ;; + esac + + if [ "$(uname -s)" != "Linux" ]; then + log "Automatic OpenSSH server installation is supported only on Linux. Skipping." + return + fi + + if need_cmd sshd || [ -x /usr/sbin/sshd ]; then + log "OpenSSH server is already installed." + elif need_cmd apt-get; then + log "Installing OpenSSH server with apt." + as_root apt-get update + as_root apt-get install -y openssh-server + elif need_cmd dnf; then + log "Installing OpenSSH server with dnf." + as_root dnf install -y openssh-server + elif need_cmd yum; then + log "Installing OpenSSH server with yum." + as_root yum install -y openssh-server + elif need_cmd pacman; then + log "Installing OpenSSH server with pacman." + as_root pacman -Sy --noconfirm openssh + elif need_cmd zypper; then + log "Installing OpenSSH server with zypper." + as_root zypper --non-interactive install openssh + else + log "No supported package manager found. Install OpenSSH server manually." + return + fi + + if need_cmd systemctl; then + if systemctl list-unit-files ssh.service >/dev/null 2>&1; then + as_root systemctl enable --now ssh + elif systemctl list-unit-files sshd.service >/dev/null 2>&1; then + as_root systemctl enable --now sshd + else + as_root systemctl restart ssh || as_root systemctl restart sshd || true + fi + elif need_cmd service; then + as_root service ssh start || as_root service sshd start || true + fi +} + +install_git() { + case "$INSTALL_GIT" in + true|1|yes|y|on) ;; + *) + return + ;; + esac + + if need_cmd git; then + log "Git is already installed." + return + fi + + if [ "$(uname -s)" != "Linux" ]; then + log "Automatic Git installation is supported only on Linux. Skipping." + return + fi + + if need_cmd apt-get; then + log "Installing Git with apt." + as_root apt-get update + as_root apt-get install -y git + elif need_cmd dnf; then + log "Installing Git with dnf." + as_root dnf install -y git + elif need_cmd yum; then + log "Installing Git with yum." + as_root yum install -y git + elif need_cmd pacman; then + log "Installing Git with pacman." + as_root pacman -Sy --noconfirm git + elif need_cmd zypper; then + log "Installing Git with zypper." + as_root zypper --non-interactive install git + else + log "No supported package manager found. Install Git manually." + fi +} + +prepare_sources() { + if [ -n "$REPO_URL" ]; then + as_root mkdir -p "$INSTALL_DIR" + if [ -d "$INSTALL_DIR/.git" ]; then + as_root git -C "$INSTALL_DIR" pull --ff-only + else + tmp_dir="$(mktemp -d)" + git clone "$REPO_URL" "$tmp_dir" + as_root cp -a "$tmp_dir/." "$INSTALL_DIR/" + rm -rf "$tmp_dir" + fi + else + current_dir="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" + as_root mkdir -p "$INSTALL_DIR" + as_root cp -a "$current_dir/." "$INSTALL_DIR/" + fi + as_root chown -R "$RUN_USER":"$RUN_USER" "$INSTALL_DIR" +} + +install_dependencies() { + (cd "$INSTALL_DIR" && bun install) +} + +write_agent_config_from_env() { + ( + cd "$INSTALL_DIR" + AGENT_CONFIG_PATH="$INSTALL_DIR/agent.config.json" \ + WORKSPACE_ROOT="$WORKSPACE_ROOT" \ + ROBOT_STACK="$ROBOT_STACK" \ + ROS_DOMAIN_ID="$ROS_DOMAIN_ID" \ + CAMERA_ENABLED="$CAMERA_ENABLED" \ + CAMERA_VIDEO_DEVICE="$CAMERA_VIDEO_DEVICE" \ + CAMERA_SIGNALING_URL="$CAMERA_SIGNALING_URL" \ + KASMVNC_GEOMETRY="$KASMVNC_GEOMETRY" \ + AGENT_GATEWAY_TOKEN="$AGENT_GATEWAY_TOKEN" \ + bun -e ' + const fs = require("fs"); + const path = process.env.AGENT_CONFIG_PATH; + const cfg = JSON.parse(fs.readFileSync(path, "utf8")); + cfg.security ??= {}; + cfg.workspace ??= {}; + cfg.ros ??= {}; + cfg.camera ??= {}; + cfg.desktop ??= {}; + cfg.security.gatewayToken = process.env.AGENT_GATEWAY_TOKEN || cfg.security.gatewayToken || ""; + cfg.workspace.root = process.env.WORKSPACE_ROOT || cfg.workspace.root || "~/autonomy-studio/workspaces"; + cfg.ros.stack = process.env.ROBOT_STACK === "nav2" ? "nav2" : "autoware"; + cfg.ros.domainId = /^\d+$/.test(process.env.ROS_DOMAIN_ID || "") ? process.env.ROS_DOMAIN_ID : "0"; + cfg.camera.enabled = /^(true|1|yes|y|on)$/i.test(process.env.CAMERA_ENABLED || ""); + cfg.camera.videoDevice = process.env.CAMERA_VIDEO_DEVICE || cfg.camera.videoDevice || "/dev/video0"; + cfg.camera.signalingUrl = process.env.CAMERA_SIGNALING_URL || cfg.camera.signalingUrl || "ws://127.0.0.1:3000"; + cfg.desktop.geometry = process.env.KASMVNC_GEOMETRY || cfg.desktop.geometry || "1600x900"; + fs.writeFileSync(path, JSON.stringify(cfg, null, 2) + "\n"); + ' + ) +} + +write_service() { + service_path="/etc/systemd/system/${SERVICE_NAME}.service" + tmp_service="$(mktemp)" + bun_bin="$(command -v bun)" + cat > "$tmp_service" <:3100" + if need_cmd tailscale; then + ts_ip="$(tailscale ip -4 2>/dev/null | head -n 1 || true)" + if [ -n "$ts_ip" ]; then + log "Tailscale Settings UI: http://${ts_ip}:3100" + fi + fi + log "" + log "Automatic Tailscale install example:" + log "INSTALL_TAILSCALE=true TAILSCALE_AUTH_KEY=tskey-auth-... sh scripts/install.sh" + log "" + log "Optional host dependencies:" + log "- KasmVNC/XFCE: vncserver or kasmvncserver, XFCE" + log "- Camera: GStreamer, v4l-utils, python3-gi, websockets" + log "- ROS bridge: ROS2 Humble, FastAPI requirements, Autoware/Nav2 message packages" + log "- Docker module runtime: docker, nvidia-container-toolkit, or the device-specific GPU runtime" +} + +handle_existing_install +collect_install_config +run_step "Install Tailscale if requested" install_tailscale +run_step "Install OpenSSH server if needed" install_openssh_server +run_step "Install Git if needed" install_git +run_step "Install Bun if needed" install_bun +run_step "Prepare sources" prepare_sources +run_step "Install dependencies" install_dependencies +run_step "Write agent config" write_agent_config_from_env +run_step "Install systemd service" write_service +print_host_notes diff --git a/scripts/installer-tui/go.mod b/scripts/installer-tui/go.mod new file mode 100644 index 0000000..56ef5a2 --- /dev/null +++ b/scripts/installer-tui/go.mod @@ -0,0 +1,39 @@ +module platform-agent-installer-tui + +go 1.22 + +require ( + github.com/charmbracelet/bubbles v0.20.0 + github.com/charmbracelet/bubbletea v1.2.4 + github.com/charmbracelet/glamour v0.8.0 + github.com/charmbracelet/lipgloss v1.0.0 + github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a +) + +require ( + github.com/alecthomas/chroma/v2 v2.14.0 // indirect + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/aymerick/douceur v0.2.0 // indirect + github.com/charmbracelet/x/ansi v0.4.5 // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/dlclark/regexp2 v1.11.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/gorilla/css v1.0.1 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/yuin/goldmark v1.7.4 // indirect + github.com/yuin/goldmark-emoji v1.0.3 // indirect + golang.org/x/net v0.27.0 // indirect + golang.org/x/sync v0.9.0 // indirect + golang.org/x/sys v0.27.0 // indirect + golang.org/x/term v0.22.0 // indirect + golang.org/x/text v0.16.0 // indirect +) diff --git a/scripts/installer-tui/go.sum b/scripts/installer-tui/go.sum new file mode 100644 index 0000000..d7d4dd9 --- /dev/null +++ b/scripts/installer-tui/go.sum @@ -0,0 +1,76 @@ +github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE= +github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= +github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I= +github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= +github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= +github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE= +github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU= +github.com/charmbracelet/bubbletea v1.2.4 h1:KN8aCViA0eps9SCOThb2/XPIlea3ANJLUkv3KnQRNCE= +github.com/charmbracelet/bubbletea v1.2.4/go.mod h1:Qr6fVQw+wX7JkWWkVyXYk/ZUQ92a6XNekLXa3rR18MM= +github.com/charmbracelet/glamour v0.8.0 h1:tPrjL3aRcQbn++7t18wOpgLyl8wrOHUEDS7IZ68QtZs= +github.com/charmbracelet/glamour v0.8.0/go.mod h1:ViRgmKkf3u5S7uakt2czJ272WSg2ZenlYEZXT2x7Bjw= +github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg= +github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo= +github.com/charmbracelet/x/ansi v0.4.5 h1:LqK4vwBNaXw2AyGIICa5/29Sbdq58GbGdFngSexTdRM= +github.com/charmbracelet/x/ansi v0.4.5/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= +github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q= +github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a h1:2MaM6YC3mGu54x+RKAA6JiFFHlHDY1UbkxqppT7wYOg= +github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a/go.mod h1:hxSnBBYLK21Vtq/PHd0S2FYCxBXzBua8ov5s1RobyRQ= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +github.com/yuin/goldmark v1.7.4 h1:BDXOHExt+A7gwPCJgPIIq7ENvceR7we7rOS9TNoLZeg= +github.com/yuin/goldmark v1.7.4/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +github.com/yuin/goldmark-emoji v1.0.3 h1:aLRkLHOuBR2czCY4R8olwMjID+tENfhyFDMCRhbIQY4= +github.com/yuin/goldmark-emoji v1.0.3/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= +golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= +golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= +golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ= +golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= +golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk= +golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= diff --git a/scripts/installer-tui/installer-tui b/scripts/installer-tui/installer-tui new file mode 100755 index 0000000..ebbd8b1 Binary files /dev/null and b/scripts/installer-tui/installer-tui differ diff --git a/scripts/installer-tui/installer-tui-linux-amd64 b/scripts/installer-tui/installer-tui-linux-amd64 new file mode 100755 index 0000000..96c13e9 Binary files /dev/null and b/scripts/installer-tui/installer-tui-linux-amd64 differ diff --git a/scripts/installer-tui/installer-tui-linux-arm64 b/scripts/installer-tui/installer-tui-linux-arm64 new file mode 100755 index 0000000..c0da45a Binary files /dev/null and b/scripts/installer-tui/installer-tui-linux-arm64 differ diff --git a/scripts/installer-tui/main.go b/scripts/installer-tui/main.go new file mode 100644 index 0000000..c7fcb55 --- /dev/null +++ b/scripts/installer-tui/main.go @@ -0,0 +1,946 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "regexp" + "runtime" + "strconv" + "strings" + "time" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/glamour" + "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" +) + +type question struct { + key string + title string + prompt string + def string + secret bool + example string + options []string +} + +var questions = []question{ + {"PLATFORM_ACCOUNT", "Autonomy Studio account", "Enter the email address used in Autonomy Studio.", "", false, "name@example.com", nil}, + {"PLATFORM_REGISTRATION_CODE", "Registration code", "Enter the 6-digit code created in Launcher Platform management.", "", false, "000000", nil}, + {"USAGE_TYPE", "Usage type", "Select how this device will be used.", "개인용", false, "", []string{"개인용", "교보재용"}}, + {"DEVICE_HOSTNAME", "Device hostname", "Hostname to register this device with. Press Enter to keep the detected hostname.", "", false, "", nil}, + {"WORKSPACE_ROOT", "Workspace", "Which directory should the platform expose as the workspace?", "~/autonomy-studio/workspaces", false, "", nil}, + {"CAMERA_VIDEO_DEVICE", "Video device", "Which V4L2 camera device should be used?", "/dev/video0", false, "/dev/video0", nil}, +} + +type model struct { + input textinput.Model + envPath string + index int + values map[string]string + choices map[string]int + provision map[string]string + provisionSummary string + showProvision bool + loading bool + err error + done bool + width int + height int +} + +type provisionResultMsg struct { + values map[string]string + summary string + err error +} + +var ( + accent = lipgloss.Color("81") + accentStrong = lipgloss.Color("87") + muted = lipgloss.Color("245") + panelBorder = lipgloss.Color("240") + panelStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(panelBorder).Padding(1, 2) + brandStyle = lipgloss.NewStyle().Bold(true).Foreground(accentStrong) + titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("255")) + promptStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("255")) + valueStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("229")) + mutedStyle = lipgloss.NewStyle().Foreground(muted) + errStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203")) + okStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("42")) + inputStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(accent).Padding(0, 1) + keyStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("244")) + selectedStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("255")).Background(lipgloss.Color("24")).Padding(0, 1) + optionStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("250")).Padding(0, 1) +) + +func init() { + lipgloss.SetColorProfile(termenv.ANSI256) +} + +func envDefault(key, fallback string) string { + if value := os.Getenv(key); value != "" { + return value + } + return fallback +} + +func initialModel(envPath string) model { + input := textinput.New() + input.Prompt = "" + input.CharLimit = 512 + input.Width = 56 + input.Focus() + values := map[string]string{} + m := model{input: input, envPath: envPath, values: values, choices: map[string]int{}, provision: map[string]string{}, width: 86, height: 24} + m.loadQuestion() + return m +} + +func (m *model) loadQuestion() { + q := questions[m.index] + value := envDefault(q.key, questionDefault(q)) + if existing, ok := m.values[q.key]; ok { + value = existing + } + if len(q.options) > 0 { + m.choices[q.key] = optionIndex(q.options, value) + m.input.SetValue("") + return + } + if _, ok := m.values[q.key]; ok { + m.input.SetValue(value) + } else if q.key == "DEVICE_HOSTNAME" || q.key == "WORKSPACE_ROOT" { + m.input.SetValue(value) + } else { + m.input.SetValue("") + } + m.input.Placeholder = q.example + m.input.EchoMode = textinput.EchoNormal + if q.secret { + m.input.EchoMode = textinput.EchoPassword + m.input.EchoCharacter = '*' + } +} + +func questionDefault(q question) string { + if q.key == "DEVICE_HOSTNAME" { + hostname, _ := os.Hostname() + if strings.TrimSpace(hostname) != "" { + return strings.TrimSpace(hostname) + } + } + if q.key == "WORKSPACE_ROOT" { + return envDefault("WORKSPACE_ROOT", "~/autonomy-studio/workspaces") + } + return q.def +} + +func questionExample(q question) string { + return q.example +} + +func (m model) Init() tea.Cmd { + return textinput.Blink +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case provisionResultMsg: + m.loading = false + if msg.err != nil { + m.err = msg.err + return m, nil + } + m.err = nil + m.provision = msg.values + m.provisionSummary = msg.summary + m.showProvision = true + return m, nil + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.input.Width = max(28, min(64, msg.Width-24)) + case tea.KeyMsg: + if isTerminalColorResponse(msg.String()) { + return m, nil + } + if m.loading { + switch msg.String() { + case "ctrl+c", "esc": + return m, tea.Quit + } + return m, nil + } + if m.showProvision { + switch msg.String() { + case "ctrl+c", "esc": + return m, tea.Quit + case "enter": + m.showProvision = false + m.index++ + m.loadQuestion() + return m, nil + case "left": + m.showProvision = false + m.index = 2 + m.loadQuestion() + return m, nil + } + return m, nil + } + switch msg.String() { + case "ctrl+c", "esc": + return m, tea.Quit + case "enter": + q := questions[m.index] + value := m.currentValue() + if err := m.validate(q, value); err != nil { + m.err = err + break + } + m.err = nil + m.values[q.key] = value + if q.key == "WORKSPACE_ROOT" { + account := strings.TrimSpace(m.values["PLATFORM_ACCOUNT"]) + registrationCode := strings.TrimSpace(m.values["PLATFORM_REGISTRATION_CODE"]) + usageType := strings.TrimSpace(m.values["USAGE_TYPE"]) + deviceHostname := strings.TrimSpace(m.values["DEVICE_HOSTNAME"]) + workspaceRoot := strings.TrimSpace(value) + m.loading = true + return m, provisionWithRegistrationCode(account, registrationCode, usageType, deviceHostname, workspaceRoot, m.width) + } + if m.index == len(questions)-1 { + m.err = m.writeEnv() + m.done = m.err == nil + return m, tea.Quit + } + m.index++ + m.loadQuestion() + case "backspace", "ctrl+h", "ctrl+?": + m.err = nil + case "up": + if len(questions[m.index].options) > 0 { + m.prevChoice() + } + case "down": + if len(questions[m.index].options) > 0 { + m.nextChoice() + } + case "left": + if m.index > 0 { + m.values[questions[m.index].key] = m.currentValue() + m.index-- + m.loadQuestion() + m.err = nil + return m, nil + } + } + } + + if len(questions[m.index].options) > 0 { + return m, nil + } + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + m.input.SetValue(sanitizeInput(m.input.Value())) + return m, cmd +} + +func (m model) currentValue() string { + q := questions[m.index] + if len(q.options) > 0 { + index := m.choices[q.key] + if index < 0 || index >= len(q.options) { + index = optionIndex(q.options, questionDefault(q)) + } + return q.options[index] + } + value := strings.TrimSpace(sanitizeInput(m.input.Value())) + if value == "" { + return questionDefault(q) + } + return value +} + +func (m *model) nextChoice() { + q := questions[m.index] + if len(q.options) == 0 { + return + } + m.choices[q.key] = (m.choices[q.key] + 1) % len(q.options) + m.err = nil +} + +func (m *model) prevChoice() { + q := questions[m.index] + if len(q.options) == 0 { + return + } + next := m.choices[q.key] - 1 + if next < 0 { + next = len(q.options) - 1 + } + m.choices[q.key] = next + m.err = nil +} + +func optionIndex(options []string, value string) int { + for i, option := range options { + if strings.EqualFold(option, value) { + return i + } + } + return 0 +} + +func (m model) validate(q question, value string) error { + if q.key == "PLATFORM_ACCOUNT" && !strings.Contains(value, "@") { + return fmt.Errorf("Enter the account email address.") + } + if q.key == "PLATFORM_REGISTRATION_CODE" && !regexp.MustCompile(`^\d{6}$`).MatchString(strings.TrimSpace(value)) { + return fmt.Errorf("Enter the 6-digit registration code.") + } + return nil +} + +func isTerminalColorResponse(value string) bool { + return strings.Contains(value, "rgb:") || strings.HasPrefix(value, "]10;") || strings.HasPrefix(value, "]11;") +} + +func sanitizeInput(value string) string { + value = stripAfter(value, "\x1b]10;") + value = stripAfter(value, "\x1b]11;") + value = stripAfter(value, "]10;") + value = stripAfter(value, "]11;") + value = stripAfter(value, "rgb:") + if value == "]" || value == "\\" || value == "\x1b\\" { + return "" + } + return strings.ReplaceAll(value, "\x1b\\", "") +} + +func stripAfter(value string, marker string) string { + if index := strings.Index(value, marker); index >= 0 { + return value[:index] + } + return value +} + +func shellQuote(value string) string { + return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'" +} + +func provisionDevice(account string, password string, usageType string, deviceHostname string, workspaceRoot string, width int) tea.Cmd { + return func() tea.Msg { + values, summary, err := fetchProvision(account, password, usageType, deviceHostname, workspaceRoot, width) + return provisionResultMsg{values: values, summary: summary, err: err} + } +} + +func buildProvisionDevice(deviceHostname string, workspaceRoot string, usageType string) map[string]string { + device := detectDevice() + if strings.TrimSpace(deviceHostname) != "" { + device["hostname"] = strings.TrimSpace(deviceHostname) + } + sshUser := envDefault("PLATFORM_AGENT_USER", envDefault("USER", "")) + if sshUser != "" { + device["sshUser"] = sshUser + } + device["workspaceRoot"] = strings.TrimSpace(workspaceRoot) + device["usageType"] = normalizeUsageType(usageType) + return device +} + +func provisionWithRegistrationCode(account string, registrationCode string, usageType string, deviceHostname string, workspaceRoot string, width int) tea.Cmd { + return func() tea.Msg { + account = strings.TrimSpace(account) + registrationCode = strings.TrimSpace(registrationCode) + usageType = normalizeUsageType(usageType) + if strings.TrimSpace(workspaceRoot) == "" { + workspaceRoot = "~/autonomy-studio/workspaces" + } + if account == "" || registrationCode == "" { + return provisionResultMsg{err: fmt.Errorf("Autonomy Studio account and registration code are required.")} + } + baseURL := strings.TrimRight(strings.TrimSpace(envDefault("PLATFORM_PROVISION_URL", "https://portal-api.autonomystudio.net/api/provision")), "/") + if baseURL == "" { + return provisionResultMsg{err: fmt.Errorf("PLATFORM_PROVISION_URL is required for device registration.")} + } + + device := buildProvisionDevice(deviceHostname, workspaceRoot, usageType) + payload := map[string]any{ + "account": account, + "userCode": registrationCode, + "hostname": device["hostname"], + "os": device["os"], + "arch": device["arch"], + "kernel": device["kernel"], + "distro": device["distro"], + "usageType": usageType, + "sshUser": device["sshUser"], + "workspaceRoot": workspaceRoot, + "device": device, + } + body, status, err := postProvision(baseURL+"/device-authorizations/consume", payload) + if err != nil { + return provisionResultMsg{err: fmt.Errorf("Device registration failed: %w", err)} + } + if status < 200 || status >= 300 { + return provisionResultMsg{err: fmt.Errorf("Device registration failed: HTTP %d: %s", status, strings.TrimSpace(string(body)))} + } + values, err := parseProvisionValues(body) + if err != nil { + return provisionResultMsg{err: err} + } + return provisionResultMsg{values: values, summary: renderProvisionSummary(values, device, usageType, "Registered", width)} + } +} + +func fetchProvision(account string, password string, usageType string, deviceHostname string, workspaceRoot string, width int) (map[string]string, string, error) { + account = strings.TrimSpace(account) + password = strings.TrimSpace(password) + usageType = normalizeUsageType(usageType) + deviceHostname = strings.TrimSpace(deviceHostname) + workspaceRoot = strings.TrimSpace(workspaceRoot) + if workspaceRoot == "" { + workspaceRoot = "~/autonomy-studio/workspaces" + } + if account == "" || password == "" { + return nil, "", fmt.Errorf("Autonomy Studio account and password are required.") + } + url := strings.TrimSpace(envDefault("PLATFORM_PROVISION_URL", "https://portal-api.autonomystudio.net/api/provision")) + if url == "" { + return nil, "", fmt.Errorf("PLATFORM_PROVISION_URL is required for account provisioning.") + } + + device := detectDevice() + if deviceHostname != "" { + device["hostname"] = deviceHostname + } + sshUser := envDefault("PLATFORM_AGENT_USER", envDefault("USER", "")) + if sshUser != "" { + device["sshUser"] = sshUser + } + device["workspaceRoot"] = workspaceRoot + payload := map[string]any{ + "account": account, + "password": password, + "hostname": device["hostname"], + "os": device["os"], + "arch": device["arch"], + "kernel": device["kernel"], + "distro": device["distro"], + "device": device, + "usageType": usageType, + "sshUser": sshUser, + "workspaceRoot": workspaceRoot, + } + device["usageType"] = usageType + respBody, status, err := postProvision(url, payload) + if err != nil { + return nil, "", fmt.Errorf("Provision request failed: %w", err) + } + if status == http.StatusBadRequest { + legacyPayload := map[string]any{ + "account": account, + "password": password, + "hostname": device["hostname"], + "usageType": usageType, + "sshUser": sshUser, + "workspaceRoot": workspaceRoot, + } + respBody, status, err = postProvision(url, legacyPayload) + if err != nil { + return nil, "", fmt.Errorf("Provision retry failed: %w", err) + } + } + if status < 200 || status >= 300 { + detail := strings.TrimSpace(string(respBody)) + if detail != "" { + return nil, "", fmt.Errorf("Provision request failed: HTTP %d: %s", status, detail) + } + return nil, "", fmt.Errorf("Provision request failed: HTTP %d", status) + } + + values, err := parseProvisionValues(respBody) + if err != nil { + return nil, "", err + } + return values, renderProvisionSummary(values, device, usageType, "Received", width), nil +} + +func parseProvisionValues(respBody []byte) (map[string]string, error) { + var data map[string]any + if err := json.Unmarshal(respBody, &data); err != nil { + return nil, fmt.Errorf("Provision response must be JSON.") + } + values := map[string]string{} + setProvision(values, "INSTALL_TAILSCALE", firstBool(data, "installTailscale", "install_tailscale", "tailscaleEnabled", "tailscale_enabled")) + setProvision(values, "TAILSCALE_AUTH_KEY", firstString(data, "tailscaleAuthKey", "tailscale_auth_key", "authKey", "auth_key")) + setProvision(values, "TAILSCALE_LOGIN_SERVER", firstString(data, "tailscaleLoginServer", "tailscale_login_server", "loginServer", "login_server")) + setProvision(values, "TAILSCALE_HOSTNAME", firstString(data, "tailscaleHostname", "tailscale_hostname", "hostname")) + setProvision(values, "TAILSCALE_EXTRA_ARGS", firstString(data, "tailscaleExtraArgs", "tailscale_extra_args", "extraArgs", "extra_args")) + setProvision(values, "AGENT_GATEWAY_TOKEN", firstString(data, "agentGatewayToken", "agent_gateway_token", "gatewayToken", "gateway_token")) + if values["INSTALL_TAILSCALE"] == "" && values["TAILSCALE_AUTH_KEY"] != "" { + values["INSTALL_TAILSCALE"] = "true" + } + return values, nil +} + +func normalizeUsageType(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "교보재용", "use for class", "class", "education", "educational", "training", "classroom": + return "class" + default: + return "personal" + } +} + +func postProvision(url string, payload map[string]any) ([]byte, int, error) { + body, err := json.Marshal(payload) + if err != nil { + return nil, 0, err + } + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return nil, 0, err + } + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 20 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + return respBody, resp.StatusCode, nil +} + +func detectDevice() map[string]string { + hostname, _ := os.Hostname() + device := map[string]string{ + "hostname": hostname, + "os": runtime.GOOS, + "arch": runtime.GOARCH, + "kernel": strings.TrimSpace(runOutput("uname", "-r")), + "distro": osReleasePrettyName(), + } + if device["kernel"] == "" { + device["kernel"] = "-" + } + if device["distro"] == "" { + device["distro"] = runtime.GOOS + } + return device +} + +func runOutput(name string, args ...string) string { + output, err := exec.Command(name, args...).Output() + if err != nil { + return "" + } + return string(output) +} + +func osReleasePrettyName() string { + content, err := os.ReadFile("/etc/os-release") + if err != nil { + return "" + } + for _, line := range strings.Split(string(content), "\n") { + key, value, ok := strings.Cut(line, "=") + if !ok || key != "PRETTY_NAME" { + continue + } + value = strings.Trim(value, `"`) + return strings.TrimSpace(value) + } + return "" +} + +func setProvision(values map[string]string, key string, value string) { + if strings.TrimSpace(value) != "" { + values[key] = strings.TrimSpace(value) + } +} + +func firstString(data map[string]any, keys ...string) string { + for _, key := range keys { + if value, ok := data[key]; ok { + switch v := value.(type) { + case string: + return v + case float64: + return strconv.FormatFloat(v, 'f', -1, 64) + case bool: + if v { + return "true" + } + return "false" + } + } + } + return "" +} + +func firstBool(data map[string]any, keys ...string) string { + for _, key := range keys { + if value, ok := data[key]; ok { + switch v := value.(type) { + case bool: + if v { + return "true" + } + return "false" + case string: + switch strings.ToLower(strings.TrimSpace(v)) { + case "true", "1", "yes", "y", "on": + return "true" + case "false", "0", "no", "n", "off": + return "false" + } + } + } + } + return "" +} + +func renderProvisionSummary(values map[string]string, device map[string]string, usageType string, status string, width int) string { + authKeyStatus := "not provided" + if values["TAILSCALE_AUTH_KEY"] != "" { + authKeyStatus = "received" + } + loginServer := displayValue(valueOrDash(values["TAILSCALE_LOGIN_SERVER"])) + hostname := valueOrDash(values["TAILSCALE_HOSTNAME"]) + extraArgs := valueOrDash(values["TAILSCALE_EXTRA_ARGS"]) + gatewayTokenStatus := "not provided" + if values["AGENT_GATEWAY_TOKEN"] != "" { + gatewayTokenStatus = "received" + } + installTailscale := valueOr(values["INSTALL_TAILSCALE"], "false") + + header := renderMarkdown(fmt.Sprintf(`# Provisioning Summary + +Status: **%s** + +## Network +`, status), width) + + rows := [][2]string{ + {"Usage type", normalizeUsageType(usageType)}, + {"Device hostname", valueOrDash(device["hostname"])}, + {"Device OS", valueOrDash(device["distro"])}, + {"Architecture", valueOrDash(device["arch"])}, + {"Kernel", valueOrDash(device["kernel"])}, + {"Install Tailscale", installTailscale}, + {"Tailscale auth key", authKeyStatus}, + {"Login server", loginServer}, + {"Tailscale hostname", hostname}, + {"Extra args", extraArgs}, + {"Agent gateway token", gatewayTokenStatus}, + } + return strings.TrimSpace(header + "\n" + renderSummaryTable(rows, width) + "\n\n" + mutedStyle.Render("These values will be applied when installation starts.")) +} + +func renderMarkdown(markdown string, width int) string { + renderer, err := glamour.NewTermRenderer( + glamour.WithStandardStyle("dark"), + glamour.WithWordWrap(max(40, width-14)), + ) + if err != nil { + return markdown + } + rendered, err := renderer.Render(markdown) + if err != nil { + return markdown + } + return strings.TrimSpace(rendered) +} + +func renderSummaryTable(rows [][2]string, width int) string { + tableWidth := min(max(56, width-18), 82) + labelWidth := 22 + valueWidth := max(18, tableWidth-labelWidth-7) + headerStyle := lipgloss.NewStyle().Bold(true).Foreground(accent) + keyStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("250")) + valueStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("229")) + borderStyle := lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("238")). + Padding(0, 1) + + lines := []string{ + headerStyle.Width(labelWidth).Render("Setting") + " " + headerStyle.Width(valueWidth).Render("Value"), + mutedStyle.Render(strings.Repeat("─", labelWidth) + " " + strings.Repeat("─", valueWidth)), + } + for _, row := range rows { + lines = append(lines, + keyStyle.Width(labelWidth).Render(row[0])+" "+ + valueStyle.Width(valueWidth).Render(clip(row[1], valueWidth)), + ) + } + return borderStyle.Width(tableWidth).Render(strings.Join(lines, "\n")) +} + +func clip(value string, width int) string { + if width < 4 || len(value) <= width { + return value + } + return value[:width-3] + "..." +} + +func valueOr(value string, fallback string) string { + if strings.TrimSpace(value) == "" { + return fallback + } + return strings.TrimSpace(value) +} + +func valueOrDash(value string) string { + return valueOr(value, "-") +} + +func displayValue(value string) string { + return strings.ReplaceAll(value, "://", "[:]//") +} + +func (m model) writeEnv() error { + lines := make([]string, 0, len(questions)) + defaults := map[string]string{ + "PLATFORM_AGENT_DIR": envDefault("PLATFORM_AGENT_DIR", "/opt/platform-agent"), + "PLATFORM_AGENT_SERVICE": envDefault("PLATFORM_AGENT_SERVICE", "platform-agent"), + "PLATFORM_AGENT_USER": envDefault("PLATFORM_AGENT_USER", envDefault("USER", "ubuntu")), + "PLATFORM_AGENT_REPO_URL": envDefault("PLATFORM_AGENT_REPO_URL", ""), + "ROBOT_STACK": envDefault("ROBOT_STACK", "autoware"), + "ROS_DOMAIN_ID": envDefault("ROS_DOMAIN_ID", "0"), + "CAMERA_ENABLED": envDefault("CAMERA_ENABLED", "true"), + "CAMERA_SIGNALING_URL": envDefault("CAMERA_SIGNALING_URL", "ws://127.0.0.1:3000"), + "KASMVNC_GEOMETRY": envDefault("KASMVNC_GEOMETRY", "1600x900"), + } + for key, value := range defaults { + lines = append(lines, fmt.Sprintf("%s=%s", key, shellQuote(value))) + } + for key, value := range m.provision { + lines = append(lines, fmt.Sprintf("%s=%s", key, shellQuote(value))) + } + for _, q := range questions { + if q.key == "PLATFORM_ACCOUNT" || q.key == "PLATFORM_REGISTRATION_CODE" || q.key == "PLATFORM_PASSWORD" { + continue + } + value := strings.TrimSpace(m.values[q.key]) + if value == "" { + value = questionDefault(q) + } + lines = append(lines, fmt.Sprintf("%s=%s", q.key, shellQuote(value))) + } + return os.WriteFile(m.envPath, []byte(strings.Join(lines, "\n")+"\n"), 0600) +} + +func (m model) View() string { + contentWidth := min(max(64, m.width-8), 92) + innerWidth := contentWidth - 6 + + var b strings.Builder + b.WriteString(header(m.index, len(questions), innerWidth) + "\n\n") + + if m.loading { + b.WriteString(statusPanel("Registering platform", "Validating the account and registration code.", innerWidth) + "\n\n") + b.WriteString(mutedStyle.Render("Please keep this terminal open. Esc cancels setup.")) + return renderShell(b.String(), contentWidth) + } + + if m.showProvision { + b.WriteString(okStyle.Render("Connection settings received") + "\n") + b.WriteString(mutedStyle.Render("Review the detected device and network settings before continuing.") + "\n\n") + b.WriteString(m.provisionSummary + "\n") + if m.err != nil { + b.WriteString("\n" + errStyle.Render(m.err.Error()) + "\n") + } + b.WriteString("\n" + footerHint("Enter", "continue", "←", "previous", "Esc", "cancel")) + return renderShell(b.String(), contentWidth) + } + + q := questions[m.index] + + if answers := recentAnswers(m); len(answers) > 0 { + b.WriteString(answerSummary(answers, innerWidth) + "\n\n") + } + + b.WriteString(sectionLabel(stepName(m.index)) + "\n") + b.WriteString(promptStyle.Render(q.title) + "\n") + b.WriteString(mutedStyle.Render(wrap(q.prompt, innerWidth)) + "\n") + if example := questionExample(q); example != "" { + b.WriteString(keyStyle.Render("Example ") + mutedStyle.Render(example) + "\n") + } + b.WriteString("\n") + if len(q.options) > 0 { + b.WriteString(renderOptions(q.options, m.choices[q.key], innerWidth) + "\n") + } else { + b.WriteString(inputStyle.Width(innerWidth-2).Render(m.input.View()) + "\n") + } + + if m.err != nil { + b.WriteString("\n" + errStyle.Render(m.err.Error()) + "\n") + } + + if len(q.options) > 0 { + b.WriteString("\n" + footerHint("↑/↓", "select", "Enter", nextAction(m.index), "←", "previous", "Esc", "cancel")) + } else { + b.WriteString("\n" + footerHint("Enter", nextAction(m.index), "←", "previous", "Esc", "cancel")) + } + + return renderShell(b.String(), contentWidth) +} + +func renderOptions(options []string, selected int, width int) string { + lines := make([]string, len(options)) + for i, option := range options { + if i == selected { + lines[i] = selectedStyle.Width(width - 2).Render("● " + option) + } else { + lines[i] = optionStyle.Width(width - 2).Render("○ " + option) + } + } + return strings.Join(lines, "\n\n") +} + +func header(index, total, width int) string { + steps := []string{"Device", "Connection", "Services"} + current := 0 + switch { + case index >= 3: + current = 2 + case index >= 2: + current = 1 + } + parts := make([]string, 0, len(steps)) + for i, step := range steps { + label := fmt.Sprintf(" %d %s ", i+1, step) + switch { + case i < current: + parts = append(parts, lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Render("✓"+label)) + case i == current: + parts = append(parts, lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("255")).Background(lipgloss.Color("24")).Render(label)) + default: + parts = append(parts, mutedStyle.Render(label)) + } + } + return brandStyle.Render("AUTONOMY STUDIO") + "\n" + + titleStyle.Render("Platform Agent Setup") + "\n\n" + + strings.Join(parts, mutedStyle.Render(" ─ ")) +} + +func recentAnswers(m model) []string { + start := max(0, m.index-2) + lines := []string{} + for i := start; i < m.index; i++ { + q := questions[i] + value := m.values[q.key] + if q.secret && value != "" { + value = "********" + } + if value == "" { + value = questionDefault(q) + } + lines = append(lines, q.title+"\t"+value) + } + return lines +} + +func renderShell(content string, width int) string { + return lipgloss.NewStyle().Margin(1, 2).Render(panelStyle.Width(width).Render(content)) +} + +func sectionLabel(value string) string { + return lipgloss.NewStyle().Bold(true).Foreground(accent).Render(strings.ToUpper(value)) +} + +func stepName(index int) string { + switch { + case index < 2: + return "Device" + case index == 2: + return "Connection" + default: + return "Services" + } +} + +func nextAction(index int) string { + if index == len(questions)-1 { + return "install" + } + return "next" +} + +func footerHint(parts ...string) string { + items := make([]string, 0, len(parts)/2) + for i := 0; i+1 < len(parts); i += 2 { + items = append(items, lipgloss.NewStyle().Bold(true).Foreground(accent).Render(parts[i])+mutedStyle.Render(" "+parts[i+1])) + } + return strings.Join(items, mutedStyle.Render(" · ")) +} + +func answerSummary(answers []string, width int) string { + rows := make([]string, 0, len(answers)) + for _, answer := range answers { + key, value, ok := strings.Cut(answer, "\t") + if ok { + rows = append(rows, mutedStyle.Width(22).Render(key)+valueStyle.Render(clip(value, max(18, width-27)))) + } + } + if len(rows) == 0 { + return "" + } + return lipgloss.NewStyle().Border(lipgloss.NormalBorder()).BorderForeground(panelBorder).Padding(0, 1).Render(strings.Join(rows, "\n")) +} + +func statusPanel(title, description string, width int) string { + spinner := lipgloss.NewStyle().Bold(true).Foreground(accentStrong).Render("◌") + return lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(accent).Padding(1, 2).Width(width - 2).Render( + spinner + " " + promptStyle.Render(title) + "\n" + mutedStyle.Render(wrap(description, width-10)), + ) +} + +func wrap(text string, width int) string { + return lipgloss.NewStyle().Width(width).Render(text) +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} + +func main() { + if len(os.Args) < 2 { + fmt.Fprintln(os.Stderr, "usage: installer-tui ") + os.Exit(2) + } + program := tea.NewProgram(initialModel(os.Args[1]), tea.WithAltScreen()) + finalModel, err := program.Run() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + if m, ok := finalModel.(model); ok && !m.done { + os.Exit(130) + } +} diff --git a/scripts/jetson_webrtc_sender.py b/scripts/jetson_webrtc_sender.py new file mode 100644 index 0000000..51e0b98 --- /dev/null +++ b/scripts/jetson_webrtc_sender.py @@ -0,0 +1,180 @@ +import asyncio +import json +import os +import signal + +import websockets +import gi + +gi.require_version("Gst", "1.0") +gi.require_version("GstWebRTC", "1.0") +gi.require_version("GstSdp", "1.0") + +from gi.repository import Gst, GstWebRTC, GstSdp + + +SIGNALING_URL = os.environ.get("SIGNALING_URL") or os.environ.get("CAMERA_SIGNALING_URL", "ws://127.0.0.1:3000") +ROOM_ID = os.environ.get("ROOM_ID", "jetson-1") +VIDEO_DEVICE = os.environ.get("VIDEO_DEVICE", "/dev/video0") +VIDEO_WIDTH = int(os.environ.get("VIDEO_WIDTH", "1920")) +VIDEO_HEIGHT = int(os.environ.get("VIDEO_HEIGHT", "1080")) +VIDEO_FRAMERATE = int(os.environ.get("VIDEO_FRAMERATE", "30")) +VIDEO_BITRATE = int(os.environ.get("VIDEO_BITRATE", "4000000")) + + +def default_pipeline_desc(): + return f""" +v4l2src device={VIDEO_DEVICE} ! +video/x-raw,format=UYVY,width={VIDEO_WIDTH},height={VIDEO_HEIGHT},framerate={VIDEO_FRAMERATE}/1 ! +nvvidconv ! +video/x-raw(memory:NVMM),format=NV12 ! +nvv4l2h264enc bitrate={VIDEO_BITRATE} insert-sps-pps=true iframeinterval=30 ! +h264parse ! +rtph264pay config-interval=1 pt=96 ! +application/x-rtp,media=video,encoding-name=H264,payload=96 ! +webrtcbin name=webrtc bundle-policy=max-bundle +""" + + +PIPELINE_DESC = os.environ.get("PIPELINE_DESC") or default_pipeline_desc() + +ws = None +loop = None +pipeline = None +webrtc = None +stopping = False + + +def log(message): + print(message, flush=True) + + +def send_json(data): + if ws and loop and not stopping: + asyncio.run_coroutine_threadsafe(ws.send(json.dumps(data)), loop) + + +def on_offer_created(promise, _, __): + promise.wait() + reply = promise.get_reply() + offer = reply.get_value("offer") + + webrtc.emit("set-local-description", offer, Gst.Promise.new()) + + send_json({ + "type": "offer", + "offer": { + "type": "offer", + "sdp": offer.sdp.as_text(), + }, + }) + log("offer sent") + + +def on_negotiation_needed(element): + promise = Gst.Promise.new_with_change_func(on_offer_created, None, None) + element.emit("create-offer", None, promise) + + +def request_offer(reason): + if not webrtc or stopping: + return + log(f"creating offer: {reason}") + on_negotiation_needed(webrtc) + + +def on_ice_candidate(element, mlineindex, candidate): + send_json({ + "type": "candidate", + "candidate": { + "candidate": candidate, + "sdpMLineIndex": mlineindex, + }, + }) + + +async def handle_message(message): + data = json.loads(message) + + if data["type"] == "joined" and data.get("role") == "viewer": + request_offer("viewer joined") + + elif data["type"] == "answer": + sdp = data["answer"]["sdp"] + _, sdpmsg = GstSdp.SDPMessage.new() + GstSdp.sdp_message_parse_buffer(bytes(sdp.encode()), sdpmsg) + + answer = GstWebRTC.WebRTCSessionDescription.new( + GstWebRTC.WebRTCSDPType.ANSWER, + sdpmsg, + ) + + webrtc.emit("set-remote-description", answer, Gst.Promise.new()) + log("answer applied") + + elif data["type"] == "candidate": + candidate = data["candidate"] + webrtc.emit( + "add-ice-candidate", + candidate.get("sdpMLineIndex", 0), + candidate["candidate"], + ) + + +async def main(): + global ws, loop, pipeline, webrtc + + loop = asyncio.get_running_loop() + + log(f"connecting signaling {SIGNALING_URL}, room={ROOM_ID}") + log("pipeline:") + log(PIPELINE_DESC) + + pipeline = Gst.parse_launch(PIPELINE_DESC) + webrtc = pipeline.get_by_name("webrtc") + if not webrtc: + raise RuntimeError("webrtcbin named 'webrtc' was not found in pipeline.") + + webrtc.connect("on-negotiation-needed", on_negotiation_needed) + webrtc.connect("on-ice-candidate", on_ice_candidate) + + async with websockets.connect(SIGNALING_URL) as websocket: + ws = websocket + + await ws.send(json.dumps({ + "type": "join", + "roomId": ROOM_ID, + "role": "jetson", + })) + + pipeline.set_state(Gst.State.PLAYING) + log("pipeline started") + await asyncio.sleep(0.5) + request_offer("pipeline started") + + async for message in ws: + await handle_message(message) + + +def stop_pipeline(): + global stopping + stopping = True + if pipeline: + pipeline.set_state(Gst.State.NULL) + log("pipeline stopped") + + +def handle_stop_signal(signum, frame): + stop_pipeline() + raise SystemExit(0) + + +if __name__ == "__main__": + Gst.init(None) + signal.signal(signal.SIGTERM, handle_stop_signal) + signal.signal(signal.SIGINT, handle_stop_signal) + + try: + asyncio.run(main()) + finally: + stop_pipeline() diff --git a/scripts/setting.sh b/scripts/setting.sh new file mode 100755 index 0000000..e20ae06 --- /dev/null +++ b/scripts/setting.sh @@ -0,0 +1,263 @@ +#!/usr/bin/env sh +set -eu + +INSTALL_DIR="${PLATFORM_AGENT_DIR:-/opt/platform-agent}" +SERVICE_NAME="${PLATFORM_AGENT_SERVICE:-platform-agent}" +CONFIG_PATH="${AGENT_CONFIG:-${INSTALL_DIR}/agent.config.json}" +SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service" + +log() { + printf '%s\n' "$*" +} + +need_cmd() { + command -v "$1" >/dev/null 2>&1 +} + +as_root() { + if [ "$(id -u)" -eq 0 ]; then + "$@" + elif need_cmd sudo; then + sudo "$@" + else + log "Root privileges are required: $*" + exit 1 + fi +} + +prompt_value() { + var_name="$1" + label="$2" + current_value="$3" + secret="${4:-false}" + if [ "$secret" = "true" ]; then + printf '%s [hidden, Enter keeps current]: ' "$label" + stty -echo 2>/dev/null || true + IFS= read -r answer || answer="" + stty echo 2>/dev/null || true + printf '\n' + else + printf '%s [%s]: ' "$label" "$current_value" + IFS= read -r answer || answer="" + fi + if [ -n "$answer" ]; then + eval "$var_name=\"\$answer\"" + fi +} + +prompt_yes_no() { + label="$1" + default="${2:-no}" + suffix="[y/N]" + [ "$default" = "yes" ] && suffix="[Y/n]" + printf '%s %s ' "$label" "$suffix" + IFS= read -r answer || answer="" + answer="$(printf '%s' "$answer" | tr '[:upper:]' '[:lower:]')" + if [ -z "$answer" ]; then + [ "$default" = "yes" ] + return + fi + [ "$answer" = "y" ] || [ "$answer" = "yes" ] +} + +json_get() { + key_path="$1" + fallback="${2:-}" + python3 - "$CONFIG_PATH" "$key_path" "$fallback" <<'PY' +import json +import sys + +path, key_path, fallback = sys.argv[1], sys.argv[2], sys.argv[3] +try: + with open(path, "r", encoding="utf-8") as fp: + value = json.load(fp) +except Exception: + print(fallback) + raise SystemExit + +for key in key_path.split("."): + if not isinstance(value, dict): + value = None + break + value = value.get(key) + +if value is None: + print(fallback) +elif isinstance(value, bool): + print("true" if value else "false") +else: + print(value) +PY +} + +service_user() { + if [ -f "$SERVICE_FILE" ]; then + user_line="$(sed -n 's/^User=//p' "$SERVICE_FILE" | head -n 1)" + [ -n "$user_line" ] && { + printf '%s' "$user_line" + return + } + fi + id -un +} + +expand_path_for_user() { + path="$1" + run_user="$2" + case "$path" in + "~") + if [ "$run_user" = "root" ]; then printf '/root'; else printf '/home/%s' "$run_user"; fi + ;; + "~/"*) + if [ "$run_user" = "root" ]; then printf '/root/%s' "${path#~/}"; else printf '/home/%s/%s' "$run_user" "${path#~/}"; fi + ;; + *) + printf '%s' "$path" + ;; + esac +} + +write_config() { + tmp_file="$(mktemp)" + WORKSPACE_ROOT="$WORKSPACE_ROOT" \ + ROBOT_STACK="$ROBOT_STACK" \ + ROS_DOMAIN_ID="$ROS_DOMAIN_ID" \ + CAMERA_ENABLED="$CAMERA_ENABLED" \ + CAMERA_VIDEO_DEVICE="$CAMERA_VIDEO_DEVICE" \ + CAMERA_SIGNALING_URL="$CAMERA_SIGNALING_URL" \ + KASMVNC_GEOMETRY="$KASMVNC_GEOMETRY" \ + AGENT_GATEWAY_TOKEN="$AGENT_GATEWAY_TOKEN" \ + python3 - "$CONFIG_PATH" "$tmp_file" <<'PY' +import json +import os +import sys + +source, target = sys.argv[1], sys.argv[2] +with open(source, "r", encoding="utf-8") as fp: + cfg = json.load(fp) + +cfg.setdefault("workspace", {}) +cfg.setdefault("ros", {}) +cfg.setdefault("camera", {}) +cfg.setdefault("desktop", {}) +cfg.setdefault("security", {}) + +cfg["workspace"]["root"] = os.environ["WORKSPACE_ROOT"] +cfg["ros"]["stack"] = "nav2" if os.environ["ROBOT_STACK"].lower() == "nav2" else "autoware" +cfg["ros"]["domainId"] = os.environ["ROS_DOMAIN_ID"] if os.environ["ROS_DOMAIN_ID"].isdigit() else "0" +cfg["camera"]["enabled"] = os.environ["CAMERA_ENABLED"].lower() in ("true", "1", "yes", "y", "on") +cfg["camera"]["videoDevice"] = os.environ["CAMERA_VIDEO_DEVICE"] +cfg["camera"]["signalingUrl"] = os.environ["CAMERA_SIGNALING_URL"] +cfg["desktop"]["geometry"] = os.environ["KASMVNC_GEOMETRY"] +if os.environ["AGENT_GATEWAY_TOKEN"]: + cfg["security"]["gatewayToken"] = os.environ["AGENT_GATEWAY_TOKEN"] + +with open(target, "w", encoding="utf-8") as fp: + json.dump(cfg, fp, ensure_ascii=False, indent=2) + fp.write("\n") +PY + as_root cp "$tmp_file" "$CONFIG_PATH" + rm -f "$tmp_file" +} + +restart_service() { + if need_cmd systemctl; then + as_root systemctl restart "$SERVICE_NAME" + elif need_cmd service; then + as_root service "$SERVICE_NAME" restart + else + log "No service manager found. Restart the agent manually." + fi +} + +if ! need_cmd python3; then + log "python3 is required to edit ${CONFIG_PATH}." + exit 1 +fi + +if [ ! -f "$CONFIG_PATH" ]; then + log "Config not found: ${CONFIG_PATH}" + log "Set AGENT_CONFIG=/path/to/agent.config.json or install the agent first." + exit 1 +fi + +RUN_USER="$(service_user)" +WORKSPACE_ROOT="$(json_get workspace.root '~/autonomy-studio/workspaces')" +ROBOT_STACK="$(json_get ros.stack autoware)" +ROS_DOMAIN_ID="$(json_get ros.domainId 0)" +CAMERA_ENABLED="$(json_get camera.enabled false)" +CAMERA_VIDEO_DEVICE="$(json_get camera.videoDevice /dev/video0)" +CAMERA_SIGNALING_URL="$(json_get camera.signalingUrl ws://127.0.0.1:3000)" +KASMVNC_GEOMETRY="$(json_get desktop.geometry 1600x900)" +AGENT_GATEWAY_TOKEN="$(json_get security.gatewayToken '')" + +while :; do + clear + log "Autonomy Studio Platform Agent Settings" + log "=======================================" + log "Config : ${CONFIG_PATH}" + log "Service : ${SERVICE_NAME}" + log "" + log "1. Workspace root ${WORKSPACE_ROOT}" + log "2. ROS stack ${ROBOT_STACK}" + log "3. ROS_DOMAIN_ID ${ROS_DOMAIN_ID}" + log "4. Camera enabled ${CAMERA_ENABLED}" + log "5. Camera device ${CAMERA_VIDEO_DEVICE}" + log "6. Camera signaling ${CAMERA_SIGNALING_URL}" + log "7. KasmVNC resolution ${KASMVNC_GEOMETRY}" + log "8. Gateway token $(if [ -n "$AGENT_GATEWAY_TOKEN" ]; then printf 'configured'; else printf 'empty'; fi)" + log "" + log "s. Save and restart" + log "q. Quit" + log "" + printf 'Select: ' + IFS= read -r choice || choice="" + case "$choice" in + 1) + prompt_value WORKSPACE_ROOT "Workspace root" "$WORKSPACE_ROOT" + ;; + 2) + prompt_value ROBOT_STACK "ROS stack (autoware/nav2)" "$ROBOT_STACK" + case "$ROBOT_STACK" in + nav2|NAV2) ROBOT_STACK="nav2" ;; + *) ROBOT_STACK="autoware" ;; + esac + ;; + 3) + prompt_value ROS_DOMAIN_ID "ROS_DOMAIN_ID" "$ROS_DOMAIN_ID" + ROS_DOMAIN_ID="$(printf '%s' "$ROS_DOMAIN_ID" | tr -cd '0-9')" + [ -n "$ROS_DOMAIN_ID" ] || ROS_DOMAIN_ID="0" + ;; + 4) + prompt_value CAMERA_ENABLED "Camera enabled true/false" "$CAMERA_ENABLED" + ;; + 5) + prompt_value CAMERA_VIDEO_DEVICE "Camera device" "$CAMERA_VIDEO_DEVICE" + ;; + 6) + prompt_value CAMERA_SIGNALING_URL "Camera signaling URL" "$CAMERA_SIGNALING_URL" + ;; + 7) + prompt_value KASMVNC_GEOMETRY "KasmVNC resolution" "$KASMVNC_GEOMETRY" + ;; + 8) + prompt_value AGENT_GATEWAY_TOKEN "Gateway token" "$AGENT_GATEWAY_TOKEN" true + ;; + s|S) + write_config + workspace_path="$(expand_path_for_user "$WORKSPACE_ROOT" "$RUN_USER")" + if prompt_yes_no "Create workspace directory ${workspace_path} if missing?" "yes"; then + as_root mkdir -p "$workspace_path" + as_root chown -R "$RUN_USER":"$RUN_USER" "$workspace_path" || true + fi + restart_service + log "" + log "Settings saved and service restarted." + exit 0 + ;; + q|Q) + log "Canceled." + exit 0 + ;; + esac +done diff --git a/scripts/systemd/platform-agent.service b/scripts/systemd/platform-agent.service new file mode 100644 index 0000000..1bbc0b1 --- /dev/null +++ b/scripts/systemd/platform-agent.service @@ -0,0 +1,16 @@ +[Unit] +Description=Platform Agent +After=network-online.target docker.service +Wants=network-online.target + +[Service] +Type=simple +WorkingDirectory=/opt/platform-agent +ExecStart=/home/ubuntu/.bun/bin/bun src/server.tsx +Restart=always +RestartSec=2 +Environment=HOST=0.0.0.0 +Environment=AGENT_CONFIG=/opt/platform-agent/agent.config.json + +[Install] +WantedBy=multi-user.target diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh new file mode 100755 index 0000000..cc33fc0 --- /dev/null +++ b/scripts/uninstall.sh @@ -0,0 +1,191 @@ +#!/usr/bin/env sh +set -eu + +INSTALL_DIR="${PLATFORM_AGENT_DIR:-/opt/platform-agent}" +SERVICE_NAME="${PLATFORM_AGENT_SERVICE:-platform-agent}" +SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service" + +log() { + printf '%s\n' "$*" +} + +need_cmd() { + command -v "$1" >/dev/null 2>&1 +} + +as_root() { + if [ "$(id -u)" -eq 0 ]; then + "$@" + elif need_cmd sudo; then + sudo "$@" + else + log "Root privileges are required: $*" + exit 1 + fi +} + +prompt_yes_no() { + label="$1" + default="${2:-no}" + suffix="[y/N]" + [ "$default" = "yes" ] && suffix="[Y/n]" + printf '%s %s ' "$label" "$suffix" + IFS= read -r answer || answer="" + answer="$(printf '%s' "$answer" | tr '[:upper:]' '[:lower:]')" + if [ -z "$answer" ]; then + [ "$default" = "yes" ] + return + fi + [ "$answer" = "y" ] || [ "$answer" = "yes" ] +} + +read_config_value() { + key_path="$1" + config_path="${INSTALL_DIR}/agent.config.json" + if [ ! -f "$config_path" ] || ! need_cmd python3; then + return + fi + python3 - "$config_path" "$key_path" <<'PY' +import json +import sys + +with open(sys.argv[1], "r", encoding="utf-8") as fp: + data = json.load(fp) + +value = data +for key in sys.argv[2].split("."): + if not isinstance(value, dict): + value = None + break + value = value.get(key) + +if isinstance(value, str): + print(value) +PY +} + +expand_path_for_user() { + path="$1" + run_user="$2" + case "$path" in + "~") + if [ "$run_user" = "root" ]; then + printf '/root' + else + printf '/home/%s' "$run_user" + fi + ;; + "~/"*) + if [ "$run_user" = "root" ]; then + printf '/root/%s' "${path#~/}" + else + printf '/home/%s/%s' "$run_user" "${path#~/}" + fi + ;; + *) + printf '%s' "$path" + ;; + esac +} + +service_user() { + if [ -f "$SERVICE_FILE" ]; then + user_line="$(sed -n 's/^User=//p' "$SERVICE_FILE" | head -n 1)" + [ -n "$user_line" ] && { + printf '%s' "$user_line" + return + } + fi + id -un +} + +remove_packages() { + packages="$1" + if [ -z "$packages" ]; then + return + fi + if need_cmd apt-get; then + as_root apt-get remove -y $packages || true + as_root apt-get autoremove -y || true + elif need_cmd dnf; then + as_root dnf remove -y $packages || true + elif need_cmd yum; then + as_root yum remove -y $packages || true + elif need_cmd pacman; then + as_root pacman -Rns --noconfirm $packages || true + elif need_cmd zypper; then + as_root zypper --non-interactive remove $packages || true + else + log "No supported package manager found. Skipping package removal." + fi +} + +clear +log "Autonomy Studio Platform Agent Uninstall" +log "========================================" +log "Service : ${SERVICE_NAME}" +log "Install dir : ${INSTALL_DIR}" +log "" + +RUN_USER="$(service_user)" +WORKSPACE_ROOT="$(read_config_value workspace.root || true)" +[ -n "${WORKSPACE_ROOT:-}" ] || WORKSPACE_ROOT="~/autonomy-studio/workspaces" +WORKSPACE_PATH="$(expand_path_for_user "$WORKSPACE_ROOT" "$RUN_USER")" + +log "Detected user : ${RUN_USER}" +log "Workspace root : ${WORKSPACE_ROOT}" +log "Resolved workspace : ${WORKSPACE_PATH}" +log "" + +if ! prompt_yes_no "Uninstall the platform agent now?" "no"; then + log "Canceled." + exit 0 +fi + +if need_cmd systemctl; then + log "Stopping systemd service." + as_root systemctl stop "$SERVICE_NAME" 2>/dev/null || true + as_root systemctl disable "$SERVICE_NAME" 2>/dev/null || true + if [ -f "$SERVICE_FILE" ]; then + as_root rm -f "$SERVICE_FILE" + as_root systemctl daemon-reload + fi +elif need_cmd service; then + as_root service "$SERVICE_NAME" stop 2>/dev/null || true +fi + +if prompt_yes_no "Remove install directory ${INSTALL_DIR}?" "yes"; then + case "$INSTALL_DIR" in + "/"|"/root"|"/home"|"/usr"|"/opt"|"/var"|"/etc") + log "Refusing to remove broad system directory: ${INSTALL_DIR}" + ;; + *) + as_root rm -rf "$INSTALL_DIR" + ;; + esac +fi + +if prompt_yes_no "Delete workspace ${WORKSPACE_PATH}? This removes cloned modules and user files." "no"; then + case "$WORKSPACE_PATH" in + "/home/$RUN_USER"|"/home/$RUN_USER/") + log "Refusing to remove user home directory: ${WORKSPACE_PATH}" + ;; + "/"|"/root"|"/home"|"/usr"|"/opt"|"/var"|"/etc"|"/home/"*) + as_root rm -rf "$WORKSPACE_PATH" + ;; + *) + as_root rm -rf "$WORKSPACE_PATH" + ;; + esac +fi + +if need_cmd tailscale && prompt_yes_no "Logout this device from Tailscale/Headscale?" "no"; then + as_root tailscale logout || true +fi + +if prompt_yes_no "Remove packages installed by install.sh if present? (tailscale openssh-server git)" "no"; then + remove_packages "tailscale openssh-server git" +fi + +log "" +log "Uninstall complete." diff --git a/src/bridge.ts b/src/bridge.ts new file mode 100644 index 0000000..872349a --- /dev/null +++ b/src/bridge.ts @@ -0,0 +1,260 @@ +import { Hono } from "hono"; +import type { AgentConfig } from "./config.ts"; + +type ThreePose = { + x: number; + y: number; + z: number; + heading: number; +}; + +type MapCenter = { + x: number; + y: number; +}; + +type MapPose = { + x: number; + y: number; + z: number; + qx: number; + qy: number; + qz: number; + qw: number; +}; + +function bridgeUrl(config: AgentConfig): string { + return config.ros.bridgeUrl || `http://${config.ros.bridgeHost}:${config.ros.bridgePort}`; +} + +function defaultCenter(): MapCenter { + return { + x: Number(process.env.CENTER_X ?? "3763.558837890625"), + y: Number(process.env.CENTER_Y ?? "73675.99609375"), + }; +} + +function validCenter(center?: MapCenter): MapCenter | undefined { + return center && Number.isFinite(center.x) && Number.isFinite(center.y) ? center : undefined; +} + +function threeToMap(pose: ThreePose, center = defaultCenter()): MapPose { + const headingRad = (pose.heading * Math.PI) / 180; + const yaw = Math.PI / 2 - headingRad; + return { + x: pose.x + center.x, + y: center.y - pose.z, + z: pose.y, + qx: 0, + qy: 0, + qz: Math.sin(yaw / 2), + qw: Math.cos(yaw / 2), + }; +} + +function mapToThree(mx: number, my: number, mz: number, center = defaultCenter()) { + return { + x: mx - center.x, + y: mz, + z: -(my - center.y), + }; +} + +function sampleTrajectory(points: number[][], minDist = 0.5): number[][] { + if (points.length === 0) return []; + const result: number[][] = [points[0]]; + let lx = points[0][0]; + let ly = points[0][1]; + + for (const point of points.slice(1)) { + const dx = point[0] - lx; + const dy = point[1] - ly; + if (Math.sqrt(dx * dx + dy * dy) >= minDist) { + result.push(point.map((value, index) => index <= 3 ? Number(value.toFixed(3)) : value)); + lx = point[0]; + ly = point[1]; + } + } + return result; +} + +function trimBehindStart(points: [number, number, number, number][], start: ThreePose) { + const headingRad = (start.heading * Math.PI) / 180; + const fwdX = Math.sin(headingRad); + const fwdZ = -Math.cos(headingRad); + + return points.filter((point) => { + const dx = point[0] - start.x; + const dz = point[2] - start.z; + return dx * fwdX + dz * fwdZ >= -0.5; + }); +} + +function convertPoints(points: { x: number; y: number; z: number; t: number }[], center?: MapCenter) { + return points.map((point) => { + const three = mapToThree(point.x, point.y, point.z, center); + return [three.x, three.y, three.z, point.t] as [number, number, number, number]; + }); +} + +async function bridgeFetch(config: AgentConfig, path: string, init?: RequestInit): Promise { + return fetch(`${bridgeUrl(config)}${path}`, init); +} + +async function bridgeJson(config: AgentConfig, path: string, init?: RequestInit): Promise { + const response = await bridgeFetch(config, path, init); + if (!response.ok) throw new Error(await response.text()); + return response.json() as Promise; +} + +function errorJson(error: unknown, fallback: string, status = 502): Response { + return Response.json({ + ok: false, + error: fallback, + detail: error instanceof Error ? error.message : String(error), + }, { status }); +} + +export function createBridgeApi(config: AgentConfig): Hono { + const app = new Hono(); + + app.get("/status", async (c) => { + try { + const [health, stack] = await Promise.all([ + bridgeJson(config, "/health").catch((error) => ({ ok: false, error: String(error) })), + bridgeJson(config, "/stack").catch((error) => ({ ok: false, error: String(error) })), + ]); + return c.json({ + ok: true, + configured: config.ros, + bridgeUrl: bridgeUrl(config), + health, + stack, + }); + } catch (error) { + return c.json({ ok: false, bridgeUrl: bridgeUrl(config), message: error instanceof Error ? error.message : String(error) }, 502); + } + }); + + app.get("/stack", async (c) => { + try { + return c.json(await bridgeJson(config, "/stack")); + } catch (error) { + return errorJson(error, "Failed to read bridge stack."); + } + }); + + app.post("/clear", async (c) => { + try { + return c.json(await bridgeJson(config, "/clear_route", { method: "POST" })); + } catch (error) { + return errorJson(error, "Failed to clear route."); + } + }); + + app.post("/stop", async (c) => { + try { + return c.json(await bridgeJson(config, "/disengage", { method: "POST" })); + } catch (error) { + return errorJson(error, "Failed to stop robot."); + } + }); + + app.post("/reset", async (c) => { + try { + return c.json(await bridgeJson(config, "/reset", { method: "POST" })); + } catch (error) { + return errorJson(error, "Failed to reset robot."); + } + }); + + return app; +} + +export function createViewerControlApi(config: AgentConfig): Hono { + const app = new Hono(); + + app.post("/plan", async (c) => { + try { + const body = await c.req.json<{ start?: ThreePose; goal?: ThreePose; center?: MapCenter }>(); + if (!body.goal) return c.json({ error: "goal is required" }, 400); + + const center = validCenter(body.center); + const goalMap = threeToMap(body.goal, center); + const startMap = body.start ? threeToMap(body.start, center) : null; + + if (startMap) { + await bridgeJson(config, "/set_initial_pose", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(startMap), + }); + await Bun.sleep(800); + } + + await bridgeJson(config, "/set_route", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(goalMap), + }); + + const [pathData, trajData] = await Promise.all([ + bridgeJson<{ points: { x: number; y: number; z: number; t: number }[] }>(config, "/get_behavior_path"), + bridgeJson<{ points: { x: number; y: number; z: number; t: number }[] }>(config, "/get_trajectory"), + ]); + + const planned = sampleTrajectory(convertPoints(pathData.points, center), 0.5); + const rawTrajectoryPoints = convertPoints(trajData.points, center); + const visibleTrajectoryPoints = body.start ? trimBehindStart(rawTrajectoryPoints, body.start) : rawTrajectoryPoints; + + return c.json({ + planned, + plannedTrajectory: sampleTrajectory(visibleTrajectoryPoints, 0.5), + }); + } catch (error) { + return errorJson(error, "Failed to plan."); + } + }); + + app.post("/drive", async (c) => { + try { + const body = await c.req.json<{ goal?: ThreePose }>(); + if (!body.goal) return c.json({ error: "goal is required" }, 400); + + await bridgeJson(config, "/engage", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ engage: true }), + }); + return c.json({ ok: true }); + } catch (error) { + return errorJson(error, "Failed to engage."); + } + }); + + app.post("/clear", async (c) => { + try { + return c.json(await bridgeJson(config, "/clear_route", { method: "POST" })); + } catch (error) { + return errorJson(error, "Failed to clear route."); + } + }); + + app.post("/stop", async (c) => { + try { + return c.json(await bridgeJson(config, "/disengage", { method: "POST" })); + } catch (error) { + return errorJson(error, "Failed to stop robot."); + } + }); + + app.post("/reset", async (c) => { + try { + return c.json(await bridgeJson(config, "/reset", { method: "POST" })); + } catch (error) { + return errorJson(error, "Failed to reset robot."); + } + }); + + return app; +} diff --git a/src/camera.ts b/src/camera.ts new file mode 100644 index 0000000..5d6674b --- /dev/null +++ b/src/camera.ts @@ -0,0 +1,246 @@ +import type { AgentConfig } from "./config.ts"; +import { readdir, stat } from "node:fs/promises"; + +type CameraDevice = { + index: number; + path: string; + name: string; + available: boolean; +}; + +type CameraProcess = { + name: "sender"; + proc: ReturnType; + command: string[]; + env: CameraStartOptions; + startedAt: string; + exitedAt?: string; + exitCode?: number | null; +}; + +export type CameraStartOptions = { + signalingUrl?: string; + roomId?: string; + streamName?: string; + videoDevice?: string; + width?: number; + height?: number; + framerate?: number; + bitrate?: number; + pipelineDesc?: string; +}; + +export class CameraManager { + private sender?: CameraProcess; + private logs: string[] = []; + + constructor(private readonly config: AgentConfig) {} + + start(options: CameraStartOptions = {}) { + if (!this.config.camera.enabled) { + return { ok: false, error: "Camera streaming is disabled in agent.config.json." }; + } + + const startOptions = this.normalizeStartOptions(options); + if (this.sender && !this.hasExited(this.sender) && !this.sameStartOptions(this.sender.env, startOptions)) { + this.killProcess(this.sender); + this.sender = undefined; + } + + if (!this.sender || this.hasExited(this.sender)) { + this.sender = this.spawnProcess( + "sender", + this.config.camera.senderCommand, + this.renderArgs(this.config.camera.senderArgs, startOptions), + startOptions, + ); + } + + return { ok: true, status: this.status() }; + } + + stop() { + this.killProcess(this.sender); + return { ok: true, status: this.status() }; + } + + status() { + return { + enabled: this.config.camera.enabled, + streamName: this.config.camera.streamName, + signalingUrl: this.config.camera.signalingUrl, + roomId: this.config.camera.roomId, + videoDevice: this.config.camera.videoDevice, + sender: this.processStatus(this.sender), + logs: this.logs.slice(-80), + }; + } + + async devices() { + const devices = await this.scanVideoDevices(); + return { ok: true, devices }; + } + + private async scanVideoDevices(): Promise { + const entries = await readdir("/dev").catch(() => []); + const videoEntries = entries + .map((name) => { + const match = /^video(\d+)$/.exec(name); + if (!match) return null; + return { index: Number(match[1]), path: `/dev/${name}` }; + }) + .filter((entry) => entry !== null) + .sort((a, b) => a.index - b.index); + + const devices = await Promise.all(videoEntries.map(async (entry) => { + const available = await stat(entry.path).then((fileStat) => fileStat.isCharacterDevice()).catch(() => false); + const name = await this.readVideoDeviceName(entry.path); + return { + ...entry, + name: name || `Video ${entry.index}`, + available, + }; + })); + + return devices; + } + + private async readVideoDeviceName(path: string): Promise { + try { + const proc = Bun.spawn(["v4l2-ctl", "--device", path, "--info"], { + stdout: "pipe", + stderr: "pipe", + }); + const output = await new Response(proc.stdout).text(); + const exitCode = await proc.exited; + if (exitCode !== 0) return ""; + + const cardLine = output.split(/\r?\n/).find((line) => line.trim().startsWith("Card type")); + return cardLine?.split(":").slice(1).join(":").trim() ?? ""; + } catch { + return ""; + } + } + + private normalizeStartOptions(options: CameraStartOptions): Required { + return { + signalingUrl: options.signalingUrl || this.config.camera.signalingUrl, + roomId: options.roomId || this.config.camera.roomId, + streamName: options.streamName || this.config.camera.streamName, + videoDevice: options.videoDevice || this.config.camera.videoDevice, + width: Number(options.width || this.config.camera.width), + height: Number(options.height || this.config.camera.height), + framerate: Number(options.framerate || this.config.camera.framerate), + bitrate: Number(options.bitrate || this.config.camera.bitrate), + pipelineDesc: options.pipelineDesc ?? this.config.camera.pipelineDesc, + }; + } + + private sameStartOptions(current: CameraStartOptions, next: CameraStartOptions) { + return JSON.stringify(current) === JSON.stringify(next); + } + + private renderArgs(args: string[], options: Required) { + return args.map((arg) => + arg + .replaceAll("{streamName}", options.streamName) + .replaceAll("{signalingUrl}", options.signalingUrl) + .replaceAll("{roomId}", options.roomId) + .replaceAll("{videoDevice}", options.videoDevice), + ); + } + + private spawnProcess(name: CameraProcess["name"], command: string, args: string[], options: Required): CameraProcess { + const cmd = [command, ...args]; + this.pushLog(`[${name}] start ${cmd.join(" ")} device=${options.videoDevice}`); + + const proc = Bun.spawn(cmd, { + stdout: "pipe", + stderr: "pipe", + env: { + ...process.env, + SIGNALING_URL: options.signalingUrl, + CAMERA_SIGNALING_URL: options.signalingUrl, + ROOM_ID: options.roomId, + STREAM_NAME: options.streamName, + VIDEO_DEVICE: options.videoDevice, + VIDEO_WIDTH: String(options.width), + VIDEO_HEIGHT: String(options.height), + VIDEO_FRAMERATE: String(options.framerate), + VIDEO_BITRATE: String(options.bitrate), + PIPELINE_DESC: options.pipelineDesc, + }, + }); + + const entry: CameraProcess = { + name, + proc, + command: cmd, + env: options, + startedAt: new Date().toISOString(), + }; + + void this.collectOutput(name, proc.stdout); + void this.collectOutput(name, proc.stderr); + proc.exited.then((exitCode) => { + entry.exitCode = exitCode; + entry.exitedAt = new Date().toISOString(); + this.pushLog(`[${name}] exited ${exitCode}`); + }); + + return entry; + } + + private async collectOutput(name: CameraProcess["name"], stream: ReadableStream | null) { + if (!stream) return; + + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + + const lines = buffer.split(/\r?\n/); + buffer = lines.pop() ?? ""; + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed) this.pushLog(`[${name}] ${trimmed}`); + } + } + + if (buffer.trim()) this.pushLog(`[${name}] ${buffer.trim()}`); + } catch (error) { + this.pushLog(`[${name}] log read failed: ${error instanceof Error ? error.message : String(error)}`); + } + } + + private killProcess(entry?: CameraProcess) { + if (!entry || this.hasExited(entry)) return; + entry.proc.kill(); + this.pushLog(`[${entry.name}] stop requested`); + } + + private hasExited(entry: CameraProcess) { + return entry.exitCode !== undefined; + } + + private processStatus(entry?: CameraProcess) { + if (!entry) return { running: false }; + return { + running: !this.hasExited(entry), + command: entry.command, + startedAt: entry.startedAt, + exitedAt: entry.exitedAt, + exitCode: entry.exitCode, + }; + } + + private pushLog(line: string) { + this.logs.push(`${new Date().toISOString()} ${line}`); + if (this.logs.length > 200) this.logs.splice(0, this.logs.length - 200); + } +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..49eba19 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,383 @@ +import { existsSync, readFileSync } from "node:fs"; +import { writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +export interface AgentConfig { + server: { + host: string; + port: number; + publicBaseUrl: string; + }; + security: { + gatewayToken: string; + }; + iframe: { + title: string; + allowOrigins: string[]; + }; + desktop: { + provider: "kasmvnc"; + display: string; + webPort: number; + protocol: string; + interface: string; + geometry: string; + desktop: string; + }; + camera: { + enabled: boolean; + autoStart: boolean; + streamName: string; + signalingUrl: string; + roomId: string; + videoDevice: string; + width: number; + height: number; + framerate: number; + bitrate: number; + senderCommand: string; + senderArgs: string[]; + pipelineDesc: string; + }; + workspace: { + root: string; + maxFileSize: number; + editableExtensions: string[]; + }; + ros: { + stack: "autoware" | "nav2"; + domainId: string; + bridgeHost: string; + bridgePort: number; + bridgeUrl: string; + autowareImage: string; + nav2Image: string; + routeInput: "rviz_topic" | "service"; + routePrefix: string; + mapPointStride: number; + mapPointLimit: number; + }; + modules: { + autowareRunScript: string; + nav2RunScript: string; + }; +} + +const defaultConfig: AgentConfig = { + server: { + host: "127.0.0.1", + port: 3100, + publicBaseUrl: "http://127.0.0.1:3100", + }, + security: { + gatewayToken: "", + }, + iframe: { + title: "KasmVNC", + allowOrigins: ["file:", "app://platform-ide", "http://localhost:*", "http://127.0.0.1:*"], + }, + desktop: { + provider: "kasmvnc", + display: ":1", + webPort: 6901, + protocol: "http", + interface: "0.0.0.0", + geometry: "1600x900", + desktop: "XFCE", + }, + camera: { + enabled: false, + autoStart: true, + streamName: "jetson-camera", + signalingUrl: "ws://127.0.0.1:3000", + roomId: "jetson-1", + videoDevice: "/dev/video0", + width: 1920, + height: 1080, + framerate: 30, + bitrate: 4000000, + senderCommand: "python3", + senderArgs: ["scripts/jetson_webrtc_sender.py"], + pipelineDesc: "", + }, + workspace: { + root: "~/autonomy-studio/workspaces", + maxFileSize: 5 * 1024 * 1024, + editableExtensions: [ + ".bash", + ".c", + ".cc", + ".cfg", + ".cmake", + ".conf", + ".cpp", + ".css", + ".csv", + ".h", + ".hpp", + ".html", + ".ini", + ".js", + ".json", + ".jsx", + ".launch", + ".log", + ".lua", + ".md", + ".msg", + ".py", + ".rs", + ".rviz", + ".sh", + ".srv", + ".toml", + ".ts", + ".tsx", + ".txt", + ".urdf", + ".xml", + ".yaml", + ".yml", + ], + }, + ros: { + stack: "autoware", + domainId: "0", + bridgeHost: "127.0.0.1", + bridgePort: 8765, + bridgeUrl: "http://127.0.0.1:8765", + autowareImage: "autoware-ros-bridge", + nav2Image: "slam-ros-bridge:latest", + routeInput: "rviz_topic", + routePrefix: "/api/routing", + mapPointStride: 8, + mapPointLimit: 80000, + }, + modules: { + autowareRunScript: "docker/docker_run_autoware-universe:humble-20240301-cuda.sh", + nav2RunScript: "docker/docker_run_future-nav2:humble.sh", + }, +}; + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function mergeConfig(base: AgentConfig, patch: unknown): AgentConfig { + if (!isPlainObject(patch)) return base; + + return { + server: { + ...base.server, + ...(isPlainObject(patch.server) ? patch.server : {}), + }, + security: { + ...base.security, + ...(isPlainObject(patch.security) ? patch.security : {}), + }, + iframe: { + ...base.iframe, + ...(isPlainObject(patch.iframe) ? patch.iframe : {}), + }, + desktop: { + ...base.desktop, + ...(isPlainObject(patch.desktop) ? patch.desktop : {}), + }, + camera: { + ...base.camera, + ...(isPlainObject(patch.camera) ? patch.camera : {}), + }, + workspace: { + ...base.workspace, + ...(isPlainObject(patch.workspace) ? patch.workspace : {}), + }, + ros: { + ...base.ros, + ...(isPlainObject(patch.ros) ? patch.ros : {}), + }, + modules: { + ...base.modules, + ...(isPlainObject(patch.modules) ? patch.modules : {}), + }, + } as AgentConfig; +} + +export function configPath(): string { + return resolve(process.env.AGENT_CONFIG ?? "agent.config.json"); +} + +function normalizeStack(value: unknown): "autoware" | "nav2" { + return String(value ?? "").trim().toLowerCase() === "nav2" ? "nav2" : "autoware"; +} + +function normalizeRouteInput(value: unknown): "rviz_topic" | "service" { + return String(value ?? "").trim().toLowerCase() === "service" ? "service" : "rviz_topic"; +} + +export function normalizeConfig(config: AgentConfig): AgentConfig { + config.server.port = Number(config.server.port); + config.security ??= { gatewayToken: "" }; + config.security.gatewayToken = String(config.security.gatewayToken ?? ""); + config.desktop.provider = "kasmvnc"; + config.desktop.display = String(config.desktop.display || ":1"); + config.desktop.webPort = Number(config.desktop.webPort); + config.desktop.protocol = String(config.desktop.protocol || "http").replace(/:$/, ""); + config.desktop.interface = String(config.desktop.interface || "0.0.0.0"); + config.desktop.geometry = String(config.desktop.geometry || "1600x900"); + config.desktop.desktop = String(config.desktop.desktop || "XFCE"); + config.camera.streamName = String(config.camera.streamName); + config.camera.signalingUrl = String(config.camera.signalingUrl); + config.camera.roomId = String(config.camera.roomId); + config.camera.videoDevice = String(config.camera.videoDevice); + config.camera.width = Number(config.camera.width); + config.camera.height = Number(config.camera.height); + config.camera.framerate = Number(config.camera.framerate); + config.camera.bitrate = Number(config.camera.bitrate); + config.camera.senderCommand = String(config.camera.senderCommand); + config.camera.pipelineDesc = String(config.camera.pipelineDesc ?? ""); + config.workspace.root = String(config.workspace.root); + config.workspace.maxFileSize = Number(config.workspace.maxFileSize); + config.workspace.editableExtensions = Array.isArray(config.workspace.editableExtensions) + ? config.workspace.editableExtensions.map((extension) => String(extension).toLowerCase()) + : defaultConfig.workspace.editableExtensions; + config.camera.senderArgs = Array.isArray(config.camera.senderArgs) + ? config.camera.senderArgs.map(String) + : defaultConfig.camera.senderArgs; + config.iframe.allowOrigins = Array.isArray(config.iframe.allowOrigins) + ? config.iframe.allowOrigins.map(String) + : defaultConfig.iframe.allowOrigins; + config.ros.stack = normalizeStack(config.ros.stack); + config.ros.domainId = /^\d+$/.test(String(config.ros.domainId ?? "")) ? String(config.ros.domainId) : "0"; + config.ros.bridgeHost = String(config.ros.bridgeHost || "127.0.0.1"); + config.ros.bridgePort = Number(config.ros.bridgePort); + config.ros.bridgeUrl = String(config.ros.bridgeUrl || `http://${config.ros.bridgeHost}:${config.ros.bridgePort}`).replace(/\/+$/, ""); + config.ros.autowareImage = String(config.ros.autowareImage || defaultConfig.ros.autowareImage); + config.ros.nav2Image = String(config.ros.nav2Image || defaultConfig.ros.nav2Image); + config.ros.routeInput = normalizeRouteInput(config.ros.routeInput); + config.ros.routePrefix = String(config.ros.routePrefix || "/api/routing"); + config.ros.mapPointStride = Number(config.ros.mapPointStride); + config.ros.mapPointLimit = Number(config.ros.mapPointLimit); + config.modules.autowareRunScript = String(config.modules.autowareRunScript || defaultConfig.modules.autowareRunScript); + config.modules.nav2RunScript = String(config.modules.nav2RunScript || defaultConfig.modules.nav2RunScript); + + if (process.env.HOST) config.server.host = process.env.HOST; + if (process.env.PORT) config.server.port = Number(process.env.PORT); + if (process.env.KASMVNC_DISPLAY) config.desktop.display = process.env.KASMVNC_DISPLAY; + if (process.env.KASMVNC_WEB_PORT) config.desktop.webPort = Number(process.env.KASMVNC_WEB_PORT); + if (process.env.KASMVNC_PROTOCOL) config.desktop.protocol = process.env.KASMVNC_PROTOCOL.replace(/:$/, ""); + if (process.env.KASMVNC_INTERFACE) config.desktop.interface = process.env.KASMVNC_INTERFACE; + if (process.env.KASMVNC_GEOMETRY) config.desktop.geometry = process.env.KASMVNC_GEOMETRY; + if (process.env.KASMVNC_DESKTOP) config.desktop.desktop = process.env.KASMVNC_DESKTOP; + if (process.env.CAMERA_ENABLED) config.camera.enabled = process.env.CAMERA_ENABLED === "true"; + if (process.env.CAMERA_SIGNALING_URL) config.camera.signalingUrl = process.env.CAMERA_SIGNALING_URL; + if (process.env.CAMERA_STREAM_NAME) config.camera.streamName = process.env.CAMERA_STREAM_NAME; + if (process.env.CAMERA_ROOM_ID) config.camera.roomId = process.env.CAMERA_ROOM_ID; + if (process.env.CAMERA_VIDEO_DEVICE) config.camera.videoDevice = process.env.CAMERA_VIDEO_DEVICE; + if (process.env.CAMERA_WIDTH) config.camera.width = Number(process.env.CAMERA_WIDTH); + if (process.env.CAMERA_HEIGHT) config.camera.height = Number(process.env.CAMERA_HEIGHT); + if (process.env.CAMERA_FRAMERATE) config.camera.framerate = Number(process.env.CAMERA_FRAMERATE); + if (process.env.CAMERA_BITRATE) config.camera.bitrate = Number(process.env.CAMERA_BITRATE); + if (process.env.CAMERA_PIPELINE_DESC) config.camera.pipelineDesc = process.env.CAMERA_PIPELINE_DESC; + if (process.env.WORKSPACE_ROOT) config.workspace.root = process.env.WORKSPACE_ROOT; + if (process.env.WORKSPACE_MAX_FILE_SIZE) config.workspace.maxFileSize = Number(process.env.WORKSPACE_MAX_FILE_SIZE); + if (process.env.ROBOT_STACK) config.ros.stack = normalizeStack(process.env.ROBOT_STACK); + if (process.env.ROS_DOMAIN_ID) config.ros.domainId = /^\d+$/.test(process.env.ROS_DOMAIN_ID) ? process.env.ROS_DOMAIN_ID : "0"; + if (process.env.ROS_BRIDGE_HOST) config.ros.bridgeHost = process.env.ROS_BRIDGE_HOST; + if (process.env.ROS_BRIDGE_PORT) config.ros.bridgePort = Number(process.env.ROS_BRIDGE_PORT); + if (process.env.ROS_BRIDGE_URL) config.ros.bridgeUrl = process.env.ROS_BRIDGE_URL.replace(/\/+$/, ""); + if (process.env.AUTOWARE_ROUTE_INPUT) config.ros.routeInput = normalizeRouteInput(process.env.AUTOWARE_ROUTE_INPUT); + if (process.env.AUTOWARE_ROUTE_PREFIX) config.ros.routePrefix = process.env.AUTOWARE_ROUTE_PREFIX; + if (process.env.MAP_POINT_STRIDE) config.ros.mapPointStride = Number(process.env.MAP_POINT_STRIDE); + if (process.env.MAP_POINT_LIMIT) config.ros.mapPointLimit = Number(process.env.MAP_POINT_LIMIT); + if (!Number.isInteger(config.server.port) || config.server.port < 1 || config.server.port > 65535) { + throw new Error("server.port must be a valid TCP port."); + } + if (!Number.isInteger(config.desktop.webPort) || config.desktop.webPort < 1 || config.desktop.webPort > 65535) { + throw new Error("desktop.webPort must be a valid TCP port."); + } + if (!Number.isFinite(config.workspace.maxFileSize) || config.workspace.maxFileSize < 1) { + throw new Error("workspace.maxFileSize must be a positive number."); + } + if (!Number.isFinite(config.camera.width) || config.camera.width < 1) { + throw new Error("camera.width must be a positive number."); + } + if (!Number.isFinite(config.camera.height) || config.camera.height < 1) { + throw new Error("camera.height must be a positive number."); + } + if (!Number.isFinite(config.camera.framerate) || config.camera.framerate < 1) { + throw new Error("camera.framerate must be a positive number."); + } + if (!Number.isFinite(config.camera.bitrate) || config.camera.bitrate < 1) { + throw new Error("camera.bitrate must be a positive number."); + } + if (!Number.isInteger(config.ros.bridgePort) || config.ros.bridgePort < 1 || config.ros.bridgePort > 65535) { + throw new Error("ros.bridgePort must be a valid TCP port."); + } + if (!Number.isFinite(config.ros.mapPointStride) || config.ros.mapPointStride < 1) { + throw new Error("ros.mapPointStride must be a positive number."); + } + if (!Number.isFinite(config.ros.mapPointLimit) || config.ros.mapPointLimit < 1000) { + throw new Error("ros.mapPointLimit must be at least 1000."); + } + + return config; +} + +export function loadConfig(): AgentConfig { + const rawConfig = existsSync(configPath()) ? JSON.parse(readFileSync(configPath(), "utf8")) : {}; + return normalizeConfig(mergeConfig(defaultConfig, rawConfig)); +} + +export async function saveConfigPatch(current: AgentConfig, patch: unknown): Promise { + const raw = existsSync(configPath()) ? JSON.parse(readFileSync(configPath(), "utf8")) : {}; + const merged = mergeConfig(mergeConfig(defaultConfig, raw), patch); + const normalized = normalizeConfig(merged); + await writeFile(configPath(), `${JSON.stringify(normalized, null, 2)}\n`, "utf8"); + Object.assign(current.server, normalized.server); + Object.assign(current.iframe, normalized.iframe); + Object.assign(current.desktop, normalized.desktop); + Object.assign(current.camera, normalized.camera); + Object.assign(current.workspace, normalized.workspace); + Object.assign(current.ros, normalized.ros); + Object.assign(current.modules, normalized.modules); + return current; +} + +export function getConfigSummary(config: AgentConfig) { + const kasmProtocol = config.desktop.protocol; + const kasmPort = config.desktop.webPort; + + return { + server: config.server, + iframe: { + title: "KasmVNC", + }, + remoteDesktop: { + provider: "kasmvnc", + display: config.desktop.display, + desktop: config.desktop.desktop, + geometry: config.desktop.geometry, + protocol: kasmProtocol, + port: kasmPort, + url: `${kasmProtocol}://127.0.0.1:${kasmPort}`, + }, + camera: { + enabled: config.camera.enabled, + autoStart: config.camera.autoStart, + streamName: config.camera.streamName, + signalingUrl: config.camera.signalingUrl, + roomId: config.camera.roomId, + videoDevice: config.camera.videoDevice, + width: config.camera.width, + height: config.camera.height, + framerate: config.camera.framerate, + bitrate: config.camera.bitrate, + senderCommand: config.camera.senderCommand, + senderArgs: config.camera.senderArgs, + pipelineDescConfigured: config.camera.pipelineDesc.length > 0, + }, + workspace: config.workspace, + ros: config.ros, + modules: config.modules, + }; +} diff --git a/src/display.ts b/src/display.ts new file mode 100644 index 0000000..49122d7 --- /dev/null +++ b/src/display.ts @@ -0,0 +1,155 @@ +import { Hono } from "hono"; + +type ProcessHandle = ReturnType; + +type DisplayProcess = { + proc: ProcessHandle; + command: string[]; + startedAt: string; + exitCode?: number | null; +}; + +const displayNumber = process.env.REMOTE_DISPLAY || ":2"; +const screen = process.env.REMOTE_DISPLAY_SCREEN || "1920x1080x24"; +const displayIndex = displayNumber.replace(/^:/, ""); + +function runShell(command: string, timeout = 3000): Promise<{ ok: boolean; stdout: string; stderr: string }> { + return new Promise((resolve) => { + const proc = Bun.spawn(["sh", "-lc", command], { + stdout: "pipe", + stderr: "pipe", + }); + const timer = setTimeout(() => { + proc.kill(); + resolve({ ok: false, stdout: "", stderr: `${command} timed out` }); + }, timeout); + + Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]).then(([stdout, stderr, exitCode]) => { + clearTimeout(timer); + resolve({ ok: exitCode === 0, stdout, stderr }); + }).catch((error) => { + clearTimeout(timer); + resolve({ ok: false, stdout: "", stderr: error instanceof Error ? error.message : String(error) }); + }); + }); +} + +function isAlive(proc?: DisplayProcess): boolean { + return Boolean(proc && proc.exitCode === undefined); +} + +async function commandExists(command: string): Promise { + return (await runShell(`command -v ${command}`)).ok; +} + +async function isDisplayProcessRunning(): Promise { + return (await runShell(`pgrep -f "Xvfb ${displayNumber}" >/dev/null || pgrep -f "Xorg ${displayNumber}" >/dev/null`)).ok; +} + +async function isDisplayAvailable(): Promise { + if (await commandExists("xdpyinfo")) { + return (await runShell(`DISPLAY=${displayNumber} xdpyinfo >/dev/null`, 2000)).ok; + } + return (await runShell(`test -S /tmp/.X11-unix/X${displayIndex}`)).ok && await isDisplayProcessRunning(); +} + +async function waitForDisplay(timeoutMs = 3000): Promise { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + if (await isDisplayAvailable()) return true; + await Bun.sleep(150); + } + return false; +} + +async function cleanupDisplay(): Promise { + await runShell(`pkill -f "openbox" || true; pkill -f "Xvfb ${displayNumber}" || true; rm -f /tmp/.X${displayIndex}-lock /tmp/.X11-unix/X${displayIndex}`, 3000); +} + +export function createDisplayApi(): Hono { + const app = new Hono(); + let xvfb: DisplayProcess | undefined; + let openbox: DisplayProcess | undefined; + const logs: string[] = []; + + function pushLog(line: string) { + logs.push(`${new Date().toISOString()} ${line}`); + if (logs.length > 100) logs.splice(0, logs.length - 100); + } + + function track(procInfo: DisplayProcess) { + procInfo.proc.exited.then((exitCode) => { + procInfo.exitCode = exitCode; + pushLog(`${procInfo.command.join(" ")} exited ${exitCode}`); + }).catch((error) => { + procInfo.exitCode = -1; + pushLog(`${procInfo.command.join(" ")} failed ${error instanceof Error ? error.message : String(error)}`); + }); + } + + async function statusPayload() { + const running = await isDisplayAvailable(); + return { + ok: true, + running, + display: displayNumber, + screen, + xvfb: isAlive(xvfb), + openbox: isAlive(openbox), + logs: logs.slice(-50), + }; + } + + app.get("/status", async (c) => c.json(await statusPayload())); + + app.post("/start", async (c) => { + if (!(await isDisplayAvailable())) { + if (!(await commandExists("Xvfb"))) { + return c.json({ ok: false, message: "Xvfb is not installed. Run sudo apt install -y xvfb openbox and try again.", status: await statusPayload() }, 500); + } + + pushLog(`${displayNumber} display is not responding. Starting a new display.`); + await cleanupDisplay(); + const command = ["Xvfb", displayNumber, "-screen", "0", screen, "-ac"]; + pushLog(`start ${command.join(" ")}`); + const proc = Bun.spawn(command, { + stdout: "ignore", + stderr: "pipe", + }); + xvfb = { proc, command, startedAt: new Date().toISOString() }; + track(xvfb); + + if (!(await waitForDisplay())) { + return c.json({ ok: false, message: `Failed to start ${displayNumber} display. Check agent.log and Xvfb installation status.`, status: await statusPayload() }, 500); + } + } + + if (!isAlive(openbox) && await commandExists("openbox")) { + const command = ["openbox"]; + pushLog(`start DISPLAY=${displayNumber} ${command.join(" ")}`); + const proc = Bun.spawn(command, { + env: { ...process.env, DISPLAY: displayNumber }, + stdout: "ignore", + stderr: "pipe", + }); + openbox = { proc, command, startedAt: new Date().toISOString() }; + track(openbox); + } + + return c.json({ ok: true, message: `${displayNumber} display is ready`, status: await statusPayload() }); + }); + + app.post("/stop", async (c) => { + if (isAlive(openbox)) openbox?.proc.kill(); + if (isAlive(xvfb)) xvfb?.proc.kill(); + await cleanupDisplay(); + pushLog("stop requested"); + return c.json({ ok: true, status: await statusPayload() }); + }); + + return app; +} diff --git a/src/kasmVnc.ts b/src/kasmVnc.ts new file mode 100644 index 0000000..c16a1ae --- /dev/null +++ b/src/kasmVnc.ts @@ -0,0 +1,192 @@ +import { Hono } from "hono"; +import type { AgentConfig } from "./config.ts"; + +type CommandResult = { + ok: boolean; + stdout: string; + stderr: string; + exitCode: number; +}; + +function desktopSettings(config: AgentConfig) { + const display = config.desktop.display || ":1"; + return { + display, + displayNumber: Number(display.replace(/^:/, "")) || 1, + webPort: Number(config.desktop.webPort || 6901), + protocol: (config.desktop.protocol || "http").replace(/:$/, ""), + bindAddress: config.desktop.interface || "0.0.0.0", + defaultGeometry: config.desktop.geometry || "1600x900", + desktopEnvironment: config.desktop.desktop || "XFCE", + }; +} + +function normalizeGeometry(value: unknown, fallback: string): string { + const text = String(value ?? "").trim(); + return /^\d{3,5}x\d{3,5}$/.test(text) ? text : fallback; +} + +async function runCommand(command: string, args: string[], timeout = 5000): Promise { + const proc = Bun.spawn([command, ...args], { + stdout: "pipe", + stderr: "pipe", + }); + const timer = setTimeout(() => proc.kill(), timeout); + try { + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + return { + ok: exitCode === 0, + stdout: stdout.trim(), + stderr: stderr.trim(), + exitCode, + }; + } finally { + clearTimeout(timer); + } +} + +async function commandExists(command: string): Promise { + return (await runCommand("sh", ["-lc", `command -v ${command}`], 2000)).ok; +} + +async function detectVncServerCommand(): Promise { + if (await commandExists("kasmvncserver")) return "kasmvncserver"; + if (await commandExists("vncserver")) return "vncserver"; + return "vncserver"; +} + +async function isKasmVncInstalled(): Promise { + const command = await detectVncServerCommand(); + if (!(await commandExists(command))) return false; + if (command === "kasmvncserver") return true; + const help = await runCommand(command, ["-h"], 3000); + return /kasm/i.test(`${help.stdout}\n${help.stderr}`); +} + +async function isRunning(display: string): Promise { + const command = await detectVncServerCommand(); + const list = await runCommand(command, ["-list"], 3000); + if (new RegExp(`\\${display}\\b`).test(`${list.stdout}\n${list.stderr}`)) return true; + const pgrep = await runCommand("pgrep", ["-f", `Xvnc.*\\${display}\\b`], 3000); + return pgrep.ok; +} + +async function isPortListening(webPort: number): Promise { + const result = await runCommand("sh", ["-lc", `ss -ltn 2>/dev/null | grep -q ':${webPort} ' || netstat -ltn 2>/dev/null | grep -q ':${webPort} '`], 3000); + return result.ok; +} + +function publicUrl(host: string, protocol: string, webPort: number): string { + const hostname = host.replace(/^https?:\/\//, "").replace(/\/.*$/, "").replace(/:\d+$/, ""); + return `${protocol}://${hostname}:${webPort}`; +} + +export function createKasmVncApi(config: AgentConfig): Hono { + const app = new Hono(); + const logs: string[] = []; + + function pushLog(line: string) { + logs.push(`${new Date().toISOString()} ${line}`); + if (logs.length > 120) logs.splice(0, logs.length - 120); + } + + async function statusPayload(requestUrl?: string) { + const settings = desktopSettings(config); + const host = requestUrl ? new URL(requestUrl).hostname : "127.0.0.1"; + const installed = await isKasmVncInstalled(); + const running = installed ? await isRunning(settings.display) : false; + const webListening = running ? await isPortListening(settings.webPort) : false; + return { + ok: true, + installed, + running, + webListening, + display: settings.display, + displayNumber: settings.displayNumber, + desktop: settings.desktopEnvironment, + geometry: settings.defaultGeometry, + protocol: settings.protocol, + port: settings.webPort, + url: publicUrl(host, settings.protocol, settings.webPort), + logs: logs.slice(-80), + }; + } + + app.get("/status", async (c) => c.json(await statusPayload(c.req.url))); + + app.post("/start", async (c) => { + const command = await detectVncServerCommand(); + if (!(await commandExists(command)) || !(await isKasmVncInstalled())) { + return c.json({ + ok: false, + message: "KasmVNC vncserver command was not found.", + status: await statusPayload(c.req.url), + }, 500); + } + + const body = await c.req.json<{ geometry?: string; width?: number; height?: number }>().catch(() => undefined); + const settings = desktopSettings(config); + const geometry = normalizeGeometry(body?.geometry ?? ( + body?.width && body?.height ? `${Math.round(body.width)}x${Math.round(body.height)}` : undefined + ), settings.defaultGeometry); + + if (await isRunning(settings.display)) { + return c.json({ ok: true, message: "KasmVNC session is already running.", status: await statusPayload(c.req.url) }); + } + + const args = [ + settings.display, + "-select-de", + settings.desktopEnvironment, + "-geometry", + geometry, + "-depth", + "24", + "-websocketPort", + String(settings.webPort), + "-interface", + settings.bindAddress, + ]; + + pushLog(`start ${command} ${args.join(" ")}`); + const result = await runCommand(command, args, 15000); + if (!result.ok) { + pushLog(`start failed ${result.stderr || result.stdout || result.exitCode}`); + return c.json({ + ok: false, + message: result.stderr || result.stdout || "Failed to start KasmVNC.", + status: await statusPayload(c.req.url), + }, 500); + } + + pushLog(result.stdout || "started"); + return c.json({ ok: true, message: "KasmVNC started.", status: await statusPayload(c.req.url) }); + }); + + app.post("/stop", async (c) => { + const command = await detectVncServerCommand(); + if (!(await commandExists(command)) || !(await isKasmVncInstalled())) { + return c.json({ ok: false, message: "KasmVNC vncserver command was not found.", status: await statusPayload(c.req.url) }, 500); + } + + const settings = desktopSettings(config); + const result = await runCommand(command, ["-kill", settings.display], 8000); + if (!result.ok && await isRunning(settings.display)) { + pushLog(`stop failed ${result.stderr || result.stdout || result.exitCode}`); + return c.json({ + ok: false, + message: result.stderr || result.stdout || "Failed to stop KasmVNC.", + status: await statusPayload(c.req.url), + }, 500); + } + + pushLog(result.stdout || "stopped"); + return c.json({ ok: true, message: "KasmVNC stopped.", status: await statusPayload(c.req.url) }); + }); + + return app; +} diff --git a/src/modules.ts b/src/modules.ts new file mode 100644 index 0000000..c9c37f0 --- /dev/null +++ b/src/modules.ts @@ -0,0 +1,594 @@ +import { Hono } from "hono"; +import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises"; +import { join, resolve, sep } from "node:path"; +import type { Server, ServerWebSocket } from "bun"; +import type { AgentConfig } from "./config.ts"; + +type ModuleWorkspaceInfo = { + name: string; + version: string; + containerName: string; + containerKey: string; + path: string; + folderName: string; +}; + +type ModuleDockerStatus = ModuleWorkspaceInfo & { + running: boolean; + exists: boolean; + status: string; +}; + +const ROS_BRIDGE_MODULES: ModuleWorkspaceInfo[] = [ + { + name: "ROS Bridge · Autoware", + version: "remote", + containerName: "ros-bridge", + containerKey: "autoware-ros-bridge", + path: "", + folderName: "__bridge_autoware", + }, + { + name: "ROS Bridge · Nav2", + version: "remote", + containerName: "ros-bridge", + containerKey: "nav2-ros-bridge", + path: "", + folderName: "__bridge_nav2", + }, +]; + +const MODULE_PARAMS_CACHE_FILE = ".platform-module-params.json"; + +function normalizeRosDomainId(value?: string): string { + const trimmed = String(value ?? process.env.ROS_DOMAIN_ID ?? "").trim(); + return /^\d+$/.test(trimmed) ? trimmed : "0"; +} + +type TerminalSocketData = { + cwd: string; + cols: number; + rows: number; + proc?: ReturnType; +}; + +function expandHome(path: string): string { + if (path === "~") return process.env.HOME ?? "/root"; + if (path.startsWith("~/")) return join(process.env.HOME ?? "/root", path.slice(2)); + return path; +} + +function workspaceRoot(config: AgentConfig): string { + return resolve(expandHome(config.workspace.root)); +} + +function resolveWorkspacePath(config: AgentConfig, inputPath = ""): string { + const root = workspaceRoot(config); + const target = resolve(root, inputPath); + if (target !== root && !target.startsWith(`${root}${sep}`)) { + throw new Error("Paths outside the workspace are not allowed"); + } + return target; +} + +function parseModuleWorkspaceFolder(folderName: string): ModuleWorkspaceInfo | null { + const match = folderName.match(/^\[([^\]]+)\](.+)_v([^/]+)_ws$/); + if (!match) return null; + const [, containerName, name, version] = match; + return { + name, + version, + containerName, + containerKey: `${containerName}-${name}_v${version}`, + path: folderName, + folderName, + }; +} + +function runCommand(command: string, args: string[], timeout = 5000): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolveCommand, rejectCommand) => { + const proc = Bun.spawn([command, ...args], { + stdout: "pipe", + stderr: "pipe", + }); + const timer = setTimeout(() => { + proc.kill(); + rejectCommand(new Error(`${command} timed out`)); + }, timeout); + + Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]).then(([stdout, stderr, exitCode]) => { + clearTimeout(timer); + if (exitCode !== 0) { + rejectCommand(new Error([stderr.trim(), stdout.trim()].filter(Boolean).join("\n") || `${command} exited ${exitCode}`)); + return; + } + resolveCommand({ stdout, stderr }); + }).catch((error) => { + clearTimeout(timer); + rejectCommand(error); + }); + }); +} + +async function readModuleWorkspaces(config: AgentConfig): Promise { + const root = workspaceRoot(config); + await mkdir(root, { recursive: true }); + const entries = await readdir(root, { withFileTypes: true }); + const moduleWorkspaces = entries + .filter((entry) => entry.isDirectory()) + .map((entry) => parseModuleWorkspaceFolder(entry.name)) + .filter((entry): entry is ModuleWorkspaceInfo => Boolean(entry)) + .filter((entry) => entry.containerName !== "ros-bridge") + .sort((a, b) => a.containerName.localeCompare(b.containerName) || a.name.localeCompare(b.name)); + + return [...moduleWorkspaces, ...ROS_BRIDGE_MODULES]; +} + +async function readDockerStatuses(): Promise> { + const statuses = new Map(); + const { stdout } = await runCommand("docker", ["ps", "-a", "--format", "{{.Names}}\t{{.Status}}"], 5000); + for (const line of stdout.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + const [name, status = ""] = trimmed.split("\t"); + statuses.set(name, { + exists: true, + running: /^Up\b/i.test(status), + status, + }); + } + return statuses; +} + +async function readModuleLogs(containerKey: string): Promise<{ ok: boolean; logs: string; message?: string }> { + if (!isAllowedContainerName(containerKey)) { + return { ok: false, logs: "", message: "Invalid container name format" }; + } + + try { + const { stdout, stderr } = await runCommand("docker", ["logs", "--tail", "300", containerKey], 5000); + return { ok: true, logs: [stdout, stderr].filter(Boolean).join("\n") }; + } catch (error) { + return { ok: false, logs: "", message: error instanceof Error ? error.message : String(error) }; + } +} + +function autowareDockerRunScript(config: AgentConfig): string { + return join(workspaceRoot(config), config.modules.autowareRunScript); +} + +function nav2DockerRunScript(config: AgentConfig): string { + return join(workspaceRoot(config), config.modules.nav2RunScript); +} + +function isNav2Module(workspace: ModuleWorkspaceInfo): boolean { + return workspace.name.startsWith("nav2-"); +} + +async function readModuleRosParams(config: AgentConfig, folderName: string): Promise { + try { + const runScript = await readFile(join(workspaceRoot(config), folderName, "run.sh"), "utf8"); + const match = runScript.match(/^ROS_PARAM=(['"])(.*?)\1/m); + return match?.[2] ?? ""; + } catch { + return ""; + } +} + +function extractRosParamsFromCommand(command: string): string | null { + const quoted = command.match(/\/nav2_ws\/run\.sh\s+"([^"]*)"/); + if (quoted) return quoted[1].trim(); + const singleQuoted = command.match(/\/nav2_ws\/run\.sh\s+'([^']*)'/); + if (singleQuoted) return singleQuoted[1].trim(); + const unquoted = command.match(/\/nav2_ws\/run\.sh\s+([^|]+)/); + return unquoted?.[1]?.trim() || null; +} + +async function readContainerRosParams(containerKey: string): Promise { + try { + const { stdout } = await runCommand("docker", ["inspect", containerKey, "--format", "{{json .Config.Cmd}}"], 5000); + const cmd = JSON.parse(stdout.trim()) as string[]; + if (!Array.isArray(cmd)) return ""; + return extractRosParamsFromCommand(cmd.join(" ")) ?? ""; + } catch { + return ""; + } +} + +async function readCachedRosParams(config: AgentConfig, containerKey: string): Promise { + try { + const raw = await readFile(join(workspaceRoot(config), MODULE_PARAMS_CACHE_FILE), "utf8"); + const parsed = JSON.parse(raw) as Record; + return typeof parsed[containerKey] === "string" ? parsed[containerKey] : ""; + } catch { + return ""; + } +} + +async function writeCachedRosParam(config: AgentConfig, containerKey: string, rosParams: string): Promise { + const trimmed = rosParams.trim(); + if (!trimmed) return; + + const cachePath = join(workspaceRoot(config), MODULE_PARAMS_CACHE_FILE); + let parsed: Record = {}; + try { + parsed = JSON.parse(await readFile(cachePath, "utf8")) as Record; + } catch { + parsed = {}; + } + parsed[containerKey] = trimmed; + await writeFile(cachePath, `${JSON.stringify(parsed, null, 2)}\n`, "utf8"); +} + +async function syncRunningModuleRosParams(config: AgentConfig, workspaces: ModuleWorkspaceInfo[]): Promise { + for (const workspace of workspaces) { + if (!isNav2Module(workspace)) continue; + const rosParams = await readContainerRosParams(workspace.containerKey); + if (rosParams) await writeCachedRosParam(config, workspace.containerKey, rosParams); + } +} + +async function resolveModuleRosParams(config: AgentConfig, workspace: ModuleWorkspaceInfo): Promise { + const liveParams = await readContainerRosParams(workspace.containerKey); + if (liveParams) { + await writeCachedRosParam(config, workspace.containerKey, liveParams); + return liveParams; + } + + const cachedParams = await readCachedRosParams(config, workspace.containerKey); + if (cachedParams) return cachedParams; + + return readModuleRosParams(config, workspace.folderName); +} + +async function pathExists(path: string): Promise { + try { + await stat(path); + return true; + } catch { + return false; + } +} + +async function findWorkspaceFolder(config: AgentConfig, pattern: RegExp): Promise { + try { + const root = workspaceRoot(config); + const entries = await readdir(root, { withFileTypes: true }); + const match = entries.find((entry) => entry.isDirectory() && pattern.test(entry.name)); + return match ? join(root, match.name) : null; + } catch { + return null; + } +} + +async function buildNav2ConfigMounts(config: AgentConfig): Promise { + const root = workspaceRoot(config); + const mounts: string[] = []; + const addIfExists = async (hostPath: string, containerPath: string) => { + if (await pathExists(hostPath)) mounts.push(`${hostPath}:${containerPath}:rw`); + }; + + await addIfExists(join(root, "config", "map"), "/nav2_ws/shared_maps"); + await addIfExists(join(root, "config", "map"), "/tmp/.ros"); + await addIfExists(join(root, "config", "map", "selectedMap"), "/nav2_ws/selected_map"); + await addIfExists(join(root, "config", "nav2_config", "vehicle"), "/nav2_ws/install/vehicle_pkg/share/vehicle_pkg/config"); + await addIfExists(join(root, "config", "nav2_config", "perception"), "/nav2_ws/install/perception_pkg/share/perception_pkg/config"); + + const localization3d = await findWorkspaceFolder(config, /^\[furive-localization\]nav2-localization3D_v[^/]+_ws$/); + if (localization3d) { + await addIfExists(join(localization3d, "install"), "/extra_ws/localization3d/install"); + } + + return mounts.join(";"); +} + +function isRosBridgeContainerName(containerKey: string): boolean { + return containerKey === "autoware-ros-bridge" || containerKey === "nav2-ros-bridge"; +} + +function isAllowedContainerName(containerKey: string): boolean { + return isRosBridgeContainerName(containerKey) || /^[A-Za-z0-9_.-]+-[A-Za-z0-9_.-]+_v[A-Za-z0-9_.-]+$/.test(containerKey); +} + +function bridgeStackFromFolderName(folderName: string): "autoware" | "nav2" | null { + if (folderName === "__bridge_autoware") return "autoware"; + if (folderName === "__bridge_nav2") return "nav2"; + const workspace = parseModuleWorkspaceFolder(folderName); + if (workspace?.containerName === "ros-bridge" && workspace.name === "autoware-ros-bridge") return "autoware"; + if (workspace?.containerName === "ros-bridge" && workspace.name === "nav2-ros-bridge") return "nav2"; + return null; +} + +async function findPlanningWorkspacePath(config: AgentConfig): Promise { + try { + const root = workspaceRoot(config); + const entries = await readdir(root, { withFileTypes: true }); + const planning = entries + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .find((name) => /^\[furive-planning\].+_ws$/.test(name)); + return planning ? join(root, planning) : null; + } catch { + return null; + } +} + +async function startRosBridgeContainer(config: AgentConfig, stack: "autoware" | "nav2", rosDomainId = "0") { + const containerKey = stack === "autoware" ? "autoware-ros-bridge" : "nav2-ros-bridge"; + const otherContainerKey = stack === "autoware" ? "nav2-ros-bridge" : "autoware-ros-bridge"; + const dockerStatuses = await readDockerStatuses().catch(() => new Map()); + + if (dockerStatuses.get(containerKey)?.running) return { ok: true, message: "Already running" }; + if (dockerStatuses.get(otherContainerKey)?.running) { + await runCommand("docker", ["stop", otherContainerKey], 15000).catch(() => ({ stdout: "", stderr: "" })); + } + if (dockerStatuses.get(containerKey)?.exists && !dockerStatuses.get(containerKey)?.running) { + await runCommand("docker", ["rm", "-f", containerKey], 10000).catch(() => ({ stdout: "", stderr: "" })); + } + + const args = [ + "run", + "--rm", + "-d", + "--name", + containerKey, + "--net=host", + "-v", + "/dev/shm:/dev/shm", + "-e", + `ROS_DOMAIN_ID=${normalizeRosDomainId(rosDomainId)}`, + "-e", + "ROS_LOCALHOST_ONLY=0", + "-e", + `ROBOT_STACK=${stack}`, + "-e", + `AUTOWARE_ROUTE_INPUT=${config.ros.routeInput}`, + "-e", + `AUTOWARE_ROUTE_PREFIX=${config.ros.routePrefix}`, + "-e", + "RCUTILS_COLORIZED_OUTPUT=1", + "-e", + "RCUTILS_LOGGING_BUFFERED_STREAM=0", + "-e", + "PYTHONUNBUFFERED=1", + "-e", + `MAP_POINT_STRIDE=${config.ros.mapPointStride}`, + "-e", + `MAP_POINT_LIMIT=${config.ros.mapPointLimit}`, + ]; + + if (stack === "autoware") { + args.push("-e", "AUTOWARE_ROUTE_INPUT=rviz_topic"); + const planningWorkspacePath = await findPlanningWorkspacePath(config); + if (planningWorkspacePath) args.push("-v", `${planningWorkspacePath}:/autoware_ws:ro`); + } + if (stack === "nav2") { + const root = workspaceRoot(config); + await mkdir(root, { recursive: true }); + args.push("-v", `${root}:/workspace`); + args.push("-e", "SAVED_MAPS_PATH=/workspace/maps"); + } + + args.push(stack === "autoware" ? config.ros.autowareImage : config.ros.nav2Image); + if (stack === "nav2") args.push("bash", "-lc", "exec python3 /ros_bridge/bridge.py"); + await runCommand("docker", args, 15000); + return { ok: true, message: "Bridge started" }; +} + +export async function statusList(config: AgentConfig): Promise { + const workspaces = await readModuleWorkspaces(config); + const statuses = await readDockerStatuses().catch(() => new Map()); + await syncRunningModuleRosParams(config, workspaces).catch(() => undefined); + + return workspaces.map((workspace) => { + const dockerStatus = statuses.get(workspace.containerKey); + return { + ...workspace, + running: Boolean(dockerStatus?.running), + exists: Boolean(dockerStatus?.exists), + status: dockerStatus?.status ?? "Not created", + }; + }); +} + +export async function startModule(config: AgentConfig, folderName: string, rosDomainId = "0") { + const bridgeStack = bridgeStackFromFolderName(folderName); + if (bridgeStack) return startRosBridgeContainer(config, bridgeStack, rosDomainId); + + const workspace = parseModuleWorkspaceFolder(folderName); + if (!workspace) return { ok: false, message: "Invalid module folder name format" }; + + const nav2Module = isNav2Module(workspace); + const scriptPath = nav2Module ? nav2DockerRunScript(config) : autowareDockerRunScript(config); + try { + await stat(scriptPath); + } catch { + return { ok: false, message: `${scriptPath} was not found` }; + } + + const statuses = await readDockerStatuses().catch(() => new Map()); + const current = statuses.get(workspace.containerKey); + if (current?.running) return { ok: true, message: "Already running" }; + if (current?.exists && !current.running) { + await runCommand("docker", ["rm", "-f", workspace.containerKey], 10000).catch(() => ({ stdout: "", stderr: "" })); + } + + const root = workspaceRoot(config); + const image = nav2Module ? "future-nav2:humble" : "ghcr.io/autowarefoundation/autoware-universe:humble-20240301-cuda"; + const rosParams = nav2Module ? await resolveModuleRosParams(config, workspace) : ""; + const env: Record = { + ...process.env, + SHARD_DIR: root, + ROS_DOMAIN_ID: normalizeRosDomainId(rosDomainId || config.ros.domainId), + ROBOT_STACK: nav2Module ? "nav2" : "autoware", + AUTOWARE_ROUTE_INPUT: config.ros.routeInput, + AUTOWARE_ROUTE_PREFIX: config.ros.routePrefix, + MAP_POINT_STRIDE: String(config.ros.mapPointStride), + MAP_POINT_LIMIT: String(config.ros.mapPointLimit), + }; + if (nav2Module) { + env.CONFIG_MOUNTS = await buildNav2ConfigMounts(config); + } + const child = Bun.spawn([ + "bash", + scriptPath, + workspace.containerKey, + workspace.folderName, + "localhost", + "localhost", + image, + rosParams, + normalizeRosDomainId(rosDomainId), + "-d", + ], { + cwd: root, + env, + stdout: "ignore", + stderr: "ignore", + }); + child.unref(); + return { ok: true, message: "Start request sent" }; +} + +export async function stopModule(config: AgentConfig, containerKey: string) { + if (!isAllowedContainerName(containerKey)) { + return { ok: false, message: "Invalid container name format" }; + } + + try { + const rosParams = await readContainerRosParams(containerKey); + if (rosParams) await writeCachedRosParam(config, containerKey, rosParams); + await runCommand("docker", ["stop", containerKey], 15000); + return { ok: true, message: "Stopped" }; + } catch (error) { + return { ok: false, message: error instanceof Error ? error.message : String(error) }; + } +} + +export function createModuleApi(config: AgentConfig): Hono { + const app = new Hono(); + + app.get("/workspaces", async (c) => { + try { + return c.json({ ok: true, workspaces: await readModuleWorkspaces(config) }); + } catch (error) { + return c.json({ ok: false, message: error instanceof Error ? error.message : "Failed to load module list", workspaces: [] }, 500); + } + }); + + app.get("/docker-statuses", async (c) => { + try { + return c.json({ ok: true, statuses: await statusList(config) }); + } catch (error) { + return c.json({ ok: false, message: error instanceof Error ? error.message : "Failed to load Docker status", statuses: [] }, 500); + } + }); + + app.get("/logs", async (c) => { + return c.json(await readModuleLogs(c.req.query("containerKey") ?? "")); + }); + + app.post("/start", async (c) => { + try { + const body = await c.req.json<{ folderName?: string; rosDomainId?: string }>().catch(() => undefined); + return c.json(await startModule(config, body?.folderName ?? "", body?.rosDomainId)); + } catch (error) { + return c.json({ ok: false, message: error instanceof Error ? error.message : String(error) }, 500); + } + }); + + app.post("/stop", async (c) => { + try { + const body = await c.req.json<{ containerKey?: string }>().catch(() => undefined); + return c.json(await stopModule(config, body?.containerKey ?? "")); + } catch (error) { + return c.json({ ok: false, message: error instanceof Error ? error.message : String(error) }, 500); + } + }); + + return app; +} + +export function handleModuleTerminalUpgrade(config: AgentConfig, req: Request, server: Server): Response { + const url = new URL(req.url); + let cwd: string; + try { + cwd = resolveWorkspacePath(config, url.searchParams.get("path") ?? ""); + } catch (error) { + return new Response(error instanceof Error ? error.message : String(error), { status: 400 }); + } + + const cols = Math.max(20, Math.min(300, Number(url.searchParams.get("cols") ?? 80) || 80)); + const rows = Math.max(5, Math.min(120, Number(url.searchParams.get("rows") ?? 24) || 24)); + const upgraded = server.upgrade(req, { data: { cwd, cols, rows } }); + return upgraded ? new Response(null) : new Response("WebSocket upgrade failed", { status: 400 }); +} + +export const moduleTerminalWebSocket = { + open(ws: ServerWebSocket) { + const shellPath = process.env.SHELL || "/bin/bash"; + const command = `stty cols ${ws.data.cols} rows ${ws.data.rows}; exec '${shellPath.replace(/'/g, "'\\''")}' -i`; + const proc = Bun.spawn(["script", "-q", "/dev/null", "-c", command], { + cwd: ws.data.cwd, + env: { + ...process.env, + TERM: "xterm-256color", + COLORTERM: "truecolor", + COLUMNS: String(ws.data.cols), + LINES: String(ws.data.rows), + }, + stdout: "pipe", + stderr: "pipe", + stdin: "pipe", + }); + ws.data.proc = proc; + + void (async () => { + const reader = proc.stdout.getReader(); + const decoder = new TextDecoder(); + try { + while (true) { + const chunk = await reader.read(); + if (chunk.done) break; + ws.send(decoder.decode(chunk.value, { stream: true })); + } + } catch { + // Client closed. + } + })(); + + void (async () => { + const reader = proc.stderr.getReader(); + const decoder = new TextDecoder(); + try { + while (true) { + const chunk = await reader.read(); + if (chunk.done) break; + ws.send(decoder.decode(chunk.value, { stream: true })); + } + } catch { + // Client closed. + } + })(); + + proc.exited.then((code) => { + try { + ws.send(`\r\n[terminal exited: ${code ?? "unknown"}]\r\n`); + ws.close(); + } catch { + // Client closed. + } + }).catch(() => undefined); + }, + message(ws: ServerWebSocket, message: string | Buffer) { + const stdin = ws.data.proc?.stdin as { write?: (data: string | Buffer) => unknown } | undefined; + stdin?.write?.(message); + }, + close(ws: ServerWebSocket) { + ws.data.proc?.kill(); + }, +}; diff --git a/src/server.tsx b/src/server.tsx new file mode 100644 index 0000000..a0335b7 --- /dev/null +++ b/src/server.tsx @@ -0,0 +1,386 @@ +import { Hono } from "hono"; +import { logger } from "hono/logger"; +import { cors } from "hono/cors"; +import { serveStatic } from "hono/bun"; +import { chmod, mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { getConfigSummary, loadConfig, saveConfigPatch } from "./config.ts"; +import { CameraManager } from "./camera.ts"; +import { createWorkspaceApi } from "./workspace.ts"; +import { createModuleApi, handleModuleTerminalUpgrade, moduleTerminalWebSocket } from "./modules.ts"; +import { createDisplayApi } from "./display.ts"; +import { createKasmVncApi } from "./kasmVnc.ts"; +import { createBridgeApi, createViewerControlApi } from "./bridge.ts"; +import { createVisualizationApi } from "./visualization.ts"; + +const config = loadConfig(); +const app = new Hono(); +const camera = new CameraManager(config); +const AUTHORIZED_KEYS_BEGIN = "# BEGIN AUTONOMY STUDIO MANAGED KEYS"; +const AUTHORIZED_KEYS_END = "# END AUTONOMY STUDIO MANAGED KEYS"; + +function kasmVncUrl(requestUrl: string): string { + const hostname = new URL(requestUrl).hostname; + const protocol = config.desktop.protocol; + const port = config.desktop.webPort; + return `${protocol}://${hostname}:${port}`; +} + +function bearerToken(headerValue: string | undefined): string { + const value = headerValue?.trim() ?? ""; + if (!value) return ""; + const [type, token] = value.split(/\s+/, 2); + return type?.toLowerCase() === "bearer" ? token ?? "" : value; +} + +function normalizePublicSshKey(value: unknown): string | null { + if (typeof value !== "string") return null; + const normalized = value.trim().replace(/\s+/g, " "); + if (!normalized) return null; + if (!/^(ssh-ed25519|ssh-rsa|ecdsa-sha2-nistp256|ecdsa-sha2-nistp384|ecdsa-sha2-nistp521)\s+[A-Za-z0-9+/=]+(?:\s+.*)?$/.test(normalized)) { + return null; + } + return normalized; +} + +function resolveSshHome(sshUser?: string | null): string { + const user = sshUser?.trim(); + const currentUser = process.env.AGENT_SSH_USER || process.env.USER || ""; + if (!user || user === currentUser) return process.env.HOME || "/root"; + if (user === "root") return "/root"; + return `/home/${user}`; +} + +async function syncAuthorizedKeys(publicKeys: string[], sshUser?: string | null): Promise<{ installed: number; path: string }> { + const home = resolveSshHome(sshUser); + const sshDir = join(home, ".ssh"); + const authorizedKeysPath = join(sshDir, "authorized_keys"); + const uniqueKeys = Array.from(new Set(publicKeys.map(normalizePublicSshKey).filter((key): key is string => Boolean(key)))); + await mkdir(sshDir, { recursive: true, mode: 0o700 }); + + const current = await readFile(authorizedKeysPath, "utf8").catch(() => ""); + const block = [ + AUTHORIZED_KEYS_BEGIN, + ...uniqueKeys.map((key) => `${key} autonomy-studio`), + AUTHORIZED_KEYS_END, + ].join("\n"); + const blockPattern = new RegExp(`\\n?${AUTHORIZED_KEYS_BEGIN}[\\s\\S]*?${AUTHORIZED_KEYS_END}\\n?`, "m"); + const base = current.replace(blockPattern, "").trimEnd(); + const next = [base, block].filter(Boolean).join("\n\n") + "\n"; + + await writeFile(authorizedKeysPath, next, { mode: 0o600 }); + await chmod(sshDir, 0o700); + await chmod(authorizedKeysPath, 0o600); + return { installed: uniqueKeys.length, path: authorizedKeysPath }; +} + +function isProtectedPath(pathname: string): boolean { + if (pathname === "/health") return false; + if (pathname.startsWith("/api/")) return true; + if (pathname.startsWith("/visualization/")) return true; + return ["/plan", "/drive", "/clear", "/stop", "/reset"].includes(pathname); +} + +app.use(logger()); +app.use("*", cors({ + origin: "*", + allowHeaders: ["Content-Type"], + allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], +})); +app.use("/static/*", serveStatic({ root: "./public" })); +app.get("/vendor/gstwebrtc-api.js", () => { + return new Response(Bun.file("node_modules/@tomoxv/gstwebrtc-api/dist/gstwebrtc-api-2.0.0.min.js"), { + headers: { "content-type": "application/javascript; charset=utf-8" }, + }); +}); + +app.use("*", async (c, next) => { + const frameAncestors = config.iframe.allowOrigins.join(" "); + c.header("X-Content-Type-Options", "nosniff"); + c.header("Referrer-Policy", "no-referrer"); + c.header("Content-Security-Policy", `frame-ancestors 'self' ${frameAncestors}`); + await next(); +}); + +app.use("*", async (c, next) => { + const gatewayToken = config.security.gatewayToken.trim(); + if (!gatewayToken || !isProtectedPath(new URL(c.req.url).pathname)) { + await next(); + return; + } + + const providedToken = + c.req.header("x-agent-gateway-token")?.trim() || + bearerToken(c.req.header("authorization")); + + if (providedToken !== gatewayToken) { + return c.json({ ok: false, message: "invalid_agent_gateway_token" }, 401); + } + + await next(); +}); + +app.get("/styles.css", () => { + return new Response(Bun.file("public/styles.css"), { + headers: { "content-type": "text/css; charset=utf-8" }, + }); +}); + +app.get("/camera.js", () => { + return new Response(Bun.file("public/camera.js"), { + headers: { "content-type": "application/javascript; charset=utf-8" }, + }); +}); + +app.get("/app.js", () => { + return new Response(Bun.file("public/app.js"), { + headers: { "content-type": "application/javascript; charset=utf-8" }, + }); +}); + +app.get("/api/camera/mjpeg", (c) => { + camera.stop(); + + const url = new URL(c.req.url); + const device = url.searchParams.get("device") || config.camera.videoDevice; + const width = Number(url.searchParams.get("width") || config.camera.width); + const height = Number(url.searchParams.get("height") || config.camera.height); + const framerate = Number(url.searchParams.get("framerate") || config.camera.framerate); + + const proc = Bun.spawn([ + "gst-launch-1.0", + "-q", + "v4l2src", + `device=${device}`, + "do-timestamp=true", + "!", + `video/x-raw,format=UYVY,width=${width},height=${height},framerate=${framerate}/1`, + "!", + "videoconvert", + "!", + "jpegenc", + "quality=70", + "!", + "multipartmux", + "boundary=frame", + "!", + "fdsink", + "fd=1", + ], { + stdout: "pipe", + stderr: "pipe", + }); + + void new Response(proc.stderr).text().then((message) => { + if (message.trim()) console.warn(`[camera-mjpeg] ${message.trim()}`); + }).catch(() => undefined); + + c.req.raw.signal.addEventListener("abort", () => { + proc.kill(); + }, { once: true }); + + return new Response(proc.stdout, { + headers: { + "content-type": "multipart/x-mixed-replace; boundary=frame", + "cache-control": "no-store", + "x-accel-buffering": "no", + }, + }); +}); + +app.get("/", (c) => { + return c.html( + + + + + Platform Agent + + + +
+
+
+

Platform Agent

+

Manage communication, remote desktop, ROS bridge, camera, and module runtime settings for Jetson or Mobility PC devices.

+
+ +
+
+
+
+

Server

+ + + +
+
+

Workspace

+ + +
+
+

Remote Desktop

+ + + + +
+
+

Camera

+ + + + + + + + +
+
+

ROS Bridge

+ + + + + + + +
+
+

Module Scripts

+ + +
+
+ + + +
+
+
+ + + , + ); +}); + +app.get("/remote", (c) => { + const frameSrc = kasmVncUrl(c.req.url); + + return c.html( + + + + + Platform Agent Remote Desktop + + + +
+