mirror of
https://github.com/blakeblackshear/frigate.git
synced 2024-11-21 19:07:46 +01:00
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:
parent
22cc2712a6
commit
f48dd8c1ab
@ -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(
|
||||||
|
@ -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
|
||||||
|
@ -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.*
|
||||||
|
@ -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}
|
||||||
|
Loading…
Reference in New Issue
Block a user