diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 35620eb99..6d58a9998 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -3,6 +3,7 @@ import { lazy } from "react";
import { Navigate, useRoutes } from "react-router-dom";
const Cameras = lazy(() => import("pages/Cameras"));
+const CamerasSettings = lazy(() => import("pages/settings/Cameras"));
const Tuning = lazy(() => import("pages/Tuning"));
const CameraRecordings = lazy(
() => import("pages/recordings/CameraRecordings"),
@@ -92,6 +93,10 @@ function App() {
path: "/settings/configuration",
element: ,
},
+ {
+ path: "/settings/cameras",
+ element: ,
+ },
{
path: "/settings/users",
element: ,
diff --git a/frontend/src/components/camera/AddCameraDialog.tsx b/frontend/src/components/camera/AddCameraDialog.tsx
new file mode 100644
index 000000000..ec2f1f96a
--- /dev/null
+++ b/frontend/src/components/camera/AddCameraDialog.tsx
@@ -0,0 +1,352 @@
+import { Add, Checkmark, Close } from "@carbon/icons-react";
+import Box from "@mui/material/Box";
+import Button from "@mui/material/Button";
+import Checkbox from "@mui/material/Checkbox";
+import Dialog from "@mui/material/Dialog";
+import DialogActions from "@mui/material/DialogActions";
+import DialogContent from "@mui/material/DialogContent";
+import DialogTitle from "@mui/material/DialogTitle";
+import FormControl from "@mui/material/FormControl";
+import FormControlLabel from "@mui/material/FormControlLabel";
+import Grid from "@mui/material/Grid";
+import InputLabel from "@mui/material/InputLabel";
+import MenuItem from "@mui/material/MenuItem";
+import Select from "@mui/material/Select";
+import TextField from "@mui/material/TextField";
+import { useState } from "react";
+
+import { LoadingButton } from "components/buttons/LoadingButton";
+import {
+ AddCameraConfigPayload,
+ useAddCameraConfig,
+} from "lib/api/cameras";
+
+type AddCameraDialogProps = {
+ open: boolean;
+ onClose: () => void;
+};
+
+const PATH_PRESETS = [
+ {
+ label: "Mobotix HTTP live",
+ path: "/control/faststream.jpg?stream=full&fps=10",
+ port: 80,
+ stream_format: "mjpeg" as const,
+ },
+ {
+ label: "Hikvision main",
+ path: "/Streaming/Channels/101/",
+ port: 554,
+ stream_format: "rtsp" as const,
+ },
+ {
+ label: "Hikvision sub",
+ path: "/Streaming/Channels/102/",
+ port: 554,
+ stream_format: "rtsp" as const,
+ },
+ {
+ label: "Dahua main",
+ path: "/cam/realmonitor?channel=1&subtype=0",
+ port: 554,
+ stream_format: "rtsp" as const,
+ },
+ {
+ label: "Dahua sub",
+ path: "/cam/realmonitor?channel=1&subtype=1",
+ port: 554,
+ stream_format: "rtsp" as const,
+ },
+ { label: "Generic", path: "/", port: 554, stream_format: "rtsp" as const },
+];
+
+const identifierFromName = (name: string) =>
+ name
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9_]+/g, "_")
+ .replace(/^_+|_+$/g, "")
+ .replace(/_+/g, "_");
+
+const initialForm: AddCameraConfigPayload = {
+ identifier: "",
+ name: "",
+ host: "",
+ port: 554,
+ path: "/Streaming/Channels/101/",
+ stream_format: "rtsp",
+ username: "",
+ password: "",
+ substream_path: "",
+ substream_port: 554,
+ substream_stream_format: "rtsp",
+ idle_timeout: 5,
+ enable_recorder: true,
+ enable_nvr: true,
+ reload: true,
+};
+
+export function AddCameraDialog({ open, onClose }: AddCameraDialogProps) {
+ const [form, setForm] = useState(initialForm);
+ const [identifierEdited, setIdentifierEdited] = useState(false);
+
+ const addCamera = useAddCameraConfig({
+ onSuccess: () => {
+ setForm(initialForm);
+ setIdentifierEdited(false);
+ onClose();
+ },
+ });
+
+ const updateForm = (
+ key: K,
+ value: AddCameraConfigPayload[K],
+ ) => {
+ setForm((current) => ({ ...current, [key]: value }));
+ };
+
+ const close = () => {
+ if (!addCamera.isPending) {
+ onClose();
+ }
+ };
+
+ const submit = () => {
+ addCamera.mutate({
+ ...form,
+ identifier: form.identifier.trim(),
+ name: form.name.trim(),
+ host: form.host.trim(),
+ path: form.path.trim(),
+ username: form.username?.trim() || null,
+ password: form.password || null,
+ substream_path: form.substream_path?.trim() || null,
+ });
+ };
+
+ const valid =
+ /^[A-Za-z0-9_]+$/.test(form.identifier) &&
+ form.name.trim().length > 0 &&
+ form.host.trim().length > 0 &&
+ form.path.trim().length > 0;
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/camera/CameraEditDialog.tsx b/frontend/src/components/camera/CameraEditDialog.tsx
new file mode 100644
index 000000000..c6aad261a
--- /dev/null
+++ b/frontend/src/components/camera/CameraEditDialog.tsx
@@ -0,0 +1,614 @@
+import { Checkmark, Close, Save, TrashCan } from "@carbon/icons-react";
+import Alert from "@mui/material/Alert";
+import Box from "@mui/material/Box";
+import Button from "@mui/material/Button";
+import Checkbox from "@mui/material/Checkbox";
+import Dialog from "@mui/material/Dialog";
+import DialogActions from "@mui/material/DialogActions";
+import DialogContent from "@mui/material/DialogContent";
+import DialogTitle from "@mui/material/DialogTitle";
+import FormControl from "@mui/material/FormControl";
+import FormControlLabel from "@mui/material/FormControlLabel";
+import Grid from "@mui/material/Grid";
+import InputLabel from "@mui/material/InputLabel";
+import ListItemText from "@mui/material/ListItemText";
+import MenuItem from "@mui/material/MenuItem";
+import Select from "@mui/material/Select";
+import TextField from "@mui/material/TextField";
+import Typography from "@mui/material/Typography";
+import { useEffect, useMemo, useState } from "react";
+
+import { LoadingButton } from "components/buttons/LoadingButton";
+import { ErrorMessage } from "components/error/ErrorMessage";
+import { Loading } from "components/loading/Loading";
+import {
+ useCameraAccessConfig,
+ useSaveCameraAccessConfig,
+} from "lib/api/cameraAccess";
+import {
+ CameraConfigPayload,
+ useCameraConfig,
+ useDeleteCameraConfig,
+ useUpdateCameraConfig,
+} from "lib/api/cameras";
+import * as types from "lib/types";
+
+type CameraEditDialogProps = {
+ camera: types.Camera | types.FailedCamera;
+ onClose: () => void;
+};
+
+const PATH_PRESETS = [
+ {
+ label: "Mobotix HTTP live",
+ path: "/control/faststream.jpg?stream=full&fps=10",
+ port: 80,
+ stream_format: "mjpeg" as const,
+ },
+ {
+ label: "Mobotix snapshot",
+ path: "/record/current.jpg",
+ port: 80,
+ stream_format: "mjpeg" as const,
+ },
+ {
+ label: "Hikvision main",
+ path: "/Streaming/Channels/101/",
+ port: 554,
+ stream_format: "rtsp" as const,
+ },
+ {
+ label: "Hikvision sub",
+ path: "/Streaming/Channels/102/",
+ port: 554,
+ stream_format: "rtsp" as const,
+ },
+ {
+ label: "Dahua main",
+ path: "/cam/realmonitor?channel=1&subtype=0",
+ port: 554,
+ stream_format: "rtsp" as const,
+ },
+ {
+ label: "Dahua sub",
+ path: "/cam/realmonitor?channel=1&subtype=1",
+ port: 554,
+ stream_format: "rtsp" as const,
+ },
+];
+
+const EMPTY_FORM: CameraConfigPayload = {
+ name: "",
+ host: "",
+ port: 554,
+ path: "",
+ stream_format: "rtsp",
+ username: "",
+ password: "",
+ substream_path: "",
+ substream_port: 554,
+ substream_stream_format: "rtsp",
+ fps: null,
+ idle_timeout: 5,
+ enable_recorder: true,
+ enable_nvr: true,
+ record_only: false,
+ width: null,
+ height: null,
+ video_filters: [],
+ reload: true,
+};
+
+function videoFiltersToText(videoFilters: string[] | undefined) {
+ return (videoFilters || []).join("\n");
+}
+
+function textToVideoFilters(value: string) {
+ return value
+ .split("\n")
+ .map((videoFilter) => videoFilter.trim())
+ .filter(Boolean);
+}
+
+function numberOrNull(value: string) {
+ if (value.trim() === "") {
+ return null;
+ }
+ return Number(value);
+}
+
+function updateCameraGroups(
+ config: types.CameraAccessConfig,
+ cameraIdentifier: string,
+ selectedGroupIds: string[],
+) {
+ const selected = new Set(selectedGroupIds);
+ return {
+ ...config,
+ camera_groups: config.camera_groups.map((group) => {
+ const cameras = new Set(group.cameras);
+ if (selected.has(group.id)) {
+ cameras.add(cameraIdentifier);
+ } else {
+ cameras.delete(cameraIdentifier);
+ }
+ return { ...group, cameras: Array.from(cameras).sort() };
+ }),
+ };
+}
+
+export function CameraEditDialog({ camera, onClose }: CameraEditDialogProps) {
+ const cameraConfig = useCameraConfig(camera.identifier);
+ const cameraAccessConfig = useCameraAccessConfig();
+ const updateCameraConfig = useUpdateCameraConfig();
+ const deleteCameraConfig = useDeleteCameraConfig();
+ const saveCameraAccessConfig = useSaveCameraAccessConfig();
+ const [form, setForm] = useState(EMPTY_FORM);
+ const [selectedGroupIds, setSelectedGroupIds] = useState([]);
+ const [videoFiltersText, setVideoFiltersText] = useState("");
+ const cameraGroups = cameraAccessConfig.data?.config.camera_groups || [];
+
+ useEffect(() => {
+ if (!cameraConfig.data?.config) {
+ return;
+ }
+ const {
+ identifier: _identifier,
+ password_set: _passwordSet,
+ ...config
+ } = cameraConfig.data.config;
+ // eslint-disable-next-line react-hooks/set-state-in-effect
+ setForm({
+ ...EMPTY_FORM,
+ ...config,
+ password: "",
+ });
+ setVideoFiltersText(videoFiltersToText(config.video_filters));
+ }, [cameraConfig.data]);
+
+ useEffect(() => {
+ if (!cameraAccessConfig.data?.config) {
+ return;
+ }
+ // eslint-disable-next-line react-hooks/set-state-in-effect
+ setSelectedGroupIds(
+ cameraAccessConfig.data.config.camera_groups
+ .filter((group) => group.cameras.includes(camera.identifier))
+ .map((group) => group.id),
+ );
+ }, [camera.identifier, cameraAccessConfig.data]);
+
+ const updateForm = (
+ key: K,
+ value: CameraConfigPayload[K],
+ ) => {
+ setForm((current) => ({ ...current, [key]: value }));
+ };
+
+ const selectedPreset = useMemo(
+ () =>
+ PATH_PRESETS.find(
+ (preset) =>
+ preset.path === form.path &&
+ preset.port === form.port &&
+ preset.stream_format === form.stream_format,
+ )?.label || "",
+ [form.path, form.port, form.stream_format],
+ );
+
+ const valid =
+ form.name.trim().length > 0 &&
+ form.host.trim().length > 0 &&
+ form.path.trim().length > 0 &&
+ form.port >= 1 &&
+ form.port <= 65535;
+
+ const isSaving =
+ updateCameraConfig.isPending ||
+ saveCameraAccessConfig.isPending ||
+ deleteCameraConfig.isPending;
+
+ const save = async () => {
+ const payload: CameraConfigPayload = {
+ ...form,
+ name: form.name.trim(),
+ host: form.host.trim(),
+ path: form.path.trim(),
+ username: form.username?.trim() || null,
+ password: form.password || null,
+ substream_path: form.substream_path?.trim() || null,
+ video_filters: textToVideoFilters(videoFiltersText),
+ };
+
+ await updateCameraConfig.mutateAsync({
+ cameraIdentifier: camera.identifier,
+ payload,
+ });
+
+ if (cameraAccessConfig.data?.config) {
+ await saveCameraAccessConfig.mutateAsync(
+ updateCameraGroups(
+ cameraAccessConfig.data.config,
+ camera.identifier,
+ selectedGroupIds,
+ ),
+ );
+ }
+
+ onClose();
+ };
+
+ const deleteCamera = async () => {
+ // eslint-disable-next-line no-alert
+ if (!window.confirm(`Delete camera ${camera.name}?`)) {
+ return;
+ }
+ await deleteCameraConfig.mutateAsync({
+ cameraIdentifier: camera.identifier,
+ reload: form.reload,
+ });
+ onClose();
+ };
+
+ const close = () => {
+ if (!isSaving) {
+ onClose();
+ }
+ };
+
+ if (cameraConfig.isLoading) {
+ return (
+
+ );
+ }
+
+ if (cameraConfig.isError || !cameraConfig.data) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/camera/NoCamerasConfigured.tsx b/frontend/src/components/camera/NoCamerasConfigured.tsx
index dccc24008..a69043f2c 100644
--- a/frontend/src/components/camera/NoCamerasConfigured.tsx
+++ b/frontend/src/components/camera/NoCamerasConfigured.tsx
@@ -1,72 +1,82 @@
+import { Add } from "@carbon/icons-react";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Grid from "@mui/material/Grid";
import Typography from "@mui/material/Typography";
+import { useState } from "react";
import { useNavigate } from "react-router-dom";
import ViseronLogo from "svg/viseron-logo.svg?react";
+import { AddCameraDialog } from "components/camera/AddCameraDialog";
+
export function NoCamerasConfigured() {
const navigate = useNavigate();
+ const [addCameraOpen, setAddCameraOpen] = useState(false);
return (
-
-
-
-
-
-
-
-
- No cameras configured
-
-
-
-
- Add a camera component to your config.yaml{" "}
- to get started.
-
-
-
-
-
-
-
+
+
+
+
+
+ No cameras configured
+
+
+
+
+ Add a camera component to your config.yaml{" "}
+ to get started.
+
+
+
+
+ }
+ variant="contained"
+ onClick={() => setAddCameraOpen(true)}
+ >
+ Add Camera
+
+
+
+
-
+ setAddCameraOpen(false)}
+ />
+ >
);
}
diff --git a/frontend/src/lib/api/cameras.ts b/frontend/src/lib/api/cameras.ts
index 4f8d708a7..ae9528514 100644
--- a/frontend/src/lib/api/cameras.ts
+++ b/frontend/src/lib/api/cameras.ts
@@ -1,6 +1,13 @@
-import { UseQueryOptions, useQuery } from "@tanstack/react-query";
+import {
+ UseMutationOptions,
+ UseQueryOptions,
+ useMutation,
+ useQuery,
+ useQueryClient,
+} from "@tanstack/react-query";
import { useMemo } from "react";
+import { useToast } from "hooks/UseToast";
import { viseronAPI } from "lib/api/client";
import { useInvalidateQueryOnEvent } from "lib/api/utils";
import * as types from "lib/types";
@@ -16,6 +23,213 @@ async function cameras() {
return response.data;
}
+export type AddCameraConfigPayload = {
+ identifier: string;
+ name: string;
+ host: string;
+ port: number;
+ path: string;
+ stream_format: "rtsp" | "mjpeg";
+ username?: string | null;
+ password?: string | null;
+ substream_path?: string | null;
+ substream_port?: number;
+ substream_stream_format?: "rtsp" | "mjpeg";
+ fps?: number | null;
+ idle_timeout: number;
+ enable_recorder: boolean;
+ enable_nvr: boolean;
+ record_only?: boolean;
+ width?: number | null;
+ height?: number | null;
+ video_filters?: string[];
+ reload: boolean;
+};
+
+export type CameraConfigPayload = Omit;
+
+export type CameraConfigResponse = {
+ config: AddCameraConfigPayload & {
+ password_set: boolean;
+ };
+};
+
+export type CameraConfigSaveResponse = {
+ message: string;
+ reloaded: boolean;
+ restart_required?: boolean;
+};
+
+async function addCameraConfig(payload: AddCameraConfigPayload) {
+ const response = await viseronAPI.post(
+ "cameras",
+ payload,
+ );
+ return response.data;
+}
+
+export const useAddCameraConfig = (
+ mutationOptions?: Omit<
+ UseMutationOptions<
+ CameraConfigSaveResponse,
+ types.APIErrorResponse,
+ AddCameraConfigPayload
+ >,
+ "mutationFn"
+ >,
+) => {
+ const queryClient = useQueryClient();
+ const toast = useToast();
+
+ return useMutation({
+ ...mutationOptions,
+ mutationFn: addCameraConfig,
+ onSuccess: async (data, variables, onMutateResult, context) => {
+ toast.success(data.message);
+ await queryClient.invalidateQueries({ queryKey: ["cameras"] });
+ await queryClient.invalidateQueries({ queryKey: ["cameras", "failed"] });
+ await mutationOptions?.onSuccess?.(
+ data,
+ variables,
+ onMutateResult,
+ context,
+ );
+ },
+ onError: (error, variables, onMutateResult, context) => {
+ toast.error(
+ error.response && error.response.data.error
+ ? `Error adding camera: ${error.response.data.error}`
+ : `An error occurred: ${error.message}`,
+ );
+ mutationOptions?.onError?.(error, variables, onMutateResult, context);
+ },
+ });
+};
+
+async function cameraConfig(cameraIdentifier: string) {
+ const response = await viseronAPI.get(
+ `cameras/${cameraIdentifier}/config`,
+ );
+ return response.data;
+}
+
+export const useCameraConfig = (cameraIdentifier: string, enabled = true) =>
+ useQuery({
+ queryKey: ["cameras", "config", cameraIdentifier],
+ queryFn: async () => cameraConfig(cameraIdentifier),
+ enabled,
+ });
+
+async function updateCameraConfig({
+ cameraIdentifier,
+ payload,
+}: {
+ cameraIdentifier: string;
+ payload: CameraConfigPayload;
+}) {
+ const response = await viseronAPI.put(
+ `cameras/${cameraIdentifier}/config`,
+ payload,
+ );
+ return response.data;
+}
+
+export const useUpdateCameraConfig = (
+ mutationOptions?: Omit<
+ UseMutationOptions<
+ CameraConfigSaveResponse,
+ types.APIErrorResponse,
+ { cameraIdentifier: string; payload: CameraConfigPayload }
+ >,
+ "mutationFn"
+ >,
+) => {
+ const queryClient = useQueryClient();
+ const toast = useToast();
+
+ return useMutation({
+ ...mutationOptions,
+ mutationFn: updateCameraConfig,
+ onSuccess: async (data, variables, onMutateResult, context) => {
+ toast.success(data.message);
+ await queryClient.invalidateQueries({ queryKey: ["cameras"] });
+ await queryClient.invalidateQueries({ queryKey: ["cameras", "failed"] });
+ await queryClient.invalidateQueries({
+ queryKey: ["cameras", "config", variables.cameraIdentifier],
+ });
+ await mutationOptions?.onSuccess?.(
+ data,
+ variables,
+ onMutateResult,
+ context,
+ );
+ },
+ onError: (error, variables, onMutateResult, context) => {
+ toast.error(
+ error.response && error.response.data.error
+ ? `Error saving camera: ${error.response.data.error}`
+ : `An error occurred: ${error.message}`,
+ );
+ mutationOptions?.onError?.(error, variables, onMutateResult, context);
+ },
+ });
+};
+
+async function deleteCameraConfig({
+ cameraIdentifier,
+ reload,
+}: {
+ cameraIdentifier: string;
+ reload: boolean;
+}) {
+ const response = await viseronAPI.delete(
+ `cameras/${cameraIdentifier}`,
+ { data: { reload } },
+ );
+ return response.data;
+}
+
+export const useDeleteCameraConfig = (
+ mutationOptions?: Omit<
+ UseMutationOptions<
+ CameraConfigSaveResponse,
+ types.APIErrorResponse,
+ { cameraIdentifier: string; reload: boolean }
+ >,
+ "mutationFn"
+ >,
+) => {
+ const queryClient = useQueryClient();
+ const toast = useToast();
+
+ return useMutation({
+ ...mutationOptions,
+ mutationFn: deleteCameraConfig,
+ onSuccess: async (data, variables, onMutateResult, context) => {
+ toast.success(data.message);
+ await queryClient.invalidateQueries({ queryKey: ["cameras"] });
+ await queryClient.invalidateQueries({ queryKey: ["cameras", "failed"] });
+ await queryClient.invalidateQueries({
+ queryKey: ["cameraaccess", "config"],
+ });
+ await mutationOptions?.onSuccess?.(
+ data,
+ variables,
+ onMutateResult,
+ context,
+ );
+ },
+ onError: (error, variables, onMutateResult, context) => {
+ toast.error(
+ error.response && error.response.data.error
+ ? `Error deleting camera: ${error.response.data.error}`
+ : `An error occurred: ${error.message}`,
+ );
+ mutationOptions?.onError?.(error, variables, onMutateResult, context);
+ },
+ });
+};
+
export const useCameras = ({ configOptions }: CamerasVariables) => {
useInvalidateQueryOnEvent([
{
diff --git a/frontend/src/pages/settings/Cameras.tsx b/frontend/src/pages/settings/Cameras.tsx
new file mode 100644
index 000000000..665e115eb
--- /dev/null
+++ b/frontend/src/pages/settings/Cameras.tsx
@@ -0,0 +1,119 @@
+import { Add, Video } from "@carbon/icons-react";
+import Avatar from "@mui/material/Avatar";
+import Box from "@mui/material/Box";
+import Button from "@mui/material/Button";
+import Container from "@mui/material/Container";
+import List from "@mui/material/List";
+import ListItemAvatar from "@mui/material/ListItemAvatar";
+import ListItemButton from "@mui/material/ListItemButton";
+import ListItemText from "@mui/material/ListItemText";
+import Paper from "@mui/material/Paper";
+import Typography from "@mui/material/Typography";
+import { useState } from "react";
+
+import { AddCameraDialog } from "components/camera/AddCameraDialog";
+import { CameraEditDialog } from "components/camera/CameraEditDialog";
+import { Loading } from "components/loading/Loading";
+import { useTitle } from "hooks/UseTitle";
+import { useCamerasAll } from "lib/api/cameras";
+import * as types from "lib/types";
+
+function cameraSecondaryText(camera: types.Camera | types.FailedCamera) {
+ if (camera.failed) {
+ return camera.error;
+ }
+
+ if (!camera.connected) {
+ return "Disconnected";
+ }
+
+ return camera.is_recording ? "Recording" : "Connected";
+}
+
+function CamerasSettings() {
+ useTitle("Cameras");
+ const [addCameraOpen, setAddCameraOpen] = useState(false);
+ const [selectedCamera, setSelectedCamera] = useState<
+ types.Camera | types.FailedCamera | null
+ >(null);
+ const cameras = useCamerasAll();
+ const cameraEntries = Object.values(cameras.combinedData);
+
+ if (cameras.isLoading) {
+ return ;
+ }
+
+ return (
+
+
+ Cameras
+ }
+ variant="contained"
+ onClick={() => setAddCameraOpen(true)}
+ >
+ Add Camera
+
+
+
+
+
+ {cameraEntries.length === 0 ? (
+
+ ) : (
+ cameraEntries.map((camera) => (
+ setSelectedCamera(camera)}
+ sx={{
+ alignItems: "center",
+ display: "flex",
+ px: 2,
+ py: 1,
+ }}
+ >
+
+
+
+
+
+
+
+ ))
+ )}
+
+
+ setAddCameraOpen(false)}
+ />
+ {selectedCamera && (
+ setSelectedCamera(null)}
+ />
+ )}
+
+ );
+}
+
+export default CamerasSettings;
diff --git a/frontend/src/pages/settings/index.tsx b/frontend/src/pages/settings/index.tsx
index 5b5a1f7f9..e13a38557 100644
--- a/frontend/src/pages/settings/index.tsx
+++ b/frontend/src/pages/settings/index.tsx
@@ -5,6 +5,7 @@ import {
SettingsEdit,
Trigger,
UserMultiple,
+ Video,
} from "@carbon/icons-react";
import { ListItemButton } from "@mui/material";
import Avatar from "@mui/material/Avatar";
@@ -37,6 +38,15 @@ function Settings() {
disabled: false,
disabledReason: null,
},
+ {
+ name: "Cameras",
+ description: "Add and review cameras",
+ path: "/settings/cameras",
+ icon: ,
+ color: "indigo",
+ disabled: false,
+ disabledReason: null,
+ },
{
name: "User Management",
description: "Create, edit, and delete users",
diff --git a/viseron/components/webserver/api/v1/cameras.py b/viseron/components/webserver/api/v1/cameras.py
index 1688f69f9..f10dda82c 100644
--- a/viseron/components/webserver/api/v1/cameras.py
+++ b/viseron/components/webserver/api/v1/cameras.py
@@ -2,12 +2,65 @@
from __future__ import annotations
import logging
+from http import HTTPStatus
+from typing import Any
+
+import voluptuous as vol
+from ruamel.yaml import YAML, YAMLError
+from ruamel.yaml.comments import CommentedMap, CommentedSeq
from viseron.components.webserver.api.handlers import BaseAPIHandler
+from viseron.components.webserver.auth import Role
+from viseron.const import CONFIG_PATH
+from viseron.reload import reload_config
LOGGER = logging.getLogger(__name__)
+CAMERA_IDENTIFIER_SCHEMA = vol.All(str, vol.Match(r"^[A-Za-z0-9_]+$"))
+STREAM_FORMATS = ["rtsp", "mjpeg"]
+
+CAMERA_CONFIG_BODY_SCHEMA = {
+ vol.Required("name"): vol.All(str, vol.Length(min=1)),
+ vol.Required("host"): vol.All(str, vol.Length(min=1)),
+ vol.Optional("port", default=554): vol.All(
+ vol.Coerce(int), vol.Range(min=1, max=65535)
+ ),
+ vol.Required("path"): vol.All(str, vol.Length(min=1)),
+ vol.Optional("stream_format", default="rtsp"): vol.In(STREAM_FORMATS),
+ vol.Optional("username", default=None): vol.Maybe(str),
+ vol.Optional("password", default=None): vol.Maybe(str),
+ vol.Optional("substream_path", default=None): vol.Maybe(str),
+ vol.Optional("substream_port", default=554): vol.All(
+ vol.Coerce(int), vol.Range(min=1, max=65535)
+ ),
+ vol.Optional("substream_stream_format", default="rtsp"): vol.In(STREAM_FORMATS),
+ vol.Optional("fps", default=None): vol.Maybe(
+ vol.All(vol.Coerce(int), vol.Range(min=1))
+ ),
+ vol.Optional("idle_timeout", default=5): vol.All(vol.Coerce(int), vol.Range(min=0)),
+ vol.Optional("enable_recorder", default=True): bool,
+ vol.Optional("enable_nvr", default=True): bool,
+ vol.Optional("record_only", default=False): bool,
+ vol.Optional("width", default=None): vol.Maybe(
+ vol.All(vol.Coerce(int), vol.Range(min=1))
+ ),
+ vol.Optional("height", default=None): vol.Maybe(
+ vol.All(vol.Coerce(int), vol.Range(min=1))
+ ),
+ vol.Optional("video_filters", default=[]): [str],
+ vol.Optional("reload", default=True): bool,
+}
+
+
+def _optional_trimmed_string(value: str | None) -> str | None:
+ """Return stripped string or None."""
+ if value is None:
+ return None
+ value = value.strip()
+ return value or None
+
+
class CamerasAPIHandler(BaseAPIHandler):
"""Handler for API calls related to cameras."""
@@ -17,6 +70,40 @@ class CamerasAPIHandler(BaseAPIHandler):
"supported_methods": ["GET"],
"method": "get_cameras_endpoint",
},
+ {
+ "requires_role": [Role.ADMIN],
+ "path_pattern": r"/cameras",
+ "supported_methods": ["POST"],
+ "method": "post_camera_config",
+ "json_body_schema": vol.Schema(
+ {
+ vol.Required("identifier"): CAMERA_IDENTIFIER_SCHEMA,
+ **CAMERA_CONFIG_BODY_SCHEMA,
+ }
+ ),
+ },
+ {
+ "requires_role": [Role.ADMIN],
+ "path_pattern": r"/cameras/(?P[A-Za-z0-9_]+)/config",
+ "supported_methods": ["GET"],
+ "method": "get_camera_config",
+ },
+ {
+ "requires_role": [Role.ADMIN],
+ "path_pattern": r"/cameras/(?P[A-Za-z0-9_]+)/config",
+ "supported_methods": ["PUT"],
+ "method": "update_camera_config",
+ "json_body_schema": vol.Schema(CAMERA_CONFIG_BODY_SCHEMA),
+ },
+ {
+ "requires_role": [Role.ADMIN],
+ "path_pattern": r"/cameras/(?P[A-Za-z0-9_]+)",
+ "supported_methods": ["DELETE"],
+ "method": "delete_camera_config",
+ "json_body_schema": vol.Schema(
+ {vol.Optional("reload", default=True): bool}
+ ),
+ },
{
"path_pattern": r"/cameras/failed",
"supported_methods": ["GET"],
@@ -24,6 +111,380 @@ class CamerasAPIHandler(BaseAPIHandler):
},
]
+ @staticmethod
+ def _yaml() -> YAML:
+ """Return configured YAML parser."""
+ yaml = YAML(typ="rt")
+ yaml.preserve_quotes = True
+ return yaml
+
+ def _load_raw_config(self) -> tuple[dict[str, Any], str]:
+ """Load config.yaml and return parsed and raw config."""
+ with open(CONFIG_PATH, encoding="utf-8") as config_file:
+ raw_config = config_file.read()
+ config = self._yaml().load(raw_config) or {}
+ return config, raw_config
+
+ def _save_config(self, config: dict[str, Any]) -> None:
+ """Save config.yaml."""
+ with open(CONFIG_PATH, "w", encoding="utf-8") as config_file:
+ self._yaml().dump(config, config_file)
+
+ @staticmethod
+ def _camera_config_from_body(
+ body: dict[str, Any],
+ existing_camera_config: dict[str, Any] | None = None,
+ ) -> dict[str, Any]:
+ """Build ffmpeg camera config from request body."""
+ camera_config: dict[str, Any] = CommentedMap(existing_camera_config or {})
+ camera_config["name"] = body["name"].strip()
+ camera_config["host"] = body["host"].strip()
+ camera_config["port"] = body["port"]
+ camera_config["path"] = body["path"].strip()
+ camera_config["stream_format"] = body["stream_format"]
+
+ if username := _optional_trimmed_string(body["username"]):
+ camera_config["username"] = username
+ else:
+ camera_config.pop("username", None)
+
+ if password := _optional_trimmed_string(body["password"]):
+ camera_config["password"] = password
+ elif existing_camera_config and "password" in existing_camera_config:
+ camera_config["password"] = existing_camera_config["password"]
+ else:
+ camera_config.pop("password", None)
+
+ if substream_path := _optional_trimmed_string(body["substream_path"]):
+ camera_config["substream"] = {
+ "path": substream_path,
+ "port": body["substream_port"],
+ "stream_format": body["substream_stream_format"],
+ }
+ else:
+ camera_config.pop("substream", None)
+
+ if body["fps"] is not None:
+ camera_config["fps"] = body["fps"]
+ else:
+ camera_config.pop("fps", None)
+
+ if body["enable_recorder"]:
+ recorder_config = CommentedMap(camera_config.get("recorder") or {})
+ recorder_config["idle_timeout"] = body["idle_timeout"]
+ camera_config["recorder"] = recorder_config
+ else:
+ camera_config.pop("recorder", None)
+
+ if body["record_only"]:
+ camera_config["record_only"] = True
+ else:
+ camera_config.pop("record_only", None)
+
+ if body["width"] is not None:
+ camera_config["width"] = body["width"]
+ else:
+ camera_config.pop("width", None)
+
+ if body["height"] is not None:
+ camera_config["height"] = body["height"]
+ else:
+ camera_config.pop("height", None)
+
+ video_filters = [
+ video_filter.strip()
+ for video_filter in body["video_filters"]
+ if video_filter.strip()
+ ]
+ if video_filters:
+ camera_config["video_filters"] = CommentedSeq(video_filters)
+ else:
+ camera_config.pop("video_filters", None)
+
+ return camera_config
+
+ @staticmethod
+ def _camera_config_to_response(
+ camera_identifier: str,
+ camera_config: dict[str, Any],
+ nvr_config: dict[str, Any],
+ ) -> dict[str, Any]:
+ """Transform config.yaml camera data into API response."""
+ substream = camera_config.get("substream") or {}
+ recorder = camera_config.get("recorder") or {}
+ return {
+ "identifier": camera_identifier,
+ "name": str(camera_config.get("name") or camera_identifier),
+ "host": str(camera_config.get("host") or ""),
+ "port": int(camera_config.get("port") or 554),
+ "path": str(camera_config.get("path") or ""),
+ "stream_format": str(camera_config.get("stream_format") or "rtsp"),
+ "username": str(camera_config.get("username") or ""),
+ "password": "",
+ "password_set": "password" in camera_config,
+ "substream_path": str(substream.get("path") or ""),
+ "substream_port": int(substream.get("port") or 554),
+ "substream_stream_format": str(
+ substream.get("stream_format") or "rtsp"
+ ),
+ "fps": camera_config.get("fps"),
+ "idle_timeout": int(recorder.get("idle_timeout") or 5),
+ "enable_recorder": bool(recorder),
+ "enable_nvr": camera_identifier in nvr_config,
+ "record_only": bool(camera_config.get("record_only", False)),
+ "width": camera_config.get("width"),
+ "height": camera_config.get("height"),
+ "video_filters": list(camera_config.get("video_filters") or []),
+ "reload": True,
+ }
+
+ def _add_camera_to_config(self, config: dict[str, Any]) -> None:
+ """Add camera to config."""
+ identifier = self.json_body["identifier"].strip()
+ config.setdefault("ffmpeg", {})
+ config["ffmpeg"].setdefault("camera", {})
+
+ if identifier in config["ffmpeg"]["camera"]:
+ raise ValueError(f"Camera '{identifier}' already exists")
+
+ config["ffmpeg"]["camera"][identifier] = self._camera_config_from_body(
+ self.json_body
+ )
+
+ if self.json_body["enable_nvr"]:
+ config.setdefault("nvr", {})
+ config["nvr"][identifier] = {}
+
+ @staticmethod
+ def _remove_camera_from_access_config(
+ config: dict[str, Any], camera_identifier: str
+ ) -> None:
+ """Remove camera from camera access config."""
+ auth_config = (config.get("webserver") or {}).get("auth") or {}
+ for camera_group in (auth_config.get("camera_groups") or {}).values():
+ if "cameras" in camera_group:
+ camera_group["cameras"] = [
+ identifier
+ for identifier in camera_group["cameras"]
+ if identifier != camera_identifier
+ ]
+ for rule in auth_config.get("ldap_camera_access") or []:
+ if "cameras" in rule:
+ rule["cameras"] = [
+ identifier
+ for identifier in rule["cameras"]
+ if identifier != camera_identifier
+ ]
+
+ def _restore_config(self, raw_config: str) -> None:
+ """Restore original config."""
+ with open(CONFIG_PATH, "w", encoding="utf-8") as config_file:
+ config_file.write(raw_config)
+
+ async def post_camera_config(self) -> None:
+ """Add a camera to config.yaml."""
+
+ def _update_config() -> dict[str, Any]:
+ config, raw_config = self._load_raw_config()
+ self._add_camera_to_config(config)
+ self._save_config(config)
+
+ if not self.json_body["reload"]:
+ return {"message": "Camera configuration saved", "reloaded": False}
+
+ result = reload_config(self._vis)
+ if result.success:
+ return {
+ "message": "Camera configuration saved and reloaded",
+ "reloaded": True,
+ "restart_required": result.restart_required,
+ }
+
+ self._restore_config(raw_config)
+ reload_config(self._vis)
+ errors = [error.message for error in result.errors]
+ return {
+ "message": "Camera configuration failed validation",
+ "reloaded": False,
+ "restored": True,
+ "errors": errors,
+ }
+
+ try:
+ result = await self.run_in_executor(_update_config)
+ except ValueError as error:
+ self.response_error(HTTPStatus.CONFLICT, reason=str(error))
+ return
+ except (OSError, YAMLError) as error:
+ LOGGER.error("Failed to add camera configuration: %s", error, exc_info=True)
+ self.response_error(
+ HTTPStatus.INTERNAL_SERVER_ERROR,
+ reason=f"Failed to update configuration: {error}",
+ )
+ return
+
+ if result.get("errors"):
+ self.response_error(
+ HTTPStatus.BAD_REQUEST,
+ reason="; ".join(result["errors"]),
+ )
+ return
+
+ await self.response_success(response=result)
+
+ async def get_camera_config(self, camera_identifier: str) -> None:
+ """Return camera config from config.yaml."""
+
+ def _get_config() -> dict[str, Any]:
+ config, _raw_config = self._load_raw_config()
+ camera_config = (
+ config.get("ffmpeg", {}).get("camera", {}).get(camera_identifier)
+ )
+ if camera_config is None:
+ raise ValueError(f"Camera '{camera_identifier}' not found")
+ return self._camera_config_to_response(
+ camera_identifier, camera_config, config.get("nvr") or {}
+ )
+
+ try:
+ camera_config = await self.run_in_executor(_get_config)
+ except ValueError as error:
+ self.response_error(HTTPStatus.NOT_FOUND, reason=str(error))
+ return
+ except (OSError, YAMLError) as error:
+ LOGGER.error("Failed to load camera configuration: %s", error, exc_info=True)
+ self.response_error(
+ HTTPStatus.INTERNAL_SERVER_ERROR,
+ reason=f"Failed to load configuration: {error}",
+ )
+ return
+
+ await self.response_success(response={"config": camera_config})
+
+ async def update_camera_config(self, camera_identifier: str) -> None:
+ """Update camera config in config.yaml."""
+
+ def _update_config() -> dict[str, Any]:
+ config, raw_config = self._load_raw_config()
+ cameras_config = config.get("ffmpeg", {}).get("camera", {})
+ if camera_identifier not in cameras_config:
+ raise ValueError(f"Camera '{camera_identifier}' not found")
+
+ cameras_config[camera_identifier] = self._camera_config_from_body(
+ self.json_body, cameras_config[camera_identifier]
+ )
+
+ if self.json_body["enable_nvr"]:
+ config.setdefault("nvr", {})
+ config["nvr"].setdefault(camera_identifier, {})
+ else:
+ (config.get("nvr") or {}).pop(camera_identifier, None)
+
+ self._save_config(config)
+ if not self.json_body["reload"]:
+ return {"message": "Camera configuration saved", "reloaded": False}
+
+ result = reload_config(self._vis)
+ if result.success:
+ return {
+ "message": "Camera configuration saved and reloaded",
+ "reloaded": True,
+ "restart_required": result.restart_required,
+ }
+
+ self._restore_config(raw_config)
+ reload_config(self._vis)
+ errors = [error.message for error in result.errors]
+ return {
+ "message": "Camera configuration failed validation",
+ "reloaded": False,
+ "restored": True,
+ "errors": errors,
+ }
+
+ try:
+ result = await self.run_in_executor(_update_config)
+ except ValueError as error:
+ self.response_error(HTTPStatus.NOT_FOUND, reason=str(error))
+ return
+ except (OSError, YAMLError) as error:
+ LOGGER.error(
+ "Failed to update camera configuration: %s", error, exc_info=True
+ )
+ self.response_error(
+ HTTPStatus.INTERNAL_SERVER_ERROR,
+ reason=f"Failed to update configuration: {error}",
+ )
+ return
+
+ if result.get("errors"):
+ self.response_error(
+ HTTPStatus.BAD_REQUEST,
+ reason="; ".join(result["errors"]),
+ )
+ return
+
+ await self.response_success(response=result)
+
+ async def delete_camera_config(self, camera_identifier: str) -> None:
+ """Delete camera from config.yaml."""
+
+ def _delete_config() -> dict[str, Any]:
+ config, raw_config = self._load_raw_config()
+ cameras_config = config.get("ffmpeg", {}).get("camera", {})
+ if camera_identifier not in cameras_config:
+ raise ValueError(f"Camera '{camera_identifier}' not found")
+
+ cameras_config.pop(camera_identifier)
+ (config.get("nvr") or {}).pop(camera_identifier, None)
+ self._remove_camera_from_access_config(config, camera_identifier)
+ self._save_config(config)
+
+ if not self.json_body["reload"]:
+ return {"message": "Camera configuration deleted", "reloaded": False}
+
+ result = reload_config(self._vis)
+ if result.success:
+ return {
+ "message": "Camera configuration deleted and reloaded",
+ "reloaded": True,
+ "restart_required": result.restart_required,
+ }
+
+ self._restore_config(raw_config)
+ reload_config(self._vis)
+ errors = [error.message for error in result.errors]
+ return {
+ "message": "Camera configuration failed validation",
+ "reloaded": False,
+ "restored": True,
+ "errors": errors,
+ }
+
+ try:
+ result = await self.run_in_executor(_delete_config)
+ except ValueError as error:
+ self.response_error(HTTPStatus.NOT_FOUND, reason=str(error))
+ return
+ except (OSError, YAMLError) as error:
+ LOGGER.error(
+ "Failed to delete camera configuration: %s", error, exc_info=True
+ )
+ self.response_error(
+ HTTPStatus.INTERNAL_SERVER_ERROR,
+ reason=f"Failed to update configuration: {error}",
+ )
+ return
+
+ if result.get("errors"):
+ self.response_error(
+ HTTPStatus.BAD_REQUEST,
+ reason="; ".join(result["errors"]),
+ )
+ return
+
+ await self.response_success(response=result)
+
async def get_cameras_endpoint(self) -> None:
"""Return cameras."""
await self.response_success(response=self._get_cameras())