diff --git a/frontend/src/pages/VideoPage.vue b/frontend/src/pages/VideoPage.vue
index 1c1fd61..8153774 100644
--- a/frontend/src/pages/VideoPage.vue
+++ b/frontend/src/pages/VideoPage.vue
@@ -24,6 +24,25 @@
+
+
+
+
+
+
+
@@ -57,6 +76,8 @@
// endpoint (nginx proxies /video/ to go2rtc): POST our SDP offer, get an SDP answer.
// Media then flows directly from the device — playback is ~sub-second and there is no
// buffer to drift, so no live-edge tracking is needed.
+import CameraService from '../services/CameraService';
+
const WHEP_URL = '/video/api/webrtc?src=camera1';
// go2rtc opens the camera only while a client is connected, so the stream can take ~1s
// to appear after the page opens (camera + encoder spin up). Retry until it's up.
@@ -64,6 +85,12 @@ const RETRY_MS = 3000;
// Cap ICE gathering so a slow candidate can't hang the offer; LAN host candidates
// resolve well within this.
const ICE_GATHER_MS = 1500;
+// Poll the camera list so a hotplugged camera (or the active one being unplugged)
+// shows up in the picker without a page reload.
+const CAMERA_POLL_MS = 5000;
+// After switching cameras the rtsp-server restarts and go2rtc has to re-open the new
+// source; give it a moment before re-negotiating so the first attempt doesn't 404.
+const SWITCH_RECONNECT_MS = 2000;
export default {
name: 'VideoPage',
@@ -72,9 +99,19 @@ export default {
status: 'connecting', // connecting | playing | offline | error
errorText: '',
pc: null,
- retryTimer: null
+ retryTimer: null,
+ cameras: [], // [{ path, index, name, type, selected }]
+ configured: '', // persisted [camera].device ("" = auto-select)
+ switching: false, // a select() round-trip + restart is in flight
+ cameraPollTimer: null
};
},
+ computed: {
+ // Label for the auto option: the lowest-numbered camera the server would pick.
+ autoLabel() {
+ return this.cameras.length ? this.cameraLabel(this.cameras[0]) : 'lowest /dev/video';
+ }
+ },
mounted() {
// Only stream while the page is actually visible: hiding the tab tears the peer
// connection down, which drops go2rtc's last consumer and releases the camera, so it
@@ -84,13 +121,61 @@ export default {
if (!document.hidden) {
this.start();
}
+ this.fetchCameras();
+ this.cameraPollTimer = setInterval(this.fetchCameras, CAMERA_POLL_MS);
},
beforeUnmount() {
document.removeEventListener('visibilitychange', this.onVisibility);
window.removeEventListener('pagehide', this.teardown);
+ if (this.cameraPollTimer) clearInterval(this.cameraPollTimer);
this.teardown();
},
methods: {
+ async fetchCameras() {
+ try {
+ const { data } = await CameraService.getCameras();
+ this.cameras = data.cameras || [];
+ // Don't clobber the dropdown mid-switch; the optimistic value set in
+ // onSelectCamera is authoritative until the restart settles.
+ if (!this.switching) {
+ this.configured = data.configured || '';
+ }
+ } catch (err) {
+ // Camera-manager down or no cameras — leave the picker hidden, keep streaming.
+ this.cameras = [];
+ }
+ },
+
+ cameraLabel(cam) {
+ const kind = cam.type === 'csi' ? 'CSI' : cam.type === 'usb' ? 'USB' : 'Camera';
+ return `${kind} — ${cam.path}`;
+ },
+
+ async onSelectCamera(device) {
+ if (device === this.configured) return;
+ this.switching = true;
+ const previous = this.configured;
+ this.configured = device; // optimistic; reconciled by the next poll
+ try {
+ const { data } = await CameraService.selectCamera(device);
+ if (data.status !== 'success') {
+ this.configured = previous;
+ alert(`Could not switch camera: ${data.message || 'unknown error'}`);
+ return;
+ }
+ // rtsp-server is restarting on the new device — reconnect the stream shortly.
+ this.teardown();
+ this.status = 'connecting';
+ this.retryTimer = setTimeout(this.start, SWITCH_RECONNECT_MS);
+ } catch (err) {
+ this.configured = previous;
+ alert('Could not switch camera: request failed');
+ } finally {
+ this.switching = false;
+ this.fetchCameras();
+ }
+ },
+
async start() {
this.teardown();
this.status = 'connecting';
@@ -299,6 +384,43 @@ export default {
visibility: hidden;
}
+/* --- camera picker --- */
+.camera-picker {
+ position: absolute;
+ top: 14px;
+ right: 14px;
+ z-index: 2;
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 10px;
+ border-radius: 5px;
+ background-color: rgba(0, 0, 0, 0.55);
+ color: var(--ark-color-white);
+ font-size: 0.85rem;
+}
+
+.camera-picker select {
+ background-color: rgba(0, 0, 0, 0.35);
+ color: var(--ark-color-white);
+ border: 1px solid rgba(255, 255, 255, 0.3);
+ border-radius: 4px;
+ padding: 4px 6px;
+ font-size: 0.85rem;
+ cursor: pointer;
+}
+
+.camera-picker select:disabled {
+ opacity: 0.6;
+ cursor: default;
+}
+
+/* The native dropdown list renders with the UA's default colors; force dark text so
+ options stay readable against the white menu background. */
+.camera-picker option {
+ color: var(--ark-color-black);
+}
+
/* --- live chrome --- */
.live-chrome {
position: absolute;
diff --git a/frontend/src/services/CameraService.js b/frontend/src/services/CameraService.js
new file mode 100644
index 0000000..d8d49ed
--- /dev/null
+++ b/frontend/src/services/CameraService.js
@@ -0,0 +1,17 @@
+import axios from 'axios';
+
+const ENDPOINTS = {
+ cameras: `/api/camera/cameras`,
+ select: `/api/camera/select`,
+};
+
+export default {
+ // { cameras: [{ path, index, name, type, selected }], configured }
+ async getCameras() {
+ return axios.get(ENDPOINTS.cameras);
+ },
+ // device: "/dev/videoN", or "" to revert to auto-select. Restarts the rtsp-server.
+ async selectCamera(device) {
+ return axios.post(ENDPOINTS.select, { device });
+ },
+};
diff --git a/mypy.ini b/mypy.ini
index e4104d8..07829e2 100644
--- a/mypy.ini
+++ b/mypy.ini
@@ -19,4 +19,5 @@ files =
services/system-manager/system_manager.py,
services/service-manager/service_manager.py,
services/autopilot-manager/autopilot_manager.py,
- services/connection-manager/connection_manager.py
+ services/connection-manager/connection_manager.py,
+ services/camera-manager/camera_manager.py
diff --git a/packaging/DEBIAN/postinst b/packaging/DEBIAN/postinst
index 02e8a20..0688be8 100755
--- a/packaging/DEBIAN/postinst
+++ b/packaging/DEBIAN/postinst
@@ -212,7 +212,7 @@ ln -sf /etc/ark-os/flight-review.ini /usr/lib/ark-os/flight-review/app/config_us
# but deliberately NOT enabled — the operator turns them on via the web UI.
# systemctl enable is a static symlink operation that works offline in a chroot.
# ===========================================================================
-ENABLE_SERVICES="mavlink-router rtsp-server go2rtc ark-ui-backend autopilot-manager connection-manager service-manager system-manager"
+ENABLE_SERVICES="mavlink-router rtsp-server go2rtc ark-ui-backend autopilot-manager camera-manager connection-manager service-manager system-manager"
if [ "$PLATFORM" = "jetson" ]; then
ENABLE_SERVICES="$ENABLE_SERVICES jetson-can"
fi
diff --git a/packaging/assemble_tree.sh b/packaging/assemble_tree.sh
index 7666ecb..3531194 100755
--- a/packaging/assemble_tree.sh
+++ b/packaging/assemble_tree.sh
@@ -81,7 +81,7 @@ sed -e "s/@ARK_USER@/$ARK_USER/g" -e "s/@PLATFORM@/$PLATFORM/g" \
chmod 0755 "$PKG$ARK/libexec/ark_os_firstboot.sh"
# --- python services ---
-for svc in autopilot_manager connection_manager service_manager system_manager; do
+for svc in autopilot_manager camera_manager connection_manager service_manager system_manager; do
dir=$(echo "$svc" | tr '_' '-')
install -m 0644 "services/$dir/$svc.py" "$PKG$ARK/python/$svc.py"
done
diff --git a/packaging/config/rtsp-server.toml b/packaging/config/rtsp-server.toml
index 87c2b2b..ba661aa 100644
--- a/packaging/config/rtsp-server.toml
+++ b/packaging/config/rtsp-server.toml
@@ -10,3 +10,7 @@ resolution = "1920x1080"
resolution_options = ["320x240", "640x480", "1280x720", "1920x1080"]
rotation = "0"
rotation_options = ["0", "90", "180", "270"]
+# Capture device to stream. Empty = auto-select the lowest-numbered connected camera
+# (CSI via nvarguscamerasrc, USB via v4l2src). The Video page writes this when you pick
+# a camera; leave empty for auto.
+device = ""
diff --git a/packaging/service-files/jetson/camera-manager.service b/packaging/service-files/jetson/camera-manager.service
new file mode 100644
index 0000000..42ba7cf
--- /dev/null
+++ b/packaging/service-files/jetson/camera-manager.service
@@ -0,0 +1,17 @@
+[Unit]
+Description=Microservice backend for selecting the RTSP server camera
+Wants=network.target network-online.target
+After=network-online.target
+
+[Service]
+Type=simple
+User=jetson
+Group=jetson
+ExecStart=/usr/lib/ark-os/venv/bin/python3 /usr/lib/ark-os/python/camera_manager.py
+Restart=on-failure
+RestartSec=5
+Environment=PYTHONUNBUFFERED=1
+Environment=PORT=3005
+
+[Install]
+WantedBy=multi-user.target ark-os.target
diff --git a/packaging/service-files/pi/camera-manager.service b/packaging/service-files/pi/camera-manager.service
new file mode 100644
index 0000000..ef01343
--- /dev/null
+++ b/packaging/service-files/pi/camera-manager.service
@@ -0,0 +1,17 @@
+[Unit]
+Description=Microservice backend for selecting the RTSP server camera
+Wants=network.target network-online.target
+After=network-online.target
+
+[Service]
+Type=simple
+User=pi
+Group=pi
+ExecStart=/usr/lib/ark-os/venv/bin/python3 /usr/lib/ark-os/python/camera_manager.py
+Restart=on-failure
+RestartSec=5
+Environment=PYTHONUNBUFFERED=1
+Environment=PORT=3005
+
+[Install]
+WantedBy=multi-user.target ark-os.target
diff --git a/services/ark-ui-backend/index.js b/services/ark-ui-backend/index.js
index 66edbad..85f1e53 100644
--- a/services/ark-ui-backend/index.js
+++ b/services/ark-ui-backend/index.js
@@ -23,12 +23,14 @@ const NETWORK_SERVICE_URL = process.env.NETWORK_SERVICE_URL || 'http://localhost
const SERVICE_MANAGER_URL = process.env.SERVICE_MANAGER_URL || 'http://localhost:3002';
const AUTOPILOT_SERVICE_URL = process.env.AUTOPILOT_SERVICE_URL || 'http://localhost:3003';
const SYSTEM_SERVICE_URL = process.env.SYSTEM_SERVICE_URL || 'http://localhost:3004';
+const CAMERA_SERVICE_URL = process.env.CAMERA_SERVICE_URL || 'http://localhost:3005';
console.log('Service URLs:');
console.log(`- NETWORK_SERVICE_URL: ${NETWORK_SERVICE_URL}`);
console.log(`- SERVICE_MANAGER_URL: ${SERVICE_MANAGER_URL}`);
console.log(`- AUTOPILOT_SERVICE_URL: ${AUTOPILOT_SERVICE_URL}`);
console.log(`- SYSTEM_SERVICE_URL: ${SYSTEM_SERVICE_URL}`);
+console.log(`- CAMERA_SERVICE_URL: ${CAMERA_SERVICE_URL}`);
// Service proxy
app.use('/api/service', createProxyMiddleware({
@@ -78,6 +80,18 @@ app.use('/api/system', createProxyMiddleware({
}
}));
+// Camera proxy
+app.use('/api/camera', createProxyMiddleware({
+ target: CAMERA_SERVICE_URL,
+ changeOrigin: true,
+ logLevel: 'warn',
+ onError: (err, req, res) => {
+ console.error(`Camera service proxy error: ${err.message}`);
+ res.writeHead(502, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'Camera service unavailable' }));
+ }
+}));
+
// NOW add body parsing middleware AFTER all proxies
app.use(express.json());
@@ -89,7 +103,8 @@ app.get('/health', (req, res) => {
network: { url: NETWORK_SERVICE_URL },
service: { url: SERVICE_MANAGER_URL },
autopilot: { url: AUTOPILOT_SERVICE_URL },
- system: { url: SYSTEM_SERVICE_URL }
+ system: { url: SYSTEM_SERVICE_URL },
+ camera: { url: CAMERA_SERVICE_URL }
}
});
});
diff --git a/services/camera-manager/camera-manager.manifest.json b/services/camera-manager/camera-manager.manifest.json
new file mode 100644
index 0000000..b909c80
--- /dev/null
+++ b/services/camera-manager/camera-manager.manifest.json
@@ -0,0 +1,6 @@
+{
+ "displayName": "Camera Manager",
+ "description": "Microservice backend for selecting the RTSP server camera",
+ "configFile": "",
+ "visible": false
+}
diff --git a/services/camera-manager/camera_manager.py b/services/camera-manager/camera_manager.py
new file mode 100644
index 0000000..67eacd3
--- /dev/null
+++ b/services/camera-manager/camera_manager.py
@@ -0,0 +1,269 @@
+#!/usr/bin/env python3
+"""ARK-OS Camera Manager — FastAPI service for the RTSP server's camera selection.
+
+The Video page in the web UI talks to this service to discover which cameras are
+connected and to choose which one the RTSP server streams. The rtsp-server.toml file
+is only the persistence layer (the value the rtsp-server reads on startup); this API is
+the data channel, mirroring the other *-manager services.
+
+The HTTP contract is the pydantic models below (CameraList, ActionResponse, ...). Every
+handler CONSTRUCTS its response model, so the type checker (`mypy`, run from the CLI or
+CI) rejects any drift between the producer and the contract before the service ever runs
+on a device. FastAPI generates the OpenAPI spec from the same models (served at
+/openapi.json, Swagger UI at /docs).
+
+Capabilities:
+- Enumerating connected V4L2 capture devices (CSI / USB), classified the same way the
+ rtsp-server classifies them, so the UI list matches what will actually stream.
+- Reading and writing the selected camera in rtsp-server.toml, then restarting the
+ rtsp-server unit so the new device takes effect.
+"""
+
+import os
+import ctypes
+import fcntl
+import logging
+import subprocess
+
+from fastapi import FastAPI
+from pydantic import BaseModel
+import toml # type: ignore[import-untyped] # runtime dep (build_venv.sh); no stubs shipped
+import uvicorn
+
+
+def setup_logging() -> logging.Logger:
+ """Setup simple logging that will be captured by journald via stdout"""
+ logging.basicConfig(
+ level=logging.INFO,
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
+ handlers=[logging.StreamHandler()]
+ )
+ return logging.getLogger('camera-manager')
+
+
+logger = setup_logging()
+
+
+# ── HTTP contract: the single source of truth ────────────────────────────────
+
+class Camera(BaseModel):
+ path: str # "/dev/video0"
+ index: int # 0 for /dev/video0 — also the auto-select ordering key
+ name: str # V4L2 card name, e.g. "vi-output, imx219 9-0010" or "HD Webcam"
+ type: str # "csi" | "usb" | "other"
+ selected: bool # the device the rtsp-server will actually stream right now
+
+
+class CameraList(BaseModel):
+ cameras: list[Camera]
+ # The configured device from rtsp-server.toml: "" means auto-select (lowest index).
+ configured: str
+
+
+class SelectRequest(BaseModel):
+ # Device path to stream (e.g. "/dev/video1"), or "" to revert to auto-select.
+ device: str
+
+
+class ActionResponse(BaseModel):
+ status: str # "success" | "fail"
+ message: str | None = None
+ configured: str | None = None
+
+
+app = FastAPI(title="ARK-OS Camera Manager", version="1.0.0")
+
+# rtsp-server.toml lives flat under the shared ARK-OS config dir, like every other
+# service config the web UI edits. Overridable for off-device testing.
+RTSP_CONFIG = os.environ.get("RTSP_CONFIG", "/etc/ark-os/rtsp-server.toml")
+RTSP_SERVICE = "rtsp-server.service"
+
+
+# ── V4L2 capability probe (VIDIOC_QUERYCAP) ──────────────────────────────────
+# Identify and classify /dev/video* nodes via ioctl rather than shelling out to
+# v4l2-ctl, so the manager has no external tool dependency and matches the
+# rtsp-server's own classification byte-for-byte.
+
+class _v4l2_capability(ctypes.Structure):
+ _fields_ = [
+ ("driver", ctypes.c_char * 16),
+ ("card", ctypes.c_char * 32),
+ ("bus_info", ctypes.c_char * 32),
+ ("version", ctypes.c_uint32),
+ ("capabilities", ctypes.c_uint32),
+ ("device_caps", ctypes.c_uint32),
+ ("reserved", ctypes.c_uint32 * 3),
+ ]
+
+
+# _IOR('V', 0, struct v4l2_capability); sizeof == 104. Same value on arm64/x86_64.
+_VIDIOC_QUERYCAP = 0x80685600
+_V4L2_CAP_VIDEO_CAPTURE = 0x00000001
+_V4L2_CAP_DEVICE_CAPS = 0x80000000
+
+
+def _classify(driver: str) -> str:
+ """Map a V4L2 driver name to a camera type (kept in sync with CameraProbe.cpp)."""
+ if "tegra" in driver or driver == "vi-output":
+ return "csi"
+ if driver == "uvcvideo":
+ return "usb"
+ return "other"
+
+
+def enumerate_cameras() -> list[Camera]:
+ """Return every /dev/video* node that can capture video, sorted by index.
+
+ UVC webcams expose extra metadata-only nodes; VIDIOC_QUERYCAP's device_caps reports
+ Metadata Capture (not Video Capture) for those, so they are filtered out and never
+ offered as a selectable camera.
+ """
+ cameras: list[Camera] = []
+
+ try:
+ entries = os.listdir("/dev")
+ except OSError as e:
+ logger.error(f"Cannot list /dev: {e}")
+ return cameras
+
+ for name in entries:
+ if not name.startswith("video"):
+ continue
+ digits = name[len("video"):]
+ if not digits.isdigit():
+ continue
+
+ path = f"/dev/{name}"
+ cap = _v4l2_capability()
+ fd = -1
+ try:
+ # O_NONBLOCK so a busy CSI sensor still answers QUERYCAP (it needs no
+ # streaming access); a node we can't open (permissions) is simply skipped.
+ fd = os.open(path, os.O_RDWR | os.O_NONBLOCK)
+ fcntl.ioctl(fd, _VIDIOC_QUERYCAP, cap)
+ except OSError:
+ continue
+ finally:
+ if fd >= 0:
+ os.close(fd)
+
+ caps = cap.device_caps if (cap.capabilities & _V4L2_CAP_DEVICE_CAPS) else cap.capabilities
+ if not (caps & _V4L2_CAP_VIDEO_CAPTURE):
+ continue
+
+ driver = cap.driver.decode("utf-8", "replace")
+ cameras.append(Camera(
+ path=path,
+ index=int(digits),
+ name=cap.card.decode("utf-8", "replace"),
+ type=_classify(driver),
+ selected=False,
+ ))
+
+ cameras.sort(key=lambda c: c.index)
+ return cameras
+
+
+# ── config read / write ──────────────────────────────────────────────────────
+
+def read_configured_device() -> str:
+ """The [camera].device value from rtsp-server.toml, or "" if unset/unreadable."""
+ try:
+ with open(RTSP_CONFIG, "r") as f:
+ data = toml.load(f)
+ except (OSError, toml.TomlDecodeError) as e:
+ logger.warning(f"Could not read {RTSP_CONFIG}: {e}")
+ return ""
+ device = data.get("camera", {}).get("device", "")
+ return device if isinstance(device, str) else ""
+
+
+def write_configured_device(device: str) -> None:
+ """Persist [camera].device into rtsp-server.toml, preserving every other field.
+
+ Re-dumps the whole table (comments are not preserved — the same trade-off the web
+ UI's TOML editor already makes), so options arrays and other settings survive.
+ """
+ with open(RTSP_CONFIG, "r") as f:
+ data = toml.load(f)
+ data.setdefault("camera", {})["device"] = device
+ with open(RTSP_CONFIG, "w") as f:
+ toml.dump(data, f)
+
+
+def mark_selected(cameras: list[Camera], configured: str) -> None:
+ """Flag the camera the rtsp-server will actually stream, mirroring its selection:
+ the configured device if it's connected, otherwise the lowest-numbered camera."""
+ if not cameras:
+ return
+ chosen = next((c for c in cameras if c.path == configured), None) if configured else None
+ if chosen is None:
+ chosen = cameras[0] # auto-select: lowest index (already sorted)
+ chosen.selected = True
+
+
+class CameraManager:
+
+ @staticmethod
+ def list_cameras() -> CameraList:
+ configured = read_configured_device()
+ cameras = enumerate_cameras()
+ mark_selected(cameras, configured)
+ return CameraList(cameras=cameras, configured=configured)
+
+ @staticmethod
+ def select_camera(device: str) -> ActionResponse:
+ # "" is allowed (revert to auto); a non-empty value must name a connected,
+ # capture-capable camera so the UI can't point the stream at a dead node.
+ if device:
+ if not any(c.path == device for c in enumerate_cameras()):
+ return ActionResponse(
+ status="fail",
+ message=f"{device} is not a connected camera",
+ )
+
+ try:
+ write_configured_device(device)
+ except (OSError, toml.TomlDecodeError) as e:
+ return ActionResponse(status="fail", message=f"Could not write config: {e}")
+
+ # Restart so the rtsp-server rebuilds its pipeline for the new device. Relies on
+ # the same polkit grant the service-manager uses; the unit name is hard-coded, so
+ # there is no injection surface here.
+ try:
+ proc = subprocess.run(
+ ["systemctl", "restart", RTSP_SERVICE],
+ capture_output=True, text=True, timeout=15,
+ )
+ except Exception as e:
+ return ActionResponse(status="fail", message=f"Saved, but restart failed: {e}",
+ configured=device)
+
+ if proc.returncode != 0:
+ msg = (proc.stderr or proc.stdout).strip() or f"exit code {proc.returncode}"
+ return ActionResponse(status="fail", message=f"Saved, but restart failed: {msg}",
+ configured=device)
+
+ return ActionResponse(status="success", configured=device)
+
+
+# ── API endpoints ─────────────────────────────────────────────────────────────
+
+@app.get("/cameras")
+def get_cameras() -> CameraList:
+ logger.debug("GET /cameras called")
+ return CameraManager.list_cameras()
+
+
+@app.post("/select", response_model_exclude_none=True)
+def select_camera(body: SelectRequest) -> ActionResponse:
+ logger.info(f"POST /select called for '{body.device or 'auto'}'")
+ return CameraManager.select_camera(body.device)
+
+
+if __name__ == '__main__':
+ host = '127.0.0.1'
+ port = int(os.environ.get("PORT", 3005))
+
+ logger.info(f"Starting Camera Manager on {host}:{port}")
+ uvicorn.run(app, host=host, port=port, access_log=False)
diff --git a/services/rtsp-server/rtsp-server b/services/rtsp-server/rtsp-server
index d4d67fb..2ddb780 160000
--- a/services/rtsp-server/rtsp-server
+++ b/services/rtsp-server/rtsp-server
@@ -1 +1 @@
-Subproject commit d4d67fbcc40dd63ce8826c69a498769081a5b8ad
+Subproject commit 2ddb780b470136a5977fab95d122abca37baa5a7