diff --git a/README.md b/README.md index 6c45629dd2..f0c0cd5692 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,7 @@ Below is a table summarizing the current status, but in general, you can expect |-------------|---------------|----------------| | **Joystick Support** | Only when tab and window are in focus | ✅ Window can be unfocused and in the background | | **Video** | Needs to be downloaded and merged into a working video using the Desktop app | ✅ Final MP4 file saved directly to your folders | +| **Direct Video Sources** | ❌ Not available (browsers can't open RTSP or raw UDP video) | ✅ Add RTSP cameras, and UDP H.264/H.265 streams as sent by ArduPilot/PX4 companion computers | | **Snapshots** | Needs to be downloaded | ✅ Saved directly to your folders | | **Vehicle Discovery** | ❌ Not available | ✅ Auto-scan for vehicles in the network| | **External Serial GNSS** | ❌ Not available (browsers can't access serial devices outside a secure context) | ✅ Read one or more USB/serial NMEA GNSS receivers into the data-lake | diff --git a/src/components/VideoPlayerStatsForNerds.vue b/src/components/VideoPlayerStatsForNerds.vue index 6f58d3ec19..bdfdf2913f 100644 --- a/src/components/VideoPlayerStatsForNerds.vue +++ b/src/components/VideoPlayerStatsForNerds.vue @@ -42,9 +42,10 @@ const props = defineProps({ }, }) -const isRtspStream = (): boolean => { +// Streams the go2rtc sidecar ingests report their stats through it, rather than through WebRTC's own stats. +const isGo2rtcStream = (): boolean => { if (!props.streamName) return false - return videoStore.getStreamProtocol(props.streamName) === 'rtsp' + return videoStore.getStreamProtocol(props.streamName) !== 'webrtc' } const canvasRef = ref(null) @@ -123,8 +124,8 @@ function draw(): void { ctx.font = '10px Arial' // Draw stats and plots for both types of streams. - // The stats and plots are different from one to another since RTSP and WebRTC streams provide different types of information. - if (isRtspStream()) { + // The stats and plots are different from one to another since go2rtc-backed and WebRTC streams provide different types of information. + if (isGo2rtcStream()) { drawPlot(rtspBitrateData.value, 'rgb(255, 165, 0)', maxRtspBitrate) drawPlot(rtspPacketRateData.value, 'rgb(100, 200, 255)', maxRtspPacketRate) drawPlot(rtspStallData.value, 'rgb(255, 0, 0)', 1) @@ -211,7 +212,7 @@ watch(videoStore.activeStreams, (streams): void => { }) const fetchRtspInfo = async (): Promise => { - if (!isRtspStream() || !window.electronAPI) return + if (!isGo2rtcStream() || !window.electronAPI) return try { const allInfo = await window.electronAPI.go2rtcGetStreamsInfo() const info = allInfo[props.streamName] @@ -240,7 +241,7 @@ onMounted(() => { intervalId = setInterval(update, props.updateInterval) draw() - if (isRtspStream()) { + if (isGo2rtcStream()) { fetchRtspInfo() rtspInfoInterval = setInterval(fetchRtspInfo, 100) } diff --git a/src/electron/preload.ts b/src/electron/preload.ts index cb150e769a..b21e3b8d56 100644 --- a/src/electron/preload.ts +++ b/src/electron/preload.ts @@ -1,5 +1,6 @@ import { contextBridge, ipcRenderer } from 'electron' +import type { RtpSourceConfig } from '@/libs/rtp-source' import type { ElectronSDLJoystickControllerStateEventData } from '@/types/joystick' import type { FileDialogOptions, FileStats } from '@/types/storage' @@ -63,6 +64,8 @@ contextBridge.exposeInMainWorld('electronAPI', { }, finalizeVideoRecording: (processId: string) => ipcRenderer.invoke('finalize-video-recording', processId), go2rtcAddStream: (name: string, rtspUrl: string) => ipcRenderer.invoke('go2rtc-add-stream', name, rtspUrl), + go2rtcAddRtpStream: (name: string, config: RtpSourceConfig) => + ipcRenderer.invoke('go2rtc-add-rtp-stream', name, config), go2rtcRemoveStream: (name: string) => ipcRenderer.invoke('go2rtc-remove-stream', name), go2rtcGetStreamsInfo: () => ipcRenderer.invoke('go2rtc-get-streams-info'), go2rtcGetPort: () => ipcRenderer.invoke('go2rtc-get-port'), diff --git a/src/electron/services/go2rtc.ts b/src/electron/services/go2rtc.ts index 00fa4e1571..a8b6e6b561 100644 --- a/src/electron/services/go2rtc.ts +++ b/src/electron/services/go2rtc.ts @@ -5,10 +5,17 @@ import { promises as fs } from 'fs' import http from 'http' import { createServer } from 'net' import { tmpdir } from 'os' -import { join } from 'path' +import { delimiter, dirname, join } from 'path' import type { Go2RTCStreamInfo } from '@/types/video' +import { + type RtpSourceConfig, + buildRtpGo2rtcSource, + rtpInputTemplate, + rtpInputTemplateName, +} from '../../libs/rtp-source' +import { getFFmpegPath } from './ffmpeg-path' import { getGo2RTCPath } from './go2rtc-path' let go2rtcProcess: ChildProcess | null = null @@ -90,17 +97,45 @@ const startGo2RTC = async (): Promise => { return go2rtcPort } - const port = await getFreeTcpPort() + // Allocated together so every socket is bound before any is released, which keeps the ports distinct. + const [port, rtspPort, webrtcPort] = await Promise.all([getFreeTcpPort(), getFreeTcpPort(), getFreeTcpPort()]) const configDir = join(tmpdir(), 'cockpit-go2rtc') await fs.mkdir(configDir, { recursive: true }) const configPath = join(configDir, 'go2rtc.yaml') - const config = `api:\n listen: "127.0.0.1:${port}"\n origin: "*"\n` + const config = [ + 'api:', + ` listen: "127.0.0.1:${port}"`, + ' origin: "*"', + // Left on its defaults go2rtc serves RTSP on :8554 and WebRTC on :8555 across every interface, + // colliding with MediaMTX or with a second go2rtc. + 'rtsp:', + ` listen: "127.0.0.1:${rtspPort}"`, + // ponytail: the WebRTC port carries UDP too, but is picked by probing TCP, so a port free on TCP and + // taken on UDP would break ICE. Upgrade path is probing both, once anything reports it in the wild. + 'webrtc:', + ` listen: ":${webrtcPort}"`, + // ffmpeg-backed sources publish into the RTSP server above. + 'ffmpeg:', + ` ${rtpInputTemplateName}: '${rtpInputTemplate}'`, + '', + ].join('\n') await fs.writeFile(configPath, config, 'utf-8') + let ffmpegDir: string | undefined + try { + ffmpegDir = dirname(getFFmpegPath()) + } catch (error) { + console.warn('[go2rtc] Bundled FFmpeg not found, UDP video streams will not work:', error) + } + const binaryPath = getGo2RTCPath() const proc = spawn(binaryPath, ['-config', configPath], { stdio: ['ignore', 'pipe', 'pipe'], + // go2rtc resolves `ffmpeg` through PATH. Naming the bundled binary in its `ffmpeg.bin` setting instead + // would break on any install path containing a space, since go2rtc splits the command it builds on + // whitespace while probing the binary without that splitting. + env: ffmpegDir ? { ...process.env, PATH: `${ffmpegDir}${delimiter}${process.env.PATH ?? ''}` } : process.env, }) proc.stdout?.on('data', (data) => { @@ -174,6 +209,16 @@ const addStream = async (name: string, rtspUrl: string): Promise => { console.log(`[go2rtc] Added stream '${name}' -> ${rtspUrl}`) } +/** + * Register a raw RTP over UDP stream with go2rtc, ingested through the bundled FFmpeg + * @param {string} name - Unique stream name + * @param {RtpSourceConfig} config - Address and port to listen on, and the codec to expect there + * @returns {Promise} + */ +const addRtpStream = async (name: string, config: RtpSourceConfig): Promise => { + await addStream(name, buildRtpGo2rtcSource(config)) +} + /** * Remove an RTSP stream from go2rtc * @param {string} name - Stream name to remove @@ -418,6 +463,15 @@ export const setupGo2RTCService = (): void => { } }) + ipcMain.handle('go2rtc-add-rtp-stream', async (_, name: string, config: RtpSourceConfig) => { + try { + await addRtpStream(name, config) + } catch (error) { + console.error('[go2rtc] Error adding RTP stream:', error) + throw error + } + }) + ipcMain.handle('go2rtc-remove-stream', async (_, name: string) => { try { await removeStream(name) diff --git a/src/libs/cosmos.ts b/src/libs/cosmos.ts index 908a69721f..bc2afe4bea 100644 --- a/src/libs/cosmos.ts +++ b/src/libs/cosmos.ts @@ -519,6 +519,12 @@ declare global { * @param rtspUrl - Full RTSP URL including optional credentials */ go2rtcAddStream: (name: string, rtspUrl: string) => Promise + /** + * Register a raw RTP over UDP stream with the go2rtc sidecar for WebRTC consumption + * @param name - Unique stream name used for WebRTC signaling + * @param config - Address and port to listen on, and the codec to expect there + */ + go2rtcAddRtpStream: (name: string, config: import('@/libs/rtp-source').RtpSourceConfig) => Promise /** * Remove an RTSP stream from the go2rtc sidecar * @param name - The stream name to remove diff --git a/src/libs/rtp-source.ts b/src/libs/rtp-source.ts new file mode 100644 index 0000000000..62d3e5c3d9 --- /dev/null +++ b/src/libs/rtp-source.ts @@ -0,0 +1,125 @@ +/** + * Raw RTP over UDP video sources, as emitted by ArduPilot/PX4 companion computers. + * + * RTP carries no description of what it transports, so a receiver has to be told the codec and clock rate + * out of band. We synthesize a minimal SDP for that, the same information QGroundControl hardcodes into its + * GStreamer caps, and hand it to ffmpeg through the go2rtc sidecar. + */ + +import { isValidIpv4Address } from './utils' + +export const rtpCodecs = ['h264', 'h265'] as const + +export type RtpCodec = (typeof rtpCodecs)[number] + +export type RtpSourceConfig = { + /** + * Local IPv4 address to listen on, or 0.0.0.0 for every network interface + */ + host: string + /** + * Local UDP port to listen on + */ + port: number + /** + * Codec the sender packetizes into RTP + */ + codec: RtpCodec +} + +// First dynamic RTP payload type, and the default of both ffmpeg's RTP muxer and GStreamer's rtph264pay, +// so it is what companion computers overwhelmingly emit. +const rtpPayloadType = 96 + +/** + * Name of the ffmpeg input template that go2rtc.yaml must declare for RTP sources to resolve. + * + * go2rtc has no native RTP source, and it refuses sources created through its API that either use the + * `exec:` scheme or contain whitespace. So the ffmpeg arguments cannot travel in the source string and + * live in a named template in the config file instead, leaving the per-stream source to just reference it. + */ +export const rtpInputTemplateName = 'cockpit-rtp' + +/** ffmpeg input arguments for the template named {@link rtpInputTemplateName}. */ +export const rtpInputTemplate = '-fflags nobuffer -flags low_delay -protocol_whitelist data,udp,rtp -f sdp -i {input}' + +/** + * Check whether a config describes a stream Cockpit can actually listen for. + * @param {RtpSourceConfig} config The config to check + * @returns {string | undefined} A user-facing description of the problem, or undefined when the config is valid + */ +export const rtpConfigError = (config: RtpSourceConfig): string | undefined => { + // The SDP describes the listen address as `IN IP4`, so a hostname or IPv6 literal would not be honest there. + if (!isValidIpv4Address(config.host)) { + return 'Address must be an IPv4 address, such as 0.0.0.0 to listen on every network interface.' + } + // Ports under 1024 need administrator privileges to bind, which would fail with no video and no clear reason. + if (!Number.isInteger(config.port) || config.port < 1024 || config.port > 65535) { + return 'Port must be a whole number between 1024 and 65535.' + } + if (!rtpCodecs.includes(config.codec)) { + return `Codec must be one of: ${rtpCodecs.join(', ')}.` + } + return undefined +} + +/** + * Build the canonical URI that identifies an RTP stream throughout Cockpit. + * @param {RtpSourceConfig} config The stream to identify + * @returns {string} A `rtp://host:port?codec=` URI + */ +export const buildRtpUrl = (config: RtpSourceConfig): string => + `rtp://${config.host}:${config.port}?codec=${config.codec}` + +/** + * Recover the config behind a URI built by {@link buildRtpUrl}. + * @param {string} url The URI to parse + * @returns {RtpSourceConfig | undefined} The config, or undefined if the URI is not a valid RTP source URI + */ +export const parseRtpUrl = (url: string): RtpSourceConfig | undefined => { + let parsed: URL + try { + parsed = new URL(url) + } catch { + return undefined + } + if (parsed.protocol !== 'rtp:') return undefined + + const config = { + host: parsed.hostname, + port: Number(parsed.port), + codec: parsed.searchParams.get('codec') as RtpCodec, + } + return rtpConfigError(config) === undefined ? config : undefined +} + +/** + * Build the SDP that tells ffmpeg where to listen and what it will find there. + * @param {RtpSourceConfig} config The stream to describe + * @returns {string} An SDP session description + */ +export const buildRtpSdp = (config: RtpSourceConfig): string => + [ + 'v=0', + `o=- 0 0 IN IP4 ${config.host}`, + 's=Cockpit RTP Stream', + `c=IN IP4 ${config.host}`, + 't=0 0', + `m=video ${config.port} RTP/AVP ${rtpPayloadType}`, + `a=rtpmap:${rtpPayloadType} ${config.codec.toUpperCase()}/90000`, + '', + ].join('\r\n') + +/** + * Build the go2rtc source string that makes the sidecar ingest an RTP stream through ffmpeg. + * + * The SDP travels inline as a `data:` URI rather than as a temp file, because go2rtc rejects sources + * containing whitespace and so could never reference a path with a space in it. + * + * Both codecs are passed through untouched. H.265 reaches the renderer only because `src/electron/main.ts` + * turns on Chromium's HEVC decoding and WebRTC HEVC switches, so removing those breaks H.265 here too. + * @param {RtpSourceConfig} config The stream to ingest + * @returns {string} A go2rtc `ffmpeg:` source string + */ +export const buildRtpGo2rtcSource = (config: RtpSourceConfig): string => + `ffmpeg:data:application/sdp;base64,${btoa(buildRtpSdp(config))}#input=${rtpInputTemplateName}#video=copy` diff --git a/src/libs/video-stream-protocol.ts b/src/libs/video-stream-protocol.ts new file mode 100644 index 0000000000..deaa0780a8 --- /dev/null +++ b/src/libs/video-stream-protocol.ts @@ -0,0 +1,16 @@ +import type { VideoStreamProtocol } from '@/types/video' + +import { parseRtpUrl } from './rtp-source' + +/** + * Infer which protocol an external stream id refers to, from the id alone. + * + * Needed wherever no correspondency entry exists to carry the protocol, such as the ignored-streams list. + * @param {string} externalId - The external stream identifier + * @returns {VideoStreamProtocol} The protocol the id describes + */ +export const protocolFromExternalId = (externalId: string): VideoStreamProtocol => { + if (externalId.startsWith('rtsp://') || externalId.startsWith('rtsps://')) return 'rtsp' + if (parseRtpUrl(externalId) !== undefined) return 'rtp' + return 'webrtc' +} diff --git a/src/stores/video.ts b/src/stores/video.ts index bd18d2d898..3595cac60c 100644 --- a/src/stores/video.ts +++ b/src/stores/video.ts @@ -20,8 +20,10 @@ import { LiveVideoProcessorChunkAppendingError, LiveVideoProcessorInitializationError, } from '@/libs/live-video-processor' +import { type RtpSourceConfig, buildRtpUrl, parseRtpUrl, rtpConfigError } from '@/libs/rtp-source' import { datalogger } from '@/libs/sensors-logging' -import { isElectron, isEqual, sanitizeFilenameComponent, sleep } from '@/libs/utils' +import { isElectron, isEqual, messageFromError, sanitizeFilenameComponent, sleep } from '@/libs/utils' +import { protocolFromExternalId } from '@/libs/video-stream-protocol' import { tempVideoStorage, videoStorage } from '@/libs/videoStorage' import type { Stream } from '@/libs/webrtc/signalling_protocol' import { useMainVehicleStore } from '@/stores/mainVehicle' @@ -106,10 +108,10 @@ export const useVideoStore = defineStore('video', () => { ) const namesAvailableStreams = computed(() => { - const rtspStreams = streamsCorrespondency.value - .filter((stream) => (stream.protocol ?? 'webrtc') === 'rtsp') + const go2rtcStreams = streamsCorrespondency.value + .filter((stream) => (stream.protocol ?? 'webrtc') !== 'webrtc') .map((stream) => stream.externalId) - return [...new Set([...namesAvailableWebRTCStreams.value, ...rtspStreams])] + return [...new Set([...namesAvailableWebRTCStreams.value, ...go2rtcStreams])] }) const namessAvailableAbstractedStreams = computed(() => { @@ -153,9 +155,23 @@ export const useVideoStore = defineStore('video', () => { resolution: string /** FPS string (e.g. "30fps") or empty if unknown */ fps: string - /** Protocol type label ("WebRTC" or "RTSP") */ + /** Protocol type label ("WebRTC", "RTSP" or "UDP") */ protocolLabel: string } => { + if (getStreamProtocol(externalId) === 'rtp') { + const go2rtcInfo = go2rtcStreamInfo.value[externalId] + const config = parseRtpUrl(externalId) + // The configured codec is preferred because go2rtc only reports one once a consumer starts the stream. + const encode = config?.codec.toUpperCase() ?? go2rtcInfo?.codec + + return { + source: `UDP port ${config?.port ?? '?'} (${encode ?? '...'})`, + resolution: go2rtcInfo?.width ? `${go2rtcInfo.width}x${go2rtcInfo.height}` : '...', + fps: go2rtcInfo?.fps ? `${go2rtcInfo.fps}fps` : '', + protocolLabel: 'UDP', + } + } + if (getStreamProtocol(externalId) === 'rtsp') { const mcmInfo = streamInformation.value.find((i) => i.rtspSourceUrl === externalId) const go2rtcInfo = go2rtcStreamInfo.value[externalId] @@ -379,8 +395,8 @@ export const useVideoStore = defineStore('video', () => { }) }, 300) - const rtspActivating = new Set() - let rtspUnsupportedWarned = false + const go2rtcActivating = new Set() + let go2rtcUnsupportedWarned = false /** * Activates a stream by starting it and storing it's variables inside a common object. @@ -389,37 +405,43 @@ export const useVideoStore = defineStore('video', () => { * @param {string} streamName - Unique name for the stream, common between the multiple consumers */ const activateStream = (streamName: string): void => { - if (getStreamProtocol(streamName) === 'rtsp') { - if (rtspActivating.has(streamName)) return + const protocol = getStreamProtocol(streamName) + if (protocol !== 'webrtc') { + if (go2rtcActivating.has(streamName)) return if (activeStreams.value[streamName]?.go2rtcManager) return - const rtspUrl = getRtspUrl(streamName) - if (!rtspUrl) { - showDialog({ message: `RTSP URL for stream '${streamName}' is missing.`, variant: 'error' }) + const rtspUrl = protocol === 'rtsp' ? getRtspUrl(streamName) : undefined + const rtpConfig = protocol === 'rtp' ? parseRtpUrl(streamName) : undefined + if (!rtspUrl && !rtpConfig) { + showDialog({ message: `Video source for stream '${streamName}' is missing or invalid.`, variant: 'error' }) return } if (!window.electronAPI) { // Activation is attempted repeatedly (e.g. via VideoPlayer's 1s polling), so guard the dialog // to a single notification per session to avoid spamming the user during boot. - if (!rtspUnsupportedWarned) { - rtspUnsupportedWarned = true + if (!go2rtcUnsupportedWarned) { + go2rtcUnsupportedWarned = true showDialog({ message: 'It looks like some of your video-related widgets (e.g.: video player, mini video recorder, snapshot tool)' + - ' are connected to RTSP streams, which are not supported in Cockpit Lite. To make sure those widgets work,' + - ' re-configure them to only use WebRTC, or upgrade to Cockpit Standalone, which supports both WebRTC and RTSP streams.', + ' are connected to RTSP or UDP video streams, which are not supported in Cockpit Lite. To make sure those' + + ' widgets work, re-configure them to only use WebRTC, or install Cockpit Standalone, which supports all of them.', variant: 'error', }) } return } - rtspActivating.add(streamName) + go2rtcActivating.add(streamName) void (async () => { try { const port = await window.electronAPI!.go2rtcGetPort() - await window.electronAPI!.go2rtcAddStream(streamName, rtspUrl) + if (rtpConfig) { + await window.electronAPI!.go2rtcAddRtpStream(streamName, rtpConfig) + } else { + await window.electronAPI!.go2rtcAddStream(streamName, rtspUrl!) + } const manager = new Go2RTCManager(port, streamName) const { mediaStream, connected } = manager.start() @@ -434,12 +456,12 @@ export const useVideoStore = defineStore('video', () => { mediaRecorder: undefined, timeRecordingStart: undefined, } - console.debug(`Activated RTSP stream '${streamName}' via go2rtc.`) + console.debug(`Activated ${protocol.toUpperCase()} stream '${streamName}' via go2rtc.`) } catch (error) { - console.error(`Failed to activate RTSP stream '${streamName}':`, error) - showDialog({ message: `Failed to start RTSP stream '${streamName}'.`, variant: 'error' }) + console.error(`Failed to activate ${protocol.toUpperCase()} stream '${streamName}':`, error) + showDialog({ message: `Failed to start video stream '${streamName}'.`, variant: 'error' }) } finally { - rtspActivating.delete(streamName) + go2rtcActivating.delete(streamName) } })() return @@ -1263,9 +1285,17 @@ export const useVideoStore = defineStore('video', () => { userRestoredStreamIds.value = [...userRestoredStreamIds.value, externalId] } - const isRtsp = externalId.startsWith('rtsp://') || externalId.startsWith('rtsps://') - if (isRtsp) { + const protocol = protocolFromExternalId(externalId) + if (protocol === 'rtsp') { initializeRtspStreamsCorrespondency() + } else if (protocol === 'rtp') { + // Nothing rediscovers a UDP stream, but its id fully describes it, so it can be rebuilt from scratch. + try { + addRtpStreamCorrespondency(parseRtpUrl(externalId)!) + } catch (error) { + openSnackbar({ variant: 'error', message: `Could not restore '${externalId}': ${messageFromError(error)}` }) + return + } } else if (namesAvailableStreams.value.includes(externalId)) { initializeStreamsCorrespondency() } else { @@ -1323,6 +1353,48 @@ export const useVideoStore = defineStore('video', () => { return newCorrespondency } + /** + * Add a new raw RTP over UDP stream to the correspondency list (Electron/standalone only) + * @param {RtpSourceConfig} config - Address and port to listen on, and the codec to expect there + * @returns {VideoStreamCorrespondency} The created correspondency entry + */ + const addRtpStreamCorrespondency = (config: RtpSourceConfig): VideoStreamCorrespondency => { + if (!window.electronAPI) { + throw new Error('UDP video streams are only available in Cockpit Standalone.') + } + + const configError = rtpConfigError(config) + if (configError) { + throw new Error(configError) + } + + // Two listeners on one port would fight over the socket, and 0.0.0.0 already covers every other address. + const conflict = streamsCorrespondency.value.find((stream) => { + const other = stream.protocol === 'rtp' ? parseRtpUrl(stream.externalId) : undefined + if (other === undefined) return false + return other.port === config.port && (other.host === config.host || [other.host, config.host].includes('0.0.0.0')) + }) + if (conflict) { + throw new Error(`Port ${config.port} is already used by '${conflict.name}'.`) + } + + const existingInternalNames = streamsCorrespondency.value.map((corr) => corr.name) + let i = 1 + let internalName = `UDP Stream ${i}` + while (existingInternalNames.includes(internalName)) { + i++ + internalName = `UDP Stream ${i}` + } + + const newCorrespondency: VideoStreamCorrespondency = { + name: internalName, + externalId: buildRtpUrl(config), + protocol: 'rtp', + } + streamsCorrespondency.value = [...streamsCorrespondency.value, newCorrespondency] + return newCorrespondency + } + registerActionCallback( availableCockpitActions.start_recording_all_streams, useThrottleFn(startRecordingAllStreams, 3000) @@ -1377,6 +1449,7 @@ export const useVideoStore = defineStore('video', () => { deleteStreamCorrespondency, restoreIgnoredStream, addRtspStreamCorrespondency, + addRtpStreamCorrespondency, enableLiveProcessing, keepRawVideoChunksAsBackup, } diff --git a/src/tests/libs/rtp-source.test.ts b/src/tests/libs/rtp-source.test.ts new file mode 100644 index 0000000000..dc09f785f4 --- /dev/null +++ b/src/tests/libs/rtp-source.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, test } from 'vitest' + +import { + type RtpSourceConfig, + buildRtpGo2rtcSource, + buildRtpSdp, + buildRtpUrl, + parseRtpUrl, + rtpConfigError, +} from '@/libs/rtp-source' + +const h264: RtpSourceConfig = { host: '0.0.0.0', port: 5600, codec: 'h264' } +const h265: RtpSourceConfig = { host: '192.168.2.1', port: 5601, codec: 'h265' } + +describe('URI build and parse', () => { + test('round-trips every valid config', () => { + for (const config of [h264, h265]) { + expect(parseRtpUrl(buildRtpUrl(config))).toEqual(config) + } + }) + + test('builds the canonical form', () => { + expect(buildRtpUrl(h264)).toBe('rtp://0.0.0.0:5600?codec=h264') + }) + + test('rejects malformed input', () => { + const invalid = [ + '', + 'not a url', + 'rtsp://0.0.0.0:5600', + 'rtp://0.0.0.0:5600', // no codec + 'rtp://0.0.0.0:5600?codec=vp8', // unsupported codec + 'rtp://0.0.0.0?codec=h264', // no port + 'rtp://0.0.0.0:80?codec=h264', // privileged port + 'rtp://0.0.0.0:70000?codec=h264', // out of range port + 'rtp://example.com:5600?codec=h264', // hostname instead of an IPv4 address + 'rtp://999.1.1.1:5600?codec=h264', + ] + for (const url of invalid) { + expect(parseRtpUrl(url), url).toBeUndefined() + } + }) +}) + +describe('config validation', () => { + test('accepts valid configs', () => { + expect(rtpConfigError(h264)).toBeUndefined() + expect(rtpConfigError(h265)).toBeUndefined() + }) + + test('explains what is wrong', () => { + expect(rtpConfigError({ ...h264, host: 'localhost' })).toMatch(/IPv4/) + expect(rtpConfigError({ ...h264, port: 5600.5 })).toMatch(/whole number/) + expect(rtpConfigError({ ...h264, codec: 'av1' as never })).toMatch(/h264, h265/) + }) +}) + +describe('SDP', () => { + test('describes the listen address, port and codec', () => { + const sdp = buildRtpSdp(h264) + expect(sdp).toContain('c=IN IP4 0.0.0.0') + expect(sdp).toContain('m=video 5600 RTP/AVP 96') + expect(sdp).toContain('a=rtpmap:96 H264/90000') + expect(sdp.startsWith('v=0\r\n')).toBe(true) + }) + + test('names the H.265 codec so ffmpeg depacketizes it correctly', () => { + expect(buildRtpSdp(h265)).toContain('a=rtpmap:96 H265/90000') + expect(buildRtpSdp(h265)).toContain('m=video 5601 RTP/AVP 96') + }) +}) + +describe('go2rtc source', () => { + test('carries the SDP inline', () => { + const source = buildRtpGo2rtcSource(h264) + expect(source).toContain('#input=cockpit-rtp') + const sdp = atob(source.slice('ffmpeg:data:application/sdp;base64,'.length, source.indexOf('#'))) + expect(sdp).toBe(buildRtpSdp(h264)) + }) + + test('passes both codecs through without re-encoding', () => { + for (const config of [h264, h265]) { + expect(buildRtpGo2rtcSource(config), config.codec).toContain('#video=copy') + } + }) + + test('never contains whitespace, which go2rtc would reject', () => { + for (const config of [h264, h265]) { + expect(buildRtpGo2rtcSource(config)).not.toMatch(/\s/) + } + }) +}) diff --git a/src/types/video.ts b/src/types/video.ts index 5c71dba57b..40e4e32414 100644 --- a/src/types/video.ts +++ b/src/types/video.ts @@ -39,7 +39,7 @@ export interface StreamData { timeRecordingStart: Date | undefined } -export type VideoStreamProtocol = 'webrtc' | 'rtsp' +export type VideoStreamProtocol = 'webrtc' | 'rtsp' | 'rtp' /** * Info about a stream's RTCPeerConnection, used for stats monitoring diff --git a/src/views/ConfigurationVideoView.vue b/src/views/ConfigurationVideoView.vue index acbe566480..4d23f572b3 100644 --- a/src/views/ConfigurationVideoView.vue +++ b/src/views/ConfigurationVideoView.vue @@ -62,20 +62,12 @@
- {{ - (item.protocol ?? videoStore.getStreamProtocol(item.externalId)) === 'rtsp' - ? 'RTSP' - : 'WebRTC' - }} + {{ protocolChip(item.externalId, item.protocol).label }}
@@ -172,6 +164,50 @@ {{ rtspInputError }} +
+
Add UDP video stream (Standalone)
+
+ + + + Add +
+
+ {{ rtpInputError }} +
+
+ Receives video sent straight to this computer over the network, the way ArduPilot and PX4 companion + computers send it. Use 0.0.0.0 to accept it on every network interface, and pick the codec the sender + is using, as this kind of stream does not announce it. +
+
@@ -455,11 +491,13 @@ import ExpansiblePanel from '@/components/ExpansiblePanel.vue' import InteractionDialog from '@/components/InteractionDialog.vue' import ScrollingText from '@/components/ScrollingText.vue' import { openSnackbar } from '@/composables/snackbar' +import { type RtpCodec, rtpConfigError } from '@/libs/rtp-source' import { isElectron, isValidIpv4Address, sanitizeIpv4Address } from '@/libs/utils' +import { protocolFromExternalId } from '@/libs/video-stream-protocol' import { useAppInterfaceStore } from '@/stores/appInterface' import { useSnapshotStore } from '@/stores/snapshot' import { useVideoStore } from '@/stores/video' -import { VideoStreamCorrespondency } from '@/types/video' +import { type VideoStreamProtocol, VideoStreamCorrespondency } from '@/types/video' import BaseConfigurationView from './BaseConfigurationView.vue' @@ -487,6 +525,29 @@ const showIgnoredStreams = ref(false) const rtspUrlInput = ref('rtsp://user:password@camera-ip:554/stream') const rtspInputError = ref('') +const rtpHostInput = ref('0.0.0.0') +const rtpPortInput = ref('5600') +const rtpCodecInput = ref('h264') +const rtpInputError = ref('') +const rtpCodecOptions = [ + { title: 'H.264', value: 'h264' as RtpCodec }, + { title: 'H.265 (HEVC)', value: 'h265' as RtpCodec }, +] + +const streamProtocolChips = { + webrtc: { label: 'WebRTC', color: '#3498db' }, + rtsp: { label: 'RTSP', color: '#e67e22' }, + rtp: { label: 'UDP', color: '#16a085' }, +} + +// Falls back rather than indexing blindly, since a vehicle-synced entry may name a protocol this version +// does not know about, and a missing chip would break the whole page. +const protocolChip = ( + externalId: string, + protocol?: VideoStreamProtocol +): (typeof streamProtocolChips)[VideoStreamProtocol] => + streamProtocolChips[protocol ?? videoStore.getStreamProtocol(externalId)] ?? streamProtocolChips.webrtc + const streamsToShow = computed(() => { return [ ...videoStore.streamsCorrespondency.map((item) => ({ ...item, isIgnored: false })), @@ -495,7 +556,7 @@ const streamsToShow = computed(() => { name: '--', externalId: id, isIgnored: true, - protocol: id.startsWith('rtsp://') || id.startsWith('rtsps://') ? ('rtsp' as const) : undefined, + protocol: protocolFromExternalId(id), })) : []), ].filter((item) => item.name !== '') @@ -549,14 +610,34 @@ const addRtspStream = (): void => { } } +const addRtpStream = (): void => { + const config = { + host: rtpHostInput.value.trim(), + port: Number(rtpPortInput.value), + codec: rtpCodecInput.value, + } + + rtpInputError.value = rtpConfigError(config) ?? '' + if (rtpInputError.value) return + + logUserAction(`Added UDP video stream on ${config.host}:${config.port} (${config.codec})`) + try { + const stream = videoStore.addRtpStreamCorrespondency(config) + openSnackbar({ variant: 'success', message: `Video stream '${stream.name}' added.` }) + } catch (error) { + rtpInputError.value = (error as Error).message + } +} + const deleteStream = (item: VideoStreamCorrespondency): void => { logUserAction(`Removed video stream '${item.name}'`) videoStore.deleteStreamCorrespondency(item.externalId) } const restoreIgnoredStream = (externalId: string): void => { - const isRtsp = externalId.startsWith('rtsp://') || externalId.startsWith('rtsps://') - const isStreamAvailable = isRtsp || videoStore.namesAvailableStreams.includes(externalId) + // go2rtc-backed streams are described entirely by their id, so they can always be brought back. + const isStreamAvailable = + protocolFromExternalId(externalId) !== 'webrtc' || videoStore.namesAvailableStreams.includes(externalId) // If the stream is available, restore normally, otherwise ask the user to confirm they want to delete it permanently if (isStreamAvailable) { @@ -597,7 +678,7 @@ const getStreamDisplayInfo = ( // eslint-disable-next-line const getStreamStatus = (item: { externalId: string; protocol?: string }): { status: 'Available' | 'Unavailable' | 'Offline' | 'Unknown'; icon: string; color: string } => { const protocol = item.protocol ?? videoStore.getStreamProtocol(item.externalId) - if (protocol === 'rtsp') { + if (protocol !== 'webrtc') { return isElectron() ? { status: 'Available', icon: 'mdi-check-circle', color: '#297e1944' } : { status: 'Unavailable', icon: 'mdi-close-circle', color: '#ff000044' }