Support Controlling PTZ Cameras Via WebUI (#4715)

* Add support for ptz commands via websocket

* Fix startup issues

* Fix bugs

* Set config manually

* Add more commands

* Add presets

* Add zooming

* Fixes

* Set name

* Cleanup

* Add ability to set presets from UI

* Add ability to set preset from UI

* Cleanup for errors

* Ui tweaks

* Add visual design for pan / tilt

* Add pan/tilt support

* Support zooming

* Try to set wsdl

* Fix duplicate logs

* Catch auth errors

* Don't init onvif for disabled cameras

* Fix layout sizing

* Don't comment out

* Fix formatting

* Add ability to control camera with keyboard shortcuts

* Disallow user selection

* Fix mobile pressing

* Remove logs

* Substitute onvif password

* Add ptz controls ot birdseye

* Put wsdl back

* Add padding

* Formatting

* Catch onvif error

* Optimize layout for mobile and web

* Place ptz controls next to birdseye view in large layout

* Fix pt support

* Center text titles

* Update tests

* Update docs

* Write camera docs for PTZ

* Add MQTT docs for PTZ

* Add ptz info docs for http

* Fix test

* Make half width when full screen

* Fix preset panel logic

* Fix parsing

* Update mqtt.md

* Catch preset error

* Add onvif example to docs

* Remove template example from main camera docs
This commit is contained in:
Nicolas Mowen
2023-04-26 05:08:53 -06:00
committed by GitHub
parent 0d16bd0144
commit 43ade86796
21 changed files with 769 additions and 16 deletions

View File

@@ -120,6 +120,15 @@ export function useSnapshotsState(camera) {
return { payload, send, connected };
}
export function usePtzCommand(camera) {
const {
value: { payload },
send,
connected,
} = useWs(`${camera}/ptz`, `${camera}/ptz`);
return { payload, send, connected };
}
export function useRestart() {
const {
value: { payload },

View File

@@ -0,0 +1,248 @@
import { h } from 'preact';
import { useState } from 'preact/hooks';
import useSWR from 'swr';
import { usePtzCommand } from '../api/ws';
import ActivityIndicator from './ActivityIndicator';
import ArrowRightDouble from '../icons/ArrowRightDouble';
import ArrowUpDouble from '../icons/ArrowUpDouble';
import ArrowDownDouble from '../icons/ArrowDownDouble';
import ArrowLeftDouble from '../icons/ArrowLeftDouble';
import Button from './Button';
import Heading from './Heading';
export default function CameraControlPanel({ camera = '' }) {
const { data: ptz } = useSWR(`${camera}/ptz/info`);
const [currentPreset, setCurrentPreset] = useState('');
const { payload: _, send: sendPtz } = usePtzCommand(camera);
const onSetPreview = async (e) => {
e.stopPropagation();
if (currentPreset == 'none') {
return;
}
sendPtz(`preset-${currentPreset}`);
setCurrentPreset('');
};
const onSetMove = async (e, dir) => {
e.stopPropagation();
sendPtz(`MOVE_${dir}`);
setCurrentPreset('');
};
const onSetZoom = async (e, dir) => {
e.stopPropagation();
sendPtz(`ZOOM_${dir}`);
setCurrentPreset('');
};
const onSetStop = async (e) => {
e.stopPropagation();
sendPtz('STOP');
};
if (!ptz) {
return <ActivityIndicator />;
}
document.addEventListener('keydown', (e) => {
if (!e) {
return;
}
if (e.repeat) {
e.preventDefault();
return;
}
if (ptz.features.includes('pt')) {
if (e.key === 'ArrowLeft') {
e.preventDefault();
onSetMove(e, 'LEFT');
} else if (e.key === 'ArrowRight') {
e.preventDefault();
onSetMove(e, 'RIGHT');
} else if (e.key === 'ArrowUp') {
e.preventDefault();
onSetMove(e, 'UP');
} else if (e.key === 'ArrowDown') {
e.preventDefault();
onSetMove(e, 'DOWN');
}
if (ptz.features.includes('zoom')) {
if (e.key == '+') {
e.preventDefault();
onSetZoom(e, 'IN');
} else if (e.key == '-') {
e.preventDefault();
onSetZoom(e, 'OUT');
}
}
}
});
document.addEventListener('keyup', (e) => {
if (!e || e.repeat) {
return;
}
if (
e.key === 'ArrowLeft' ||
e.key === 'ArrowRight' ||
e.key === 'ArrowUp' ||
e.key === 'ArrowDown' ||
e.key === '+' ||
e.key === '-'
) {
e.preventDefault();
onSetStop(e);
}
});
return (
<div data-testid="control-panel" className="p-4 text-center sm:flex justify-start">
{ptz.features.includes('pt') && (
<div className="flex justify-center">
<div className="w-44 px-4">
<Heading size="xs" className="my-4">
Pan / Tilt
</Heading>
<div className="w-full flex justify-center">
<button
onMouseDown={(e) => onSetMove(e, 'UP')}
onMouseUp={(e) => onSetStop(e)}
onTouchStart={(e) => {
onSetMove(e, 'UP');
e.preventDefault();
}}
onTouchEnd={(e) => {
onSetStop(e);
e.preventDefault();
}}
>
<ArrowUpDouble className="h-12 p-2 bg-slate-500" />
</button>
</div>
<div className="w-full flex justify-between">
<button
onMouseDown={(e) => onSetMove(e, 'LEFT')}
onMouseUp={(e) => onSetStop(e)}
onTouchStart={(e) => {
onSetMove(e, 'LEFT');
e.preventDefault();
}}
onTouchEnd={(e) => {
onSetStop(e);
e.preventDefault();
}}
>
<ArrowLeftDouble className="btn h-12 p-2 bg-slate-500" />
</button>
<button
onMouseDown={(e) => onSetMove(e, 'RIGHT')}
onMouseUp={(e) => onSetStop(e)}
onTouchStart={(e) => {
onSetMove(e, 'RIGHT');
e.preventDefault();
}}
onTouchEnd={(e) => {
onSetStop(e);
e.preventDefault();
}}
>
<ArrowRightDouble className="h-12 p-2 bg-slate-500" />
</button>
</div>
<div className="flex justify-center">
<button
onMouseDown={(e) => onSetMove(e, 'DOWN')}
onMouseUp={(e) => onSetStop(e)}
onTouchStart={(e) => {
onSetMove(e, 'DOWN');
e.preventDefault();
}}
onTouchEnd={(e) => {
onSetStop(e);
e.preventDefault();
}}
>
<ArrowDownDouble className="h-12 p-2 bg-slate-500" />
</button>
</div>
</div>
</div>
)}
{ptz.features.includes('zoom') && (
<div className="px-4 sm:w-44">
<Heading size="xs" className="my-4">
Zoom
</Heading>
<div className="w-full flex justify-center">
<button
onMouseDown={(e) => onSetZoom(e, 'IN')}
onMouseUp={(e) => onSetStop(e)}
onTouchStart={(e) => {
onSetZoom(e, 'IN');
e.preventDefault();
}}
onTouchEnd={(e) => {
onSetStop(e);
e.preventDefault();
}}
>
<div className="h-12 w-12 p-2 text-2xl bg-slate-500 select-none">+</div>
</button>
</div>
<div className="h-12" />
<div className="flex justify-center">
<button
onMouseDown={(e) => onSetZoom(e, 'OUT')}
onMouseUp={(e) => onSetStop(e)}
onTouchStart={(e) => {
onSetZoom(e, 'OUT');
e.preventDefault();
}}
onTouchEnd={(e) => {
onSetStop(e);
e.preventDefault();
}}
>
<div className="h-12 w-12 p-2 text-2xl bg-slate-500 select-none">-</div>
</button>
</div>
</div>
)}
{(ptz.presets || []).length > 0 && (
<div className="px-4">
<Heading size="xs" className="my-4">
Presets
</Heading>
<div className="py-4">
<select
className="cursor-pointer rounded dark:bg-slate-800"
value={currentPreset}
onChange={(e) => {
setCurrentPreset(e.target.value);
}}
>
<option value="">Select Preset</option>
{ptz.presets.map((item) => (
<option key={item} value={item}>
{item.charAt(0).toUpperCase() + item.slice(1)}
</option>
))}
</select>
</div>
<Button onClick={(e) => onSetPreview(e)}>Move Camera To Preset</Button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,19 @@
import { h } from 'preact';
import { memo } from 'preact/compat';
export function ArrowDownDouble({ className = '' }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className={`${className}`}
>
<path d="M19.5 5.25l-7.5 7.5-7.5-7.5m15 6l-7.5 7.5-7.5-7.5" />
</svg>
);
}
export default memo(ArrowDownDouble);

View File

@@ -0,0 +1,19 @@
import { h } from 'preact';
import { memo } from 'preact/compat';
export function ArrowLeftDouble({ className = '' }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className={`${className}`}
>
<path d="M18.75 19.5l-7.5-7.5 7.5-7.5m-6 15L5.25 12l7.5-7.5" />
</svg>
);
}
export default memo(ArrowLeftDouble);

View File

@@ -3,8 +3,15 @@ import { memo } from 'preact/compat';
export function ArrowRightDouble({ className = '' }) {
return (
<svg className={`fill-current ${className}`} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M0 3.795l2.995-2.98 11.132 11.185-11.132 11.186-2.995-2.981 8.167-8.205-8.167-8.205zm18.04 8.205l-8.167 8.205 2.995 2.98 11.132-11.185-11.132-11.186-2.995 2.98 8.167 8.206z" />
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className={`${className}`}
>
<path d="M11.25 4.5l7.5 7.5-7.5 7.5m-6-15l7.5 7.5-7.5 7.5" />
</svg>
);
}

View File

@@ -0,0 +1,19 @@
import { h } from 'preact';
import { memo } from 'preact/compat';
export function ArrowUpDouble({ className = '' }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className={`${className}`}
>
<path d="M4.5 12.75l7.5-7.5 7.5 7.5m-15 6l7.5-7.5 7.5 7.5" />
</svg>
);
}
export default memo(ArrowUpDouble);

View File

@@ -6,6 +6,8 @@ import Heading from '../components/Heading';
import WebRtcPlayer from '../components/WebRtcPlayer';
import MsePlayer from '../components/MsePlayer';
import useSWR from 'swr';
import { useMemo } from 'preact/hooks';
import CameraControlPanel from '../components/CameraControlPanel';
export default function Birdseye() {
const { data: config } = useSWR('config');
@@ -16,6 +18,16 @@ export default function Birdseye() {
);
const sourceValues = ['mse', 'webrtc', 'jsmpeg'];
const ptzCameras = useMemo(() => {
if (!config) {
return [];
}
return Object.entries(config.cameras)
.filter(([_, conf]) => conf.onvif?.host)
.map(([_, camera]) => camera.name);
}, [config]);
if (!config || !sourceIsLoaded) {
return <ActivityIndicator />;
}
@@ -25,7 +37,7 @@ export default function Birdseye() {
if ('MediaSource' in window) {
player = (
<Fragment>
<div className="max-w-5xl">
<div className="max-w-5xl xl:w-1/2">
<MsePlayer camera="birdseye" />
</div>
</Fragment>
@@ -42,7 +54,7 @@ export default function Birdseye() {
} else if (viewSource == 'webrtc' && config.birdseye.restream) {
player = (
<Fragment>
<div className="max-w-5xl">
<div className="max-w-5xl xl:w-1/2">
<WebRtcPlayer camera="birdseye" />
</div>
</Fragment>
@@ -50,7 +62,7 @@ export default function Birdseye() {
} else {
player = (
<Fragment>
<div className="max-w-7xl">
<div className="max-w-7xl xl:w-1/2">
<JSMpegPlayer camera="birdseye" />
</div>
</Fragment>
@@ -79,7 +91,21 @@ export default function Birdseye() {
)}
</div>
{player}
<div className="xl:flex justify-between">
{player}
{ptzCameras && (
<div className="dark:bg-gray-800 shadow-md hover:shadow-lg rounded-lg transition-shadow p-4 w-full sm:w-min xl:h-min xl:w-1/2">
<Heading size="sm">Control Panel</Heading>
{ptzCameras.map((camera) => (
<div className="p-4" key={camera}>
<Heading size="lg">{camera.replaceAll('_', ' ')}</Heading>
<CameraControlPanel camera={camera} />
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -15,6 +15,7 @@ import { useApiHost } from '../api';
import useSWR from 'swr';
import WebRtcPlayer from '../components/WebRtcPlayer';
import MsePlayer from '../components/MsePlayer';
import CameraControlPanel from '../components/CameraControlPanel';
const emptyObject = Object.freeze({});
@@ -188,6 +189,13 @@ export default function Camera({ camera }) {
{player}
{cameraConfig?.onvif?.host && (
<div className="dark:bg-gray-800 shadow-md hover:shadow-lg rounded-lg transition-shadow p-4 w-full sm:w-min">
<Heading size="sm">Control Panel</Heading>
<CameraControlPanel camera={camera} />
</div>
)}
<div className="space-y-4">
<Heading size="sm">Tracked objects</Heading>
<div className="flex flex-wrap justify-start">