Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 123 additions & 1 deletion frontend/src/pages/VideoPage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,25 @@
</button>
</div>

<!-- Camera picker: only shown when more than one camera is connected. Sits in
the top-right and stays reachable whatever the stream status is, so you can
switch source while it's still connecting. -->
<div v-if="cameras.length > 1" class="camera-picker">
<i class="fas fa-video"></i>
<select
:value="configured"
:disabled="switching"
title="Choose which camera the RTSP server streams"
@change="onSelectCamera($event.target.value)"
>
<option value="">Auto ({{ autoLabel }})</option>
<option v-for="cam in cameras" :key="cam.path" :value="cam.path" :title="cam.name">
{{ cameraLabel(cam) }}
</option>
</select>
<i v-if="switching" class="fas fa-spinner fa-spin"></i>
</div>

<div v-if="status !== 'playing'" class="overlay">
<template v-if="status === 'offline'">
<i class="fas fa-video-slash overlay-icon"></i>
Expand Down Expand Up @@ -57,13 +76,21 @@
// 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.
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',
Expand All @@ -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
Expand All @@ -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';
Expand Down Expand Up @@ -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;
Expand Down
17 changes: 17 additions & 0 deletions frontend/src/services/CameraService.js
Original file line number Diff line number Diff line change
@@ -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 });
},
};
3 changes: 2 additions & 1 deletion mypy.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion packaging/DEBIAN/postinst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packaging/assemble_tree.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions packaging/config/rtsp-server.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""
17 changes: 17 additions & 0 deletions packaging/service-files/jetson/camera-manager.service
Original file line number Diff line number Diff line change
@@ -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
17 changes: 17 additions & 0 deletions packaging/service-files/pi/camera-manager.service
Original file line number Diff line number Diff line change
@@ -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
17 changes: 16 additions & 1 deletion services/ark-ui-backend/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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());

Expand All @@ -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 }
}
});
});
Expand Down
6 changes: 6 additions & 0 deletions services/camera-manager/camera-manager.manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"displayName": "Camera Manager",
"description": "Microservice backend for selecting the RTSP server camera",
"configFile": "",
"visible": false
}
Loading
Loading