Feature: camera debug/config enhancements (#6920)

* Add functionality to update YAML config file with PUT request in HTTP endpoint

* Refactor copying of text to clipboard with Clipboard API and fallback to document.execCommand('copy') in CameraMap.jsx file

* Update YAML file from URL query parameters in frigate/http.py and add functionality to save motion masks, zones, and object masks in CameraMap.jsx

* formatting

* fix merging fuckup

* Refactor camera zone coordinate saving to use single query parameter per zone in CameraMap.jsx

* remove unnecessary print statements in util.py

* Refactor update_yaml_file function to separate the logic for updating YAML data into a new function update_yaml().

* fix merge errors

* Refactor code to improve error handling and add dependencies to useEffect hooks
This commit is contained in:
Sergey Krashevich 2023-07-06 21:54:26 +03:00 committed by GitHub
parent 22cc2712a6
commit f48dd8c1ab
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 236 additions and 11 deletions

View File

@ -30,7 +30,7 @@ from playhouse.sqliteq import SqliteQueueDatabase
from tzlocal import get_localzone_name from tzlocal import get_localzone_name
from frigate.config import FrigateConfig from frigate.config import FrigateConfig
from frigate.const import CLIPS_DIR, MAX_SEGMENT_DURATION, RECORD_DIR from frigate.const import CLIPS_DIR, CONFIG_DIR, MAX_SEGMENT_DURATION, RECORD_DIR
from frigate.events.external import ExternalEventProcessor from frigate.events.external import ExternalEventProcessor
from frigate.models import Event, Recordings, Timeline from frigate.models import Event, Recordings, Timeline
from frigate.object_processing import TrackedObject from frigate.object_processing import TrackedObject
@ -39,7 +39,11 @@ from frigate.ptz import OnvifController
from frigate.record.export import PlaybackFactorEnum, RecordingExporter from frigate.record.export import PlaybackFactorEnum, RecordingExporter
from frigate.stats import stats_snapshot from frigate.stats import stats_snapshot
from frigate.storage import StorageMaintainer from frigate.storage import StorageMaintainer
from frigate.util.builtin import clean_camera_user_pass, get_tz_modifiers from frigate.util.builtin import (
clean_camera_user_pass,
get_tz_modifiers,
update_yaml_from_url,
)
from frigate.util.services import ffprobe_stream, restart_frigate, vainfo_hwaccel from frigate.util.services import ffprobe_stream, restart_frigate, vainfo_hwaccel
from frigate.version import VERSION from frigate.version import VERSION
@ -1026,6 +1030,48 @@ def config_save():
return "Config successfully saved.", 200 return "Config successfully saved.", 200
@bp.route("/config/set", methods=["PUT"])
def config_set():
config_file = os.environ.get("CONFIG_FILE", f"{CONFIG_DIR}/config.yml")
# Check if we can use .yaml instead of .yml
config_file_yaml = config_file.replace(".yml", ".yaml")
if os.path.isfile(config_file_yaml):
config_file = config_file_yaml
with open(config_file, "r") as f:
old_raw_config = f.read()
f.close()
try:
update_yaml_from_url(config_file, request.url)
with open(config_file, "r") as f:
new_raw_config = f.read()
f.close()
# Validate the config schema
try:
FrigateConfig.parse_raw(new_raw_config)
except Exception:
with open(config_file, "w") as f:
f.write(old_raw_config)
f.close()
return make_response(
jsonify(
{
"success": False,
"message": f"\nConfig Error:\n\n{str(traceback.format_exc())}",
}
),
400,
)
except Exception as e:
logging.error(f"Error updating config: {e}")
return "Error updating config", 500
return "Config successfully updated", 200
@bp.route("/config/schema.json") @bp.route("/config/schema.json")
def config_schema(): def config_schema():
return current_app.response_class( return current_app.response_class(

View File

@ -14,10 +14,12 @@ from collections.abc import Mapping
from queue import Empty, Full from queue import Empty, Full
from typing import Any, Tuple from typing import Any, Tuple
import numpy as np
import pytz import pytz
import yaml import yaml
from faster_fifo import DEFAULT_CIRCULAR_BUFFER_SIZE, DEFAULT_TIMEOUT from faster_fifo import DEFAULT_CIRCULAR_BUFFER_SIZE, DEFAULT_TIMEOUT
from faster_fifo import Queue as FFQueue from faster_fifo import Queue as FFQueue
from ruamel.yaml import YAML
from frigate.const import REGEX_HTTP_CAMERA_USER_PASS, REGEX_RTSP_CAMERA_USER_PASS from frigate.const import REGEX_HTTP_CAMERA_USER_PASS, REGEX_RTSP_CAMERA_USER_PASS
@ -224,3 +226,76 @@ def to_relative_box(
(box[2] - box[0]) / width, # w (box[2] - box[0]) / width, # w
(box[3] - box[1]) / height, # h (box[3] - box[1]) / height, # h
) )
def create_mask(frame_shape, mask):
mask_img = np.zeros(frame_shape, np.uint8)
mask_img[:] = 255
def update_yaml_from_url(file_path, url):
parsed_url = urllib.parse.urlparse(url)
query_string = urllib.parse.parse_qs(parsed_url.query, keep_blank_values=True)
for key_path_str, new_value_list in query_string.items():
key_path = key_path_str.split(".")
for i in range(len(key_path)):
try:
index = int(key_path[i])
key_path[i] = (key_path[i - 1], index)
key_path.pop(i - 1)
except ValueError:
pass
new_value = new_value_list[0]
update_yaml_file(file_path, key_path, new_value)
def update_yaml_file(file_path, key_path, new_value):
yaml = YAML()
with open(file_path, "r") as f:
data = yaml.load(f)
data = update_yaml(data, key_path, new_value)
with open(file_path, "w") as f:
yaml.dump(data, f)
def update_yaml(data, key_path, new_value):
temp = data
for key in key_path[:-1]:
if isinstance(key, tuple):
if key[0] not in temp:
temp[key[0]] = [{}] * max(1, key[1] + 1)
elif len(temp[key[0]]) <= key[1]:
temp[key[0]] += [{}] * (key[1] - len(temp[key[0]]) + 1)
temp = temp[key[0]][key[1]]
else:
if key not in temp:
temp[key] = {}
temp = temp[key]
last_key = key_path[-1]
if new_value == "":
if isinstance(last_key, tuple):
del temp[last_key[0]][last_key[1]]
else:
del temp[last_key]
else:
if isinstance(last_key, tuple):
if last_key[0] not in temp:
temp[last_key[0]] = [{}] * max(1, last_key[1] + 1)
elif len(temp[last_key[0]]) <= last_key[1]:
temp[last_key[0]] += [{}] * (last_key[1] - len(temp[last_key[0]]) + 1)
temp[last_key[0]][last_key[1]] = new_value
else:
if (
last_key in temp
and isinstance(temp[last_key], dict)
and isinstance(new_value, dict)
):
temp[last_key].update(new_value)
else:
temp[last_key] = new_value
return data

View File

@ -15,6 +15,7 @@ pydantic == 1.10.*
git+https://github.com/fbcotter/py3nvml#egg=py3nvml git+https://github.com/fbcotter/py3nvml#egg=py3nvml
PyYAML == 6.0 PyYAML == 6.0
pytz == 2023.3 pytz == 2023.3
ruamel.yaml == 0.17.*
tzlocal == 5.0.* tzlocal == 5.0.*
types-PyYAML == 6.0.* types-PyYAML == 6.0.*
requests == 2.31.* requests == 2.31.*

View File

@ -7,7 +7,7 @@ import { useResizeObserver } from '../hooks';
import { useCallback, useMemo, useRef, useState } from 'preact/hooks'; import { useCallback, useMemo, useRef, useState } from 'preact/hooks';
import { useApiHost } from '../api'; import { useApiHost } from '../api';
import useSWR from 'swr'; import useSWR from 'swr';
import axios from 'axios';
export default function CameraMasks({ camera }) { export default function CameraMasks({ camera }) {
const { data: config } = useSWR('config'); const { data: config } = useSWR('config');
const apiHost = useApiHost(); const apiHost = useApiHost();
@ -95,12 +95,53 @@ export default function CameraMasks({ camera }) {
[motionMaskPoints, setMotionMaskPoints] [motionMaskPoints, setMotionMaskPoints]
); );
const handleCopyMotionMasks = useCallback(async () => { const handleCopyMotionMasks = useCallback(() => {
await window.navigator.clipboard.writeText(` motion: const textToCopy = ` motion:
mask: mask:
${motionMaskPoints.map((mask) => ` - ${polylinePointsToPolyline(mask)}`).join('\n')}`); ${motionMaskPoints.map((mask) => ` - ${polylinePointsToPolyline(mask)}`).join('\n')}`;
if (window.navigator.clipboard && window.navigator.clipboard.writeText) {
// Use Clipboard API if available
window.navigator.clipboard.writeText(textToCopy).catch((err) => {
throw new Error('Failed to copy text: ', err);
});
} else {
// Fallback to document.execCommand('copy')
const textarea = document.createElement('textarea');
textarea.value = textToCopy;
document.body.appendChild(textarea);
textarea.select();
try {
const successful = document.execCommand('copy');
if (!successful) {
throw new Error('Failed to copy text');
}
} catch (err) {
throw new Error('Failed to copy text: ', err);
}
document.body.removeChild(textarea);
}
}, [motionMaskPoints]); }, [motionMaskPoints]);
const handleSaveMotionMasks = useCallback(async () => {
try {
const queryParameters = motionMaskPoints
.map((mask, index) => `cameras.${camera}.motion.mask.${index}=${polylinePointsToPolyline(mask)}`)
.join('&');
const endpoint = `config/set?${queryParameters}`;
const response = await axios.put(endpoint);
if (response.status === 200) {
// handle successful response
}
} catch (error) {
// handle error
//console.error(error);
}
}, [camera, motionMaskPoints]);
// Zone methods // Zone methods
const handleEditZone = useCallback( const handleEditZone = useCallback(
(key) => { (key) => {
@ -127,15 +168,53 @@ ${motionMaskPoints.map((mask) => ` - ${polylinePointsToPolyline(mask)}`).jo
); );
const handleCopyZones = useCallback(async () => { const handleCopyZones = useCallback(async () => {
await window.navigator.clipboard.writeText(` zones: const textToCopy = ` zones:
${Object.keys(zonePoints) ${Object.keys(zonePoints)
.map( .map(
(zoneName) => ` ${zoneName}: (zoneName) => ` ${zoneName}:
coordinates: ${polylinePointsToPolyline(zonePoints[zoneName])}` coordinates: ${polylinePointsToPolyline(zonePoints[zoneName])}`).join('\n')}`;
)
.join('\n')}`); if (window.navigator.clipboard && window.navigator.clipboard.writeText) {
// Use Clipboard API if available
window.navigator.clipboard.writeText(textToCopy).catch((err) => {
throw new Error('Failed to copy text: ', err);
});
} else {
// Fallback to document.execCommand('copy')
const textarea = document.createElement('textarea');
textarea.value = textToCopy;
document.body.appendChild(textarea);
textarea.select();
try {
const successful = document.execCommand('copy');
if (!successful) {
throw new Error('Failed to copy text');
}
} catch (err) {
throw new Error('Failed to copy text: ', err);
}
document.body.removeChild(textarea);
}
}, [zonePoints]); }, [zonePoints]);
const handleSaveZones = useCallback(async () => {
try {
const queryParameters = Object.keys(zonePoints)
.map((zoneName) => `cameras.${camera}.zones.${zoneName}.coordinates=${polylinePointsToPolyline(zonePoints[zoneName])}`)
.join('&');
const endpoint = `config/set?${queryParameters}`;
const response = await axios.put(endpoint);
if (response.status === 200) {
// handle successful response
}
} catch (error) {
// handle error
//console.error(error);
}
}, [camera, zonePoints]);
// Object methods // Object methods
const handleEditObjectMask = useCallback( const handleEditObjectMask = useCallback(
(key, subkey) => { (key, subkey) => {
@ -175,6 +254,23 @@ ${Object.keys(objectMaskPoints)
.join('\n')}`); .join('\n')}`);
}, [objectMaskPoints]); }, [objectMaskPoints]);
const handleSaveObjectMasks = useCallback(async () => {
try {
const queryParameters = Object.keys(objectMaskPoints)
.filter((objectName) => objectMaskPoints[objectName].length > 0)
.map((objectName, index) => `cameras.${camera}.objects.filters.${objectName}.mask.${index}=${polylinePointsToPolyline(objectMaskPoints[objectName])}`)
.join('&');
const endpoint = `config/set?${queryParameters}`;
const response = await axios.put(endpoint);
if (response.status === 200) {
// handle successful response
}
} catch (error) {
// handle error
//console.error(error);
}
}, [camera, objectMaskPoints]);
const handleAddToObjectMask = useCallback( const handleAddToObjectMask = useCallback(
(key) => { (key) => {
const newObjectMaskPoints = { ...objectMaskPoints, [key]: [...objectMaskPoints[key], []] }; const newObjectMaskPoints = { ...objectMaskPoints, [key]: [...objectMaskPoints[key], []] };
@ -246,6 +342,7 @@ ${Object.keys(objectMaskPoints)
editing={editing} editing={editing}
title="Motion masks" title="Motion masks"
onCopy={handleCopyMotionMasks} onCopy={handleCopyMotionMasks}
onSave={handleSaveMotionMasks}
onCreate={handleAddMask} onCreate={handleAddMask}
onEdit={handleEditMask} onEdit={handleEditMask}
onRemove={handleRemoveMask} onRemove={handleRemoveMask}
@ -258,6 +355,7 @@ ${Object.keys(objectMaskPoints)
editing={editing} editing={editing}
title="Zones" title="Zones"
onCopy={handleCopyZones} onCopy={handleCopyZones}
onSave={handleSaveZones}
onCreate={handleAddZone} onCreate={handleAddZone}
onEdit={handleEditZone} onEdit={handleEditZone}
onRemove={handleRemoveZone} onRemove={handleRemoveZone}
@ -272,6 +370,7 @@ ${Object.keys(objectMaskPoints)
title="Object masks" title="Object masks"
onAdd={handleAddToObjectMask} onAdd={handleAddToObjectMask}
onCopy={handleCopyObjectMasks} onCopy={handleCopyObjectMasks}
onSave={handleSaveObjectMasks}
onCreate={handleAddObjectMask} onCreate={handleAddObjectMask}
onEdit={handleEditObjectMask} onEdit={handleEditObjectMask}
onRemove={handleRemoveObjectMask} onRemove={handleRemoveObjectMask}
@ -407,6 +506,7 @@ function MaskValues({
title, title,
onAdd, onAdd,
onCopy, onCopy,
onSave,
onCreate, onCreate,
onEdit, onEdit,
onRemove, onRemove,
@ -455,6 +555,8 @@ function MaskValues({
[onAdd] [onAdd]
); );
return ( return (
<div className="overflow-hidden" onMouseOver={handleMousein} onMouseOut={handleMouseout}> <div className="overflow-hidden" onMouseOver={handleMousein} onMouseOut={handleMouseout}>
<div className="flex space-x-4"> <div className="flex space-x-4">
@ -463,6 +565,7 @@ function MaskValues({
</Heading> </Heading>
<Button onClick={onCopy}>Copy</Button> <Button onClick={onCopy}>Copy</Button>
<Button onClick={onCreate}>Add</Button> <Button onClick={onCreate}>Add</Button>
<Button onClick={onSave}>Save</Button>
</div> </div>
<pre className="relative overflow-auto font-mono text-gray-900 dark:text-gray-100 rounded bg-gray-100 dark:bg-gray-800 p-2"> <pre className="relative overflow-auto font-mono text-gray-900 dark:text-gray-100 rounded bg-gray-100 dark:bg-gray-800 p-2">
{yamlPrefix} {yamlPrefix}