Initial commit
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
node_modules
|
||||
.git
|
||||
.agents
|
||||
.codex
|
||||
.env
|
||||
bun.lockb
|
||||
npm-debug.log
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
.dockerignore
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
.codex
|
||||
.agents
|
||||
|
||||
+41
@@ -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"]
|
||||
@@ -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://<platform-ip>: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://<platform-ip>: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
|
||||
```
|
||||
@@ -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://<device-ip>:3100
|
||||
```
|
||||
|
||||
KasmVNC iframe:
|
||||
|
||||
```text
|
||||
http://<device-ip>: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
|
||||
```
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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=="],
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
Executable
+47
@@ -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"
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
+107
@@ -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]) => `
|
||||
<article>
|
||||
<span>${label}</span>
|
||||
<strong>${value}</strong>
|
||||
</article>
|
||||
`).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.";
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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"]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
fastapi>=0.110.0
|
||||
uvicorn[standard]>=0.29.0
|
||||
pydantic>=2.0.0
|
||||
Executable
+87
@@ -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
|
||||
|
||||
Executable
+778
@@ -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" <<EOF
|
||||
[Unit]
|
||||
Description=Platform Agent
|
||||
After=network-online.target docker.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=${RUN_USER}
|
||||
WorkingDirectory=${INSTALL_DIR}
|
||||
ExecStart=${bun_bin} src/server.tsx
|
||||
Restart=always
|
||||
RestartSec=2
|
||||
Environment=HOST=0.0.0.0
|
||||
Environment=AGENT_CONFIG=${INSTALL_DIR}/agent.config.json
|
||||
Environment=AGENT_SSH_USER=${RUN_USER}
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
as_root cp "$tmp_service" "$service_path"
|
||||
rm -f "$tmp_service"
|
||||
as_root systemctl daemon-reload
|
||||
as_root systemctl enable "$SERVICE_NAME"
|
||||
as_root systemctl restart "$SERVICE_NAME"
|
||||
}
|
||||
|
||||
print_host_notes() {
|
||||
log ""
|
||||
log "Install complete: ${INSTALL_DIR}"
|
||||
log "Service: systemctl status ${SERVICE_NAME}"
|
||||
log "Settings UI: http://<device-ip>: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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
Executable
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -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 <env-output-path>")
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
Executable
+263
@@ -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
|
||||
@@ -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
|
||||
Executable
+191
@@ -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."
|
||||
+260
@@ -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<Response> {
|
||||
return fetch(`${bridgeUrl(config)}${path}`, init);
|
||||
}
|
||||
|
||||
async function bridgeJson<T>(config: AgentConfig, path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await bridgeFetch(config, path, init);
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
+246
@@ -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<typeof Bun.spawn>;
|
||||
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<CameraDevice[]> {
|
||||
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<string> {
|
||||
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<CameraStartOptions> {
|
||||
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<CameraStartOptions>) {
|
||||
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<CameraStartOptions>): 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<Uint8Array> | 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);
|
||||
}
|
||||
}
|
||||
+383
@@ -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<string, unknown> {
|
||||
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<AgentConfig> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
import { Hono } from "hono";
|
||||
|
||||
type ProcessHandle = ReturnType<typeof Bun.spawn>;
|
||||
|
||||
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<boolean> {
|
||||
return (await runShell(`command -v ${command}`)).ok;
|
||||
}
|
||||
|
||||
async function isDisplayProcessRunning(): Promise<boolean> {
|
||||
return (await runShell(`pgrep -f "Xvfb ${displayNumber}" >/dev/null || pgrep -f "Xorg ${displayNumber}" >/dev/null`)).ok;
|
||||
}
|
||||
|
||||
async function isDisplayAvailable(): Promise<boolean> {
|
||||
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<boolean> {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
if (await isDisplayAvailable()) return true;
|
||||
await Bun.sleep(150);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function cleanupDisplay(): Promise<void> {
|
||||
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;
|
||||
}
|
||||
+192
@@ -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<CommandResult> {
|
||||
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<boolean> {
|
||||
return (await runCommand("sh", ["-lc", `command -v ${command}`], 2000)).ok;
|
||||
}
|
||||
|
||||
async function detectVncServerCommand(): Promise<string> {
|
||||
if (await commandExists("kasmvncserver")) return "kasmvncserver";
|
||||
if (await commandExists("vncserver")) return "vncserver";
|
||||
return "vncserver";
|
||||
}
|
||||
|
||||
async function isKasmVncInstalled(): Promise<boolean> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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;
|
||||
}
|
||||
+594
@@ -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<typeof Bun.spawn>;
|
||||
};
|
||||
|
||||
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<ModuleWorkspaceInfo[]> {
|
||||
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<Map<string, { running: boolean; exists: boolean; status: string }>> {
|
||||
const statuses = new Map<string, { running: boolean; exists: boolean; status: string }>();
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
try {
|
||||
const raw = await readFile(join(workspaceRoot(config), MODULE_PARAMS_CACHE_FILE), "utf8");
|
||||
const parsed = JSON.parse(raw) as Record<string, string>;
|
||||
return typeof parsed[containerKey] === "string" ? parsed[containerKey] : "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
async function writeCachedRosParam(config: AgentConfig, containerKey: string, rosParams: string): Promise<void> {
|
||||
const trimmed = rosParams.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
const cachePath = join(workspaceRoot(config), MODULE_PARAMS_CACHE_FILE);
|
||||
let parsed: Record<string, string> = {};
|
||||
try {
|
||||
parsed = JSON.parse(await readFile(cachePath, "utf8")) as Record<string, string>;
|
||||
} catch {
|
||||
parsed = {};
|
||||
}
|
||||
parsed[containerKey] = trimmed;
|
||||
await writeFile(cachePath, `${JSON.stringify(parsed, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
async function syncRunningModuleRosParams(config: AgentConfig, workspaces: ModuleWorkspaceInfo[]): Promise<void> {
|
||||
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<string> {
|
||||
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<boolean> {
|
||||
try {
|
||||
await stat(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function findWorkspaceFolder(config: AgentConfig, pattern: RegExp): Promise<string | null> {
|
||||
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<string> {
|
||||
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<string | null> {
|
||||
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<string, { running: boolean; exists: boolean; status: string }>());
|
||||
|
||||
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<ModuleDockerStatus[]> {
|
||||
const workspaces = await readModuleWorkspaces(config);
|
||||
const statuses = await readDockerStatuses().catch(() => new Map<string, { running: boolean; exists: boolean; status: string }>());
|
||||
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<string, { running: boolean; exists: boolean; status: string }>());
|
||||
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<string, string | undefined> = {
|
||||
...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<TerminalSocketData>): 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<TerminalSocketData>) {
|
||||
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<TerminalSocketData>, message: string | Buffer) {
|
||||
const stdin = ws.data.proc?.stdin as { write?: (data: string | Buffer) => unknown } | undefined;
|
||||
stdin?.write?.(message);
|
||||
},
|
||||
close(ws: ServerWebSocket<TerminalSocketData>) {
|
||||
ws.data.proc?.kill();
|
||||
},
|
||||
};
|
||||
+386
@@ -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(
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Platform Agent</title>
|
||||
<link rel="stylesheet" href="/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="admin-shell">
|
||||
<header class="admin-header">
|
||||
<div>
|
||||
<h1>Platform Agent</h1>
|
||||
<p>Manage communication, remote desktop, ROS bridge, camera, and module runtime settings for Jetson or Mobility PC devices.</p>
|
||||
</div>
|
||||
<nav>
|
||||
<a href="/remote" target="_blank">Remote</a>
|
||||
<a href="/camera" target="_blank">Camera</a>
|
||||
</nav>
|
||||
</header>
|
||||
<section class="status-grid" data-status-grid />
|
||||
<form class="config-form" data-config-form>
|
||||
<section>
|
||||
<h2>Server</h2>
|
||||
<label>Host<input name="server.host" /></label>
|
||||
<label>Port<input name="server.port" type="number" min="1" max="65535" /></label>
|
||||
<label>Public URL<input name="server.publicBaseUrl" /></label>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Workspace</h2>
|
||||
<label>Root<input name="workspace.root" /></label>
|
||||
<label>Max file size<input name="workspace.maxFileSize" type="number" min="1" /></label>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Remote Desktop</h2>
|
||||
<label>Display<input name="desktop.display" /></label>
|
||||
<label>Web port<input name="desktop.webPort" type="number" min="1" max="65535" /></label>
|
||||
<label>Geometry<input name="desktop.geometry" /></label>
|
||||
<label>Desktop<input name="desktop.desktop" /></label>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Camera</h2>
|
||||
<label class="inline"><input name="camera.enabled" type="checkbox" /> Enabled</label>
|
||||
<label>Device<input name="camera.videoDevice" /></label>
|
||||
<label>Signaling URL<input name="camera.signalingUrl" /></label>
|
||||
<label>Room ID<input name="camera.roomId" /></label>
|
||||
<label>Width<input name="camera.width" type="number" min="1" /></label>
|
||||
<label>Height<input name="camera.height" type="number" min="1" /></label>
|
||||
<label>Framerate<input name="camera.framerate" type="number" min="1" /></label>
|
||||
<label>Bitrate<input name="camera.bitrate" type="number" min="1" /></label>
|
||||
</section>
|
||||
<section>
|
||||
<h2>ROS Bridge</h2>
|
||||
<label>Stack<select name="ros.stack"><option value="autoware">Autoware</option><option value="nav2">Nav2</option></select></label>
|
||||
<label>ROS_DOMAIN_ID<input name="ros.domainId" inputmode="numeric" /></label>
|
||||
<label>Bridge URL<input name="ros.bridgeUrl" /></label>
|
||||
<label>Route input<select name="ros.routeInput"><option value="rviz_topic">rviz_topic</option><option value="service">service</option></select></label>
|
||||
<label>Route prefix<input name="ros.routePrefix" /></label>
|
||||
<label>Map point stride<input name="ros.mapPointStride" type="number" min="1" /></label>
|
||||
<label>Map point limit<input name="ros.mapPointLimit" type="number" min="1000" /></label>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Module Scripts</h2>
|
||||
<label>Autoware script<input name="modules.autowareRunScript" /></label>
|
||||
<label>Nav2 script<input name="modules.nav2RunScript" /></label>
|
||||
</section>
|
||||
<footer>
|
||||
<button type="button" data-refresh>Refresh</button>
|
||||
<button type="submit">Save config</button>
|
||||
<span data-message></span>
|
||||
</footer>
|
||||
</form>
|
||||
</main>
|
||||
<script type="module" src="/app.js"></script>
|
||||
</body>
|
||||
</html>,
|
||||
);
|
||||
});
|
||||
|
||||
app.get("/remote", (c) => {
|
||||
const frameSrc = kasmVncUrl(c.req.url);
|
||||
|
||||
return c.html(
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Platform Agent Remote Desktop</title>
|
||||
<link rel="stylesheet" href="/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<iframe
|
||||
title="KasmVNC Remote Desktop"
|
||||
src={frameSrc}
|
||||
referrerPolicy="no-referrer"
|
||||
allow="clipboard-read; clipboard-write; fullscreen; pointer-lock"
|
||||
/>
|
||||
</main>
|
||||
</body>
|
||||
</html>,
|
||||
);
|
||||
});
|
||||
|
||||
app.get("/camera", (c) => {
|
||||
return c.html(
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{config.camera.streamName} Camera</title>
|
||||
<link rel="stylesheet" href="/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="camera-shell">
|
||||
<video autoplay playsinline muted data-video />
|
||||
<img alt="camera stream" data-mjpeg />
|
||||
<section class="camera-panel">
|
||||
<strong data-state>initializing</strong>
|
||||
<div>
|
||||
<select data-mode>
|
||||
<option value="mjpeg">MJPEG</option>
|
||||
<option value="webrtc">WebRTC</option>
|
||||
</select>
|
||||
<button type="button" data-start>Start</button>
|
||||
<button type="button" data-stop>Stop</button>
|
||||
</div>
|
||||
<pre data-logs />
|
||||
</section>
|
||||
</main>
|
||||
<script type="module" src="/camera.js"></script>
|
||||
</body>
|
||||
</html>,
|
||||
);
|
||||
});
|
||||
|
||||
app.get("/health", (c) => c.json({
|
||||
ok: true,
|
||||
sshUser: process.env.AGENT_SSH_USER || process.env.USER || null,
|
||||
workspaceRoot: config.workspace.root,
|
||||
}));
|
||||
app.post("/api/ssh/authorized-keys", async (c) => {
|
||||
const body = await c.req.json().catch(() => ({}));
|
||||
const keys = Array.isArray(body?.publicKeys) ? body.publicKeys.map(String) : [];
|
||||
const sshUser = typeof body?.sshUser === "string" ? body.sshUser : null;
|
||||
const result = await syncAuthorizedKeys(keys, sshUser);
|
||||
return c.json({ ok: true, ...result });
|
||||
});
|
||||
app.get("/api/config", (c) => c.json(getConfigSummary(config)));
|
||||
app.post("/api/config", async (c) => {
|
||||
const body = await c.req.json().catch(() => ({}));
|
||||
return c.json(getConfigSummary(await saveConfigPatch(config, body)));
|
||||
});
|
||||
app.put("/api/config", async (c) => {
|
||||
const body = await c.req.json().catch(() => ({}));
|
||||
return c.json(getConfigSummary(await saveConfigPatch(config, body)));
|
||||
});
|
||||
app.get("/api/camera/status", (c) => c.json(camera.status()));
|
||||
app.get("/api/camera/devices", async (c) => c.json(await camera.devices()));
|
||||
app.post("/api/camera/start", async (c) => {
|
||||
const body = await c.req.json().catch(() => ({}));
|
||||
return c.json(camera.start(body));
|
||||
});
|
||||
app.post("/api/camera/stop", (c) => c.json(camera.stop()));
|
||||
app.route("/api/workspace", createWorkspaceApi(config));
|
||||
app.route("/api/kasmvnc", createKasmVncApi(config));
|
||||
app.route("/api/display", createDisplayApi());
|
||||
app.route("/api/modules", createModuleApi(config));
|
||||
app.route("/api/bridge", createBridgeApi(config));
|
||||
app.route("/visualization", createVisualizationApi(config));
|
||||
app.route("/", createViewerControlApi(config));
|
||||
|
||||
Bun.serve({
|
||||
hostname: config.server.host,
|
||||
port: config.server.port,
|
||||
fetch(req, server) {
|
||||
const url = new URL(req.url);
|
||||
if (url.pathname === "/api/modules/terminal") return handleModuleTerminalUpgrade(config, req, server as never);
|
||||
return app.fetch(req, server);
|
||||
},
|
||||
websocket: {
|
||||
open(ws) {
|
||||
moduleTerminalWebSocket.open(ws as never);
|
||||
},
|
||||
message(ws, message) {
|
||||
moduleTerminalWebSocket.message(ws as never, message);
|
||||
},
|
||||
close(ws) {
|
||||
moduleTerminalWebSocket.close(ws as never);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`Agent listening on ${config.server.publicBaseUrl}`);
|
||||
console.log(`Embedding KasmVNC: ${kasmVncUrl(config.server.publicBaseUrl)}`);
|
||||
@@ -0,0 +1,149 @@
|
||||
import { Hono } from "hono";
|
||||
import type { AgentConfig } from "./config.ts";
|
||||
import { startModule, statusList, stopModule } from "./modules.ts";
|
||||
|
||||
type VisualizationStack = "autoware" | "nav2";
|
||||
|
||||
type VisualizationProfile = {
|
||||
type?: string;
|
||||
bridge?: {
|
||||
image?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type VisualizationStartBody = {
|
||||
stack?: string;
|
||||
rosDomainId?: string;
|
||||
profile?: VisualizationProfile;
|
||||
};
|
||||
|
||||
function normalizeStack(value?: string | null): VisualizationStack {
|
||||
const stack = String(value ?? "").trim().toLowerCase();
|
||||
return stack === "nav2" || stack === "slam" || stack === "real_slam" || stack === "real_navigation"
|
||||
? "nav2"
|
||||
: "autoware";
|
||||
}
|
||||
|
||||
function normalizeRosDomainId(value?: string | null): string {
|
||||
const trimmed = String(value ?? "").trim();
|
||||
return /^\d+$/.test(trimmed) ? trimmed : "0";
|
||||
}
|
||||
|
||||
function bridgeFolderName(stack: VisualizationStack): string {
|
||||
return stack === "nav2" ? "__bridge_nav2" : "__bridge_autoware";
|
||||
}
|
||||
|
||||
function bridgeContainerKey(stack: VisualizationStack): string {
|
||||
return stack === "nav2" ? "nav2-ros-bridge" : "autoware-ros-bridge";
|
||||
}
|
||||
|
||||
function applyProfileToConfig(config: AgentConfig, stack: VisualizationStack, body: VisualizationStartBody): void {
|
||||
config.ros.stack = stack;
|
||||
config.ros.domainId = normalizeRosDomainId(body.rosDomainId ?? config.ros.domainId);
|
||||
|
||||
const bridgeImage = body.profile?.bridge?.image?.trim();
|
||||
if (!bridgeImage) return;
|
||||
if (stack === "nav2") config.ros.nav2Image = bridgeImage;
|
||||
else config.ros.autowareImage = bridgeImage;
|
||||
}
|
||||
|
||||
async function readBridgeStatus(config: AgentConfig, requestedStack?: VisualizationStack) {
|
||||
const statuses = await statusList(config);
|
||||
const autoware = statuses.find((item) => item.containerKey === "autoware-ros-bridge");
|
||||
const nav2 = statuses.find((item) => item.containerKey === "nav2-ros-bridge");
|
||||
const runningStack = nav2?.running ? "nav2" : autoware?.running ? "autoware" : null;
|
||||
const stack = runningStack ?? requestedStack ?? config.ros.stack;
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
running: Boolean(runningStack),
|
||||
stack,
|
||||
configured: {
|
||||
stack: config.ros.stack,
|
||||
domainId: config.ros.domainId,
|
||||
bridgeUrl: config.ros.bridgeUrl,
|
||||
autowareImage: config.ros.autowareImage,
|
||||
nav2Image: config.ros.nav2Image,
|
||||
},
|
||||
containers: {
|
||||
autoware: autoware ? { running: autoware.running, status: autoware.status } : null,
|
||||
nav2: nav2 ? { running: nav2.running, status: nav2.status } : null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createVisualizationApi(config: AgentConfig): Hono {
|
||||
const app = new Hono();
|
||||
|
||||
app.get("/status", async (c) => {
|
||||
try {
|
||||
return c.json(await readBridgeStatus(config));
|
||||
} catch (error) {
|
||||
return c.json({
|
||||
ok: false,
|
||||
running: false,
|
||||
stack: null,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
}, 502);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/start", async (c) => {
|
||||
try {
|
||||
const body = await c.req.json<VisualizationStartBody>().catch(() => ({} as VisualizationStartBody));
|
||||
const stack = normalizeStack(body.stack ?? body.profile?.type ?? config.ros.stack);
|
||||
applyProfileToConfig(config, stack, body);
|
||||
|
||||
const result = await startModule(config, bridgeFolderName(stack), config.ros.domainId);
|
||||
if (!result.ok) {
|
||||
return c.json({
|
||||
ok: false,
|
||||
running: false,
|
||||
stack,
|
||||
message: result.message || "Failed to start visualization bridge.",
|
||||
}, 502);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
...(await readBridgeStatus(config, stack)),
|
||||
running: true,
|
||||
stack,
|
||||
message: result.message,
|
||||
});
|
||||
} catch (error) {
|
||||
return c.json({
|
||||
ok: false,
|
||||
running: false,
|
||||
stack: null,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
}, 502);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/stop", async (c) => {
|
||||
try {
|
||||
const body = await c.req.json<VisualizationStartBody>().catch(() => ({} as VisualizationStartBody));
|
||||
const stack = normalizeStack(body.stack ?? body.profile?.type ?? config.ros.stack);
|
||||
const targets = new Set(["autoware-ros-bridge", "nav2-ros-bridge", bridgeContainerKey(stack)]);
|
||||
|
||||
for (const containerKey of targets) {
|
||||
await stopModule(config, containerKey).catch(() => ({ ok: false }));
|
||||
}
|
||||
|
||||
return c.json({
|
||||
ok: true,
|
||||
running: false,
|
||||
stack: null,
|
||||
});
|
||||
} catch (error) {
|
||||
return c.json({
|
||||
ok: false,
|
||||
running: false,
|
||||
stack: null,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
}, 502);
|
||||
}
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
import { Hono } from "hono";
|
||||
import { mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { basename, extname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import type { AgentConfig } from "./config.ts";
|
||||
|
||||
interface WorkspaceEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
type: "file" | "directory";
|
||||
size: number;
|
||||
modifiedAt: string;
|
||||
}
|
||||
|
||||
const moduleNamePattern = /^\[[^\]]+\](.+)_v[^/]+_ws$/;
|
||||
|
||||
function expandHome(path: string): string {
|
||||
if (path === "~") return homedir();
|
||||
if (path.startsWith("~/")) return join(homedir(), path.slice(2));
|
||||
return path;
|
||||
}
|
||||
|
||||
function getWorkspaceRoot(config: AgentConfig): string {
|
||||
return resolve(expandHome(config.workspace.root));
|
||||
}
|
||||
|
||||
function normalizeRequestPath(path: string): string {
|
||||
const normalized = normalize(path || ".").replace(/^(\.\.(\/|\\|$))+/, "");
|
||||
return normalized === "." ? "" : normalized;
|
||||
}
|
||||
|
||||
function resolveWorkspacePath(config: AgentConfig, requestPath: string): string {
|
||||
const root = getWorkspaceRoot(config);
|
||||
const normalizedPath = normalizeRequestPath(requestPath);
|
||||
const absolutePath = isAbsolute(normalizedPath) ? normalize(normalizedPath) : resolve(root, normalizedPath);
|
||||
const relativePath = relative(root, absolutePath);
|
||||
|
||||
if (relativePath.startsWith("..") || isAbsolute(relativePath)) {
|
||||
throw new Error("Workspace path is outside of root.");
|
||||
}
|
||||
|
||||
return absolutePath;
|
||||
}
|
||||
|
||||
function toRelativePath(config: AgentConfig, absolutePath: string): string {
|
||||
return relative(getWorkspaceRoot(config), absolutePath).split(sep).join("/");
|
||||
}
|
||||
|
||||
function isEditableFile(config: AgentConfig, filePath: string): boolean {
|
||||
const extension = extname(filePath).toLowerCase();
|
||||
return extension.length === 0 || config.workspace.editableExtensions.includes(extension);
|
||||
}
|
||||
|
||||
function imageMimeType(filePath: string): string | null {
|
||||
const extension = extname(filePath).toLowerCase();
|
||||
if (extension === ".png") return "image/png";
|
||||
if (extension === ".jpg" || extension === ".jpeg") return "image/jpeg";
|
||||
if (extension === ".gif") return "image/gif";
|
||||
if (extension === ".webp") return "image/webp";
|
||||
if (extension === ".svg") return "image/svg+xml";
|
||||
if (extension === ".bmp") return "image/bmp";
|
||||
if (extension === ".ico") return "image/x-icon";
|
||||
return null;
|
||||
}
|
||||
|
||||
function errorJson(message: string, status = 400): Response {
|
||||
return Response.json({ ok: false, message }, { status });
|
||||
}
|
||||
|
||||
async function persistWorkspaceRoot(config: AgentConfig, root: string): Promise<void> {
|
||||
const configPath = resolve(process.env.AGENT_CONFIG ?? "agent.config.json");
|
||||
|
||||
try {
|
||||
const raw = await readFile(configPath, "utf8").catch(() => "{}");
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
const workspace = typeof parsed.workspace === "object" && parsed.workspace !== null && !Array.isArray(parsed.workspace)
|
||||
? parsed.workspace as Record<string, unknown>
|
||||
: {};
|
||||
|
||||
parsed.workspace = {
|
||||
...workspace,
|
||||
root,
|
||||
};
|
||||
|
||||
await writeFile(configPath, `${JSON.stringify(parsed, null, 2)}\n`, "utf8");
|
||||
} catch (error) {
|
||||
console.warn("Failed to persist workspace root:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function setWorkspaceRoot(config: AgentConfig, body: { path?: string; root?: string }) {
|
||||
const nextRoot = String(body.path ?? body.root ?? "").trim();
|
||||
if (!nextRoot) return errorJson("path is required.");
|
||||
|
||||
config.workspace.root = nextRoot;
|
||||
await mkdir(getWorkspaceRoot(config), { recursive: true });
|
||||
await persistWorkspaceRoot(config, nextRoot);
|
||||
|
||||
return Response.json({
|
||||
ok: true,
|
||||
root: config.workspace.root,
|
||||
resolvedRoot: getWorkspaceRoot(config),
|
||||
});
|
||||
}
|
||||
|
||||
async function toEntry(config: AgentConfig, absolutePath: string): Promise<WorkspaceEntry> {
|
||||
const itemStat = await stat(absolutePath);
|
||||
|
||||
return {
|
||||
name: basename(absolutePath),
|
||||
path: toRelativePath(config, absolutePath),
|
||||
type: itemStat.isDirectory() ? "directory" : "file",
|
||||
size: itemStat.size,
|
||||
modifiedAt: itemStat.mtime.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async function listDirectory(config: AgentConfig, requestPath: string): Promise<WorkspaceEntry[]> {
|
||||
const directoryPath = resolveWorkspacePath(config, requestPath);
|
||||
const directoryStat = await stat(directoryPath);
|
||||
if (!directoryStat.isDirectory()) throw new Error("Path is not a directory.");
|
||||
|
||||
const names = await readdir(directoryPath);
|
||||
const entries = await Promise.all(names.map((name) => toEntry(config, join(directoryPath, name))));
|
||||
|
||||
return entries.sort((a, b) => {
|
||||
if (a.type !== b.type) return a.type === "directory" ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
|
||||
export function createWorkspaceApi(config: AgentConfig): Hono {
|
||||
const app = new Hono();
|
||||
|
||||
app.post("/", async (c) => {
|
||||
try {
|
||||
return setWorkspaceRoot(config, await c.req.json<{ path?: string; root?: string }>());
|
||||
} catch (error) {
|
||||
return errorJson(error instanceof Error ? error.message : "Failed to set workspace root.", 500);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/root", async (c) => {
|
||||
try {
|
||||
return setWorkspaceRoot(config, await c.req.json<{ path?: string; root?: string }>());
|
||||
} catch (error) {
|
||||
return errorJson(error instanceof Error ? error.message : "Failed to set workspace root.", 500);
|
||||
}
|
||||
});
|
||||
|
||||
app.put("/root", async (c) => {
|
||||
try {
|
||||
return setWorkspaceRoot(config, await c.req.json<{ path?: string; root?: string }>());
|
||||
} catch (error) {
|
||||
return errorJson(error instanceof Error ? error.message : "Failed to set workspace root.", 500);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/summary", (c) => {
|
||||
c.header("Content-Type", "application/json; charset=utf-8");
|
||||
return c.json({
|
||||
ok: true,
|
||||
root: config.workspace.root,
|
||||
maxFileSize: config.workspace.maxFileSize,
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/modules", async (c) => {
|
||||
try {
|
||||
const rootEntries = await listDirectory(config, "");
|
||||
const modules = rootEntries
|
||||
.filter((entry) => entry.type === "directory")
|
||||
.map((entry) => {
|
||||
const match = moduleNamePattern.exec(entry.name);
|
||||
if (!match) return null;
|
||||
return {
|
||||
name: match[1],
|
||||
directory: entry.name,
|
||||
path: entry.path,
|
||||
modifiedAt: entry.modifiedAt,
|
||||
};
|
||||
})
|
||||
.filter((entry) => entry !== null);
|
||||
|
||||
return c.json({ ok: true, modules });
|
||||
} catch (error) {
|
||||
return errorJson(error instanceof Error ? error.message : "Failed to list modules.", 500);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/tree", async (c) => {
|
||||
try {
|
||||
const path = c.req.query("path") ?? "";
|
||||
return c.json({ ok: true, entries: await listDirectory(config, path) });
|
||||
} catch (error) {
|
||||
return errorJson(error instanceof Error ? error.message : "Failed to list workspace.", 500);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/file", async (c) => {
|
||||
try {
|
||||
const path = c.req.query("path") ?? "";
|
||||
const absolutePath = resolveWorkspacePath(config, path);
|
||||
const fileStat = await stat(absolutePath);
|
||||
|
||||
if (!fileStat.isFile()) return errorJson("Path is not a file.");
|
||||
if (fileStat.size > config.workspace.maxFileSize) return errorJson("File is too large.", 413);
|
||||
if (!isEditableFile(config, absolutePath)) return errorJson("File type is not editable.", 415);
|
||||
|
||||
return c.json({
|
||||
ok: true,
|
||||
path: toRelativePath(config, absolutePath),
|
||||
content: await readFile(absolutePath, "utf8"),
|
||||
size: fileStat.size,
|
||||
modifiedAt: fileStat.mtime.toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
return errorJson(error instanceof Error ? error.message : "Failed to read file.", 500);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/image", async (c) => {
|
||||
try {
|
||||
const path = c.req.query("path") ?? "";
|
||||
const absolutePath = resolveWorkspacePath(config, path);
|
||||
const fileStat = await stat(absolutePath);
|
||||
const mime = imageMimeType(absolutePath);
|
||||
|
||||
if (!fileStat.isFile()) return errorJson("Path is not a file.");
|
||||
if (!mime) return errorJson("File type is not a supported image.", 415);
|
||||
if (fileStat.size > config.workspace.maxFileSize) return errorJson("File is too large.", 413);
|
||||
|
||||
const data = await readFile(absolutePath);
|
||||
return c.json({
|
||||
ok: true,
|
||||
name: basename(absolutePath),
|
||||
path: toRelativePath(config, absolutePath),
|
||||
mime,
|
||||
size: fileStat.size,
|
||||
dataUrl: `data:${mime};base64,${Buffer.from(data).toString("base64")}`,
|
||||
});
|
||||
} catch (error) {
|
||||
return errorJson(error instanceof Error ? error.message : "Failed to read image.", 500);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/file", async (c) => {
|
||||
try {
|
||||
const body = await c.req.json<{ path?: string; content?: string }>();
|
||||
const path = body.path ?? "";
|
||||
const content = body.content ?? "";
|
||||
const byteLength = Buffer.byteLength(content, "utf8");
|
||||
const absolutePath = resolveWorkspacePath(config, path);
|
||||
|
||||
if (!path) return errorJson("path is required.");
|
||||
if (byteLength > config.workspace.maxFileSize) return errorJson("File is too large.", 413);
|
||||
if (!isEditableFile(config, absolutePath)) return errorJson("File type is not editable.", 415);
|
||||
|
||||
await mkdir(resolve(absolutePath, ".."), { recursive: true });
|
||||
await writeFile(absolutePath, content, "utf8");
|
||||
return c.json({ ok: true, entry: await toEntry(config, absolutePath) });
|
||||
} catch (error) {
|
||||
return errorJson(error instanceof Error ? error.message : "Failed to save file.", 500);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/folder", async (c) => {
|
||||
try {
|
||||
const body = await c.req.json<{ path?: string }>();
|
||||
const path = body.path ?? "";
|
||||
if (!path) return errorJson("path is required.");
|
||||
|
||||
const absolutePath = resolveWorkspacePath(config, path);
|
||||
await mkdir(absolutePath, { recursive: true });
|
||||
return c.json({ ok: true, entry: await toEntry(config, absolutePath) });
|
||||
} catch (error) {
|
||||
return errorJson(error instanceof Error ? error.message : "Failed to create folder.", 500);
|
||||
}
|
||||
});
|
||||
|
||||
app.delete("/file", async (c) => {
|
||||
try {
|
||||
const path = c.req.query("path") ?? "";
|
||||
if (!path) return errorJson("path is required.");
|
||||
|
||||
const absolutePath = resolveWorkspacePath(config, path);
|
||||
await rm(absolutePath, { recursive: true, force: false });
|
||||
return c.json({ ok: true });
|
||||
} catch (error) {
|
||||
return errorJson(error instanceof Error ? error.message : "Failed to delete file.", 500);
|
||||
}
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "hono/jsx",
|
||||
"types": ["bun-types"],
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"]
|
||||
}
|
||||
Reference in New Issue
Block a user