From f48dd8c1abb8e7c180fdc03cfc5acea16d96315d Mon Sep 17 00:00:00 2001 From: Sergey Krashevich Date: Thu, 6 Jul 2023 21:54:26 +0300 Subject: [PATCH] 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 --- frigate/http.py | 50 ++++++++++++++- frigate/util/builtin.py | 75 ++++++++++++++++++++++ requirements-wheels.txt | 1 + web/src/routes/CameraMap.jsx | 121 ++++++++++++++++++++++++++++++++--- 4 files changed, 236 insertions(+), 11 deletions(-) diff --git a/frigate/http.py b/frigate/http.py index db3ccb8df..ba62af047 100644 --- a/frigate/http.py +++ b/frigate/http.py @@ -30,7 +30,7 @@ from playhouse.sqliteq import SqliteQueueDatabase from tzlocal import get_localzone_name 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.models import Event, Recordings, Timeline from frigate.object_processing import TrackedObject @@ -39,7 +39,11 @@ from frigate.ptz import OnvifController from frigate.record.export import PlaybackFactorEnum, RecordingExporter from frigate.stats import stats_snapshot 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.version import VERSION @@ -1026,6 +1030,48 @@ def config_save(): 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") def config_schema(): return current_app.response_class( diff --git a/frigate/util/builtin.py b/frigate/util/builtin.py index 900764a23..f55ea5e37 100644 --- a/frigate/util/builtin.py +++ b/frigate/util/builtin.py @@ -14,10 +14,12 @@ from collections.abc import Mapping from queue import Empty, Full from typing import Any, Tuple +import numpy as np import pytz import yaml from faster_fifo import DEFAULT_CIRCULAR_BUFFER_SIZE, DEFAULT_TIMEOUT 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 @@ -224,3 +226,76 @@ def to_relative_box( (box[2] - box[0]) / width, # w (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 diff --git a/requirements-wheels.txt b/requirements-wheels.txt index 778737a92..3232e8f31 100644 --- a/requirements-wheels.txt +++ b/requirements-wheels.txt @@ -15,6 +15,7 @@ pydantic == 1.10.* git+https://github.com/fbcotter/py3nvml#egg=py3nvml PyYAML == 6.0 pytz == 2023.3 +ruamel.yaml == 0.17.* tzlocal == 5.0.* types-PyYAML == 6.0.* requests == 2.31.* diff --git a/web/src/routes/CameraMap.jsx b/web/src/routes/CameraMap.jsx index ca77ec56e..2dce5597d 100644 --- a/web/src/routes/CameraMap.jsx +++ b/web/src/routes/CameraMap.jsx @@ -7,7 +7,7 @@ import { useResizeObserver } from '../hooks'; import { useCallback, useMemo, useRef, useState } from 'preact/hooks'; import { useApiHost } from '../api'; import useSWR from 'swr'; - +import axios from 'axios'; export default function CameraMasks({ camera }) { const { data: config } = useSWR('config'); const apiHost = useApiHost(); @@ -95,12 +95,53 @@ export default function CameraMasks({ camera }) { [motionMaskPoints, setMotionMaskPoints] ); - const handleCopyMotionMasks = useCallback(async () => { - await window.navigator.clipboard.writeText(` motion: - mask: -${motionMaskPoints.map((mask) => ` - ${polylinePointsToPolyline(mask)}`).join('\n')}`); + const handleCopyMotionMasks = useCallback(() => { + const textToCopy = ` motion: + mask: + ${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]); + 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 const handleEditZone = useCallback( (key) => { @@ -127,15 +168,53 @@ ${motionMaskPoints.map((mask) => ` - ${polylinePointsToPolyline(mask)}`).jo ); const handleCopyZones = useCallback(async () => { - await window.navigator.clipboard.writeText(` zones: + const textToCopy = ` zones: ${Object.keys(zonePoints) .map( (zoneName) => ` ${zoneName}: - coordinates: ${polylinePointsToPolyline(zonePoints[zoneName])}` - ) - .join('\n')}`); + coordinates: ${polylinePointsToPolyline(zonePoints[zoneName])}`).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]); + 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 const handleEditObjectMask = useCallback( (key, subkey) => { @@ -175,6 +254,23 @@ ${Object.keys(objectMaskPoints) .join('\n')}`); }, [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( (key) => { const newObjectMaskPoints = { ...objectMaskPoints, [key]: [...objectMaskPoints[key], []] }; @@ -246,6 +342,7 @@ ${Object.keys(objectMaskPoints) editing={editing} title="Motion masks" onCopy={handleCopyMotionMasks} + onSave={handleSaveMotionMasks} onCreate={handleAddMask} onEdit={handleEditMask} onRemove={handleRemoveMask} @@ -258,6 +355,7 @@ ${Object.keys(objectMaskPoints) editing={editing} title="Zones" onCopy={handleCopyZones} + onSave={handleSaveZones} onCreate={handleAddZone} onEdit={handleEditZone} onRemove={handleRemoveZone} @@ -272,6 +370,7 @@ ${Object.keys(objectMaskPoints) title="Object masks" onAdd={handleAddToObjectMask} onCopy={handleCopyObjectMasks} + onSave={handleSaveObjectMasks} onCreate={handleAddObjectMask} onEdit={handleEditObjectMask} onRemove={handleRemoveObjectMask} @@ -407,6 +506,7 @@ function MaskValues({ title, onAdd, onCopy, + onSave, onCreate, onEdit, onRemove, @@ -455,6 +555,8 @@ function MaskValues({ [onAdd] ); + + return (
@@ -463,6 +565,7 @@ function MaskValues({ +
         {yamlPrefix}