mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-09-05 17:51:36 +02:00
feat: reload go2rtc config
This commit is contained in:
parent
78e431d5e3
commit
85741c02e4
@ -32,6 +32,7 @@ from frigate.config import FrigateConfig
|
|||||||
from frigate.config.camera.updater import (
|
from frigate.config.camera.updater import (
|
||||||
CameraConfigUpdateEnum,
|
CameraConfigUpdateEnum,
|
||||||
CameraConfigUpdateTopic,
|
CameraConfigUpdateTopic,
|
||||||
|
reload_go2rtc,
|
||||||
)
|
)
|
||||||
from frigate.models import Event, Timeline
|
from frigate.models import Event, Timeline
|
||||||
from frigate.stats.prometheus import get_metrics, update_metrics
|
from frigate.stats.prometheus import get_metrics, update_metrics
|
||||||
@ -424,15 +425,38 @@ def config_set(request: Request, body: AppConfigSetBody):
|
|||||||
|
|
||||||
if field == "add":
|
if field == "add":
|
||||||
settings = config.cameras[camera]
|
settings = config.cameras[camera]
|
||||||
|
if camera in old_config.cameras:
|
||||||
|
request.app.config_publisher.publish_update(
|
||||||
|
CameraConfigUpdateTopic(CameraConfigUpdateEnum.edit, camera),
|
||||||
|
settings,
|
||||||
|
)
|
||||||
|
continue_publish = False
|
||||||
|
else:
|
||||||
|
continue_publish = True
|
||||||
|
elif field == "edit":
|
||||||
|
settings = config.cameras[camera]
|
||||||
|
if camera in old_config.cameras:
|
||||||
|
logger.info(f"Publishing edit event for camera {camera}")
|
||||||
|
continue_publish = True
|
||||||
|
else:
|
||||||
|
request.app.config_publisher.publish_update(
|
||||||
|
CameraConfigUpdateTopic(CameraConfigUpdateEnum.add, camera),
|
||||||
|
settings,
|
||||||
|
)
|
||||||
|
continue_publish = False
|
||||||
elif field == "remove":
|
elif field == "remove":
|
||||||
settings = old_config.cameras[camera]
|
settings = old_config.cameras[camera]
|
||||||
|
continue_publish = True
|
||||||
else:
|
else:
|
||||||
settings = config.get_nested_object(body.update_topic)
|
settings = config.get_nested_object(body.update_topic)
|
||||||
|
continue_publish = True
|
||||||
|
|
||||||
request.app.config_publisher.publish_update(
|
if continue_publish:
|
||||||
CameraConfigUpdateTopic(CameraConfigUpdateEnum[field], camera),
|
logger.info(f"Publishing {field} event for camera {camera}")
|
||||||
settings,
|
request.app.config_publisher.publish_update(
|
||||||
)
|
CameraConfigUpdateTopic(CameraConfigUpdateEnum[field], camera),
|
||||||
|
settings,
|
||||||
|
)
|
||||||
|
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content=(
|
content=(
|
||||||
@ -530,6 +554,23 @@ def nvinfo():
|
|||||||
return JSONResponse(content=get_nvidia_driver_info())
|
return JSONResponse(content=get_nvidia_driver_info())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/go2rtc/reload", dependencies=[Depends(require_role(["admin"]))])
|
||||||
|
def reload_go2rtc_endpoint():
|
||||||
|
"""reload go2rtc service"""
|
||||||
|
try:
|
||||||
|
reload_go2rtc()
|
||||||
|
return JSONResponse(
|
||||||
|
content={"success": True, "message": "go2rtc reloaded"},
|
||||||
|
status_code=200,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"reload go2rtc failed: {e}")
|
||||||
|
return JSONResponse(
|
||||||
|
content={"success": False, "message": f"{str(e)}"},
|
||||||
|
status_code=500,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/logs/{service}", tags=[Tags.logs])
|
@router.get("/logs/{service}", tags=[Tags.logs])
|
||||||
async def logs(
|
async def logs(
|
||||||
service: str = Path(enum=["frigate", "nginx", "go2rtc"]),
|
service: str = Path(enum=["frigate", "nginx", "go2rtc"]),
|
||||||
|
@ -50,6 +50,7 @@ class CameraMaintainer(threading.Thread):
|
|||||||
[
|
[
|
||||||
CameraConfigUpdateEnum.add,
|
CameraConfigUpdateEnum.add,
|
||||||
CameraConfigUpdateEnum.remove,
|
CameraConfigUpdateEnum.remove,
|
||||||
|
CameraConfigUpdateEnum.edit,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
self.shm_count = self.__calculate_shm_frame_count()
|
self.shm_count = self.__calculate_shm_frame_count()
|
||||||
@ -194,6 +195,21 @@ class CameraMaintainer(threading.Thread):
|
|||||||
for update_type, updated_cameras in updates.items():
|
for update_type, updated_cameras in updates.items():
|
||||||
if update_type == CameraConfigUpdateEnum.add.name:
|
if update_type == CameraConfigUpdateEnum.add.name:
|
||||||
for camera in updated_cameras:
|
for camera in updated_cameras:
|
||||||
|
self.__start_camera_processor(
|
||||||
|
camera,
|
||||||
|
self.update_subscriber.camera_configs[camera],
|
||||||
|
runtime=True,
|
||||||
|
)
|
||||||
|
self.__start_camera_capture(
|
||||||
|
camera,
|
||||||
|
self.update_subscriber.camera_configs[camera],
|
||||||
|
runtime=True,
|
||||||
|
)
|
||||||
|
elif update_type == CameraConfigUpdateEnum.edit.name:
|
||||||
|
for camera in updated_cameras:
|
||||||
|
self.__stop_camera_capture_process(camera)
|
||||||
|
self.__stop_camera_process(camera)
|
||||||
|
|
||||||
self.__start_camera_processor(
|
self.__start_camera_processor(
|
||||||
camera,
|
camera,
|
||||||
self.update_subscriber.camera_configs[camera],
|
self.update_subscriber.camera_configs[camera],
|
||||||
@ -205,8 +221,9 @@ class CameraMaintainer(threading.Thread):
|
|||||||
runtime=True,
|
runtime=True,
|
||||||
)
|
)
|
||||||
elif update_type == CameraConfigUpdateEnum.remove.name:
|
elif update_type == CameraConfigUpdateEnum.remove.name:
|
||||||
self.__stop_camera_capture_process(camera)
|
for camera in updated_cameras:
|
||||||
self.__stop_camera_process(camera)
|
self.__stop_camera_capture_process(camera)
|
||||||
|
self.__stop_camera_process(camera)
|
||||||
|
|
||||||
# ensure the capture processes are done
|
# ensure the capture processes are done
|
||||||
for camera in self.camera_processes.keys():
|
for camera in self.camera_processes.keys():
|
||||||
|
@ -1,11 +1,20 @@
|
|||||||
"""Convenience classes for updating configurations dynamically."""
|
"""Convenience classes for updating configurations dynamically."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Any
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
from frigate.comms.config_updater import ConfigPublisher, ConfigSubscriber
|
from frigate.comms.config_updater import ConfigPublisher, ConfigSubscriber
|
||||||
from frigate.config import CameraConfig, FrigateConfig
|
from frigate.config import CameraConfig, FrigateConfig
|
||||||
|
from frigate.util.builtin import update_yaml_file_bulk
|
||||||
|
from frigate.util.config import find_config_file
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class CameraConfigUpdateEnum(str, Enum):
|
class CameraConfigUpdateEnum(str, Enum):
|
||||||
@ -16,6 +25,7 @@ class CameraConfigUpdateEnum(str, Enum):
|
|||||||
audio_transcription = "audio_transcription"
|
audio_transcription = "audio_transcription"
|
||||||
birdseye = "birdseye"
|
birdseye = "birdseye"
|
||||||
detect = "detect"
|
detect = "detect"
|
||||||
|
edit = "edit" # for editing an existing camera
|
||||||
enabled = "enabled"
|
enabled = "enabled"
|
||||||
motion = "motion" # includes motion and motion masks
|
motion = "motion" # includes motion and motion masks
|
||||||
notifications = "notifications"
|
notifications = "notifications"
|
||||||
@ -76,37 +86,61 @@ class CameraConfigUpdateSubscriber:
|
|||||||
self, camera: str, update_type: CameraConfigUpdateEnum, updated_config: Any
|
self, camera: str, update_type: CameraConfigUpdateEnum, updated_config: Any
|
||||||
) -> None:
|
) -> None:
|
||||||
if update_type == CameraConfigUpdateEnum.add:
|
if update_type == CameraConfigUpdateEnum.add:
|
||||||
|
"""Add a new camera and handle go2rtc streams if needed"""
|
||||||
self.config.cameras[camera] = updated_config
|
self.config.cameras[camera] = updated_config
|
||||||
self.camera_configs[camera] = updated_config
|
self.camera_configs[camera] = updated_config
|
||||||
|
|
||||||
|
# Handle potential go2rtc streams for new camera
|
||||||
|
if hasattr(updated_config, "ffmpeg") and hasattr(
|
||||||
|
updated_config.ffmpeg, "inputs"
|
||||||
|
):
|
||||||
|
streams = self._extract_stream_names(updated_config)
|
||||||
|
if (
|
||||||
|
streams
|
||||||
|
and hasattr(self.config, "go2rtc")
|
||||||
|
and hasattr(self.config.go2rtc, "streams")
|
||||||
|
):
|
||||||
|
go2rtc_updated = False
|
||||||
|
for stream_name in streams:
|
||||||
|
logger.info(
|
||||||
|
f"New go2rtc stream detected for camera {camera}: {stream_name}"
|
||||||
|
)
|
||||||
|
go2rtc_updated = True
|
||||||
|
|
||||||
|
if go2rtc_updated:
|
||||||
|
self.reload_go2rtc()
|
||||||
|
|
||||||
|
return
|
||||||
|
elif update_type == CameraConfigUpdateEnum.edit:
|
||||||
|
"""Update camera configuration without stopping processes"""
|
||||||
|
old_config = self.camera_configs.get(camera)
|
||||||
|
|
||||||
|
self._handle_go2rtc_stream_updates(camera, old_config, updated_config)
|
||||||
|
|
||||||
|
self.config.cameras[camera] = updated_config
|
||||||
|
self.camera_configs[camera] = updated_config
|
||||||
|
|
||||||
return
|
return
|
||||||
elif update_type == CameraConfigUpdateEnum.remove:
|
elif update_type == CameraConfigUpdateEnum.remove:
|
||||||
"""Remove go2rtc streams with camera"""
|
"""Remove go2rtc streams with camera"""
|
||||||
camera_config = self.camera_configs.get(camera)
|
camera_config = self.camera_configs.get(camera)
|
||||||
if (
|
go2rtc_updated = False
|
||||||
camera_config
|
|
||||||
and hasattr(self.config, "go2rtc")
|
# Use helper methods to handle go2rtc streams
|
||||||
and hasattr(camera_config, "ffmpeg")
|
if camera_config:
|
||||||
and hasattr(camera_config.ffmpeg, "inputs")
|
streams = self._extract_stream_names(camera_config)
|
||||||
):
|
for stream_name in streams:
|
||||||
for input_item in camera_config.ffmpeg.inputs:
|
if not self._is_stream_in_use(stream_name, camera):
|
||||||
if hasattr(input_item, "path") and isinstance(input_item.path, str):
|
if self._remove_unused_stream(stream_name):
|
||||||
if (
|
go2rtc_updated = True
|
||||||
"rtsp://" in input_item.path
|
|
||||||
and "127.0.0.1:8554/" in input_item.path
|
if go2rtc_updated:
|
||||||
):
|
self.reload_go2rtc()
|
||||||
stream_name = (
|
|
||||||
input_item.path.split("127.0.0.1:8554/")[-1]
|
self.camera_configs.pop(camera, None)
|
||||||
.split("&")[0]
|
if camera in self.config.cameras:
|
||||||
.split("?")[0]
|
self.config.cameras.pop(camera)
|
||||||
)
|
|
||||||
if (
|
|
||||||
hasattr(self.config.go2rtc, "streams")
|
|
||||||
and stream_name in self.config.go2rtc.streams
|
|
||||||
):
|
|
||||||
self.config.go2rtc.streams.pop(stream_name)
|
|
||||||
|
|
||||||
self.config.cameras.pop(camera)
|
|
||||||
self.camera_configs.pop(camera)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
config = self.camera_configs.get(camera)
|
config = self.camera_configs.get(camera)
|
||||||
@ -170,3 +204,138 @@ class CameraConfigUpdateSubscriber:
|
|||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
self.subscriber.stop()
|
self.subscriber.stop()
|
||||||
|
|
||||||
|
def _extract_stream_names(self, config):
|
||||||
|
"""Extract go2rtc stream names from configuration"""
|
||||||
|
streams = []
|
||||||
|
|
||||||
|
if not (hasattr(config, "ffmpeg") and hasattr(config.ffmpeg, "inputs")):
|
||||||
|
return streams
|
||||||
|
|
||||||
|
for input_item in config.ffmpeg.inputs:
|
||||||
|
if not (hasattr(input_item, "path") and isinstance(input_item.path, str)):
|
||||||
|
continue
|
||||||
|
|
||||||
|
path = input_item.path
|
||||||
|
if "rtsp://" in path and "127.0.0.1:8554/" in path:
|
||||||
|
stream_name = (
|
||||||
|
path.split("127.0.0.1:8554/")[-1].split("&")[0].split("?")[0]
|
||||||
|
)
|
||||||
|
streams.append(stream_name)
|
||||||
|
|
||||||
|
return streams
|
||||||
|
|
||||||
|
def _is_stream_in_use(self, stream_name, current_camera):
|
||||||
|
"""Check if stream is used by other cameras"""
|
||||||
|
for cam_name, cam_config in self.camera_configs.items():
|
||||||
|
if cam_name == current_camera:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not (
|
||||||
|
hasattr(cam_config, "ffmpeg") and hasattr(cam_config.ffmpeg, "inputs")
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
for input_item in cam_config.ffmpeg.inputs:
|
||||||
|
if (
|
||||||
|
hasattr(input_item, "path")
|
||||||
|
and isinstance(input_item.path, str)
|
||||||
|
and "127.0.0.1:8554/" + stream_name in input_item.path
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _remove_unused_stream(self, stream_name):
|
||||||
|
"""Remove unused go2rtc stream"""
|
||||||
|
if not (
|
||||||
|
hasattr(self.config, "go2rtc")
|
||||||
|
and hasattr(self.config.go2rtc, "streams")
|
||||||
|
and stream_name in self.config.go2rtc.streams
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
|
||||||
|
logger.info(f"Removing unused go2rtc stream: {stream_name}")
|
||||||
|
self.config.go2rtc.streams.pop(stream_name)
|
||||||
|
config_file = find_config_file()
|
||||||
|
updates: Dict[str, Any] = {f"go2rtc.streams.{stream_name}": ""}
|
||||||
|
update_yaml_file_bulk(config_file, updates)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _handle_go2rtc_stream_updates(self, camera, old_config, updated_config):
|
||||||
|
"""Handle go2rtc stream configuration updates"""
|
||||||
|
if not (
|
||||||
|
old_config
|
||||||
|
and hasattr(self.config, "go2rtc")
|
||||||
|
and hasattr(self.config.go2rtc, "streams")
|
||||||
|
):
|
||||||
|
return
|
||||||
|
|
||||||
|
old_streams = self._extract_stream_names(old_config)
|
||||||
|
new_streams = self._extract_stream_names(updated_config)
|
||||||
|
|
||||||
|
if not (old_streams or new_streams):
|
||||||
|
return
|
||||||
|
|
||||||
|
removed_streams = [s for s in old_streams if s not in new_streams]
|
||||||
|
added_streams = [s for s in new_streams if s not in old_streams]
|
||||||
|
common_streams = [s for s in old_streams if s in new_streams]
|
||||||
|
|
||||||
|
go2rtc_updated = False
|
||||||
|
|
||||||
|
for stream_name in added_streams:
|
||||||
|
logger.info(f"New go2rtc stream detected: {stream_name}")
|
||||||
|
go2rtc_updated = True
|
||||||
|
|
||||||
|
for stream_name in removed_streams:
|
||||||
|
if not self._is_stream_in_use(stream_name, camera):
|
||||||
|
if self._remove_unused_stream(stream_name):
|
||||||
|
go2rtc_updated = True
|
||||||
|
|
||||||
|
for stream_name in common_streams:
|
||||||
|
if stream_name in self.config.go2rtc.streams:
|
||||||
|
for input_item in (
|
||||||
|
updated_config.ffmpeg.inputs
|
||||||
|
if (
|
||||||
|
hasattr(updated_config, "ffmpeg")
|
||||||
|
and hasattr(updated_config.ffmpeg, "inputs")
|
||||||
|
)
|
||||||
|
else []
|
||||||
|
):
|
||||||
|
if (
|
||||||
|
hasattr(input_item, "path")
|
||||||
|
and isinstance(input_item.path, str)
|
||||||
|
and "127.0.0.1:8554/" + stream_name in input_item.path
|
||||||
|
):
|
||||||
|
logger.info(
|
||||||
|
f"Checking for changes in go2rtc stream: {stream_name}"
|
||||||
|
)
|
||||||
|
go2rtc_updated = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if go2rtc_updated:
|
||||||
|
self.reload_go2rtc()
|
||||||
|
|
||||||
|
def reload_go2rtc(self):
|
||||||
|
"""Regenerate go2rtc config and restart the service."""
|
||||||
|
try:
|
||||||
|
create_config_path = "/usr/local/go2rtc/create_config.py"
|
||||||
|
if os.path.exists(create_config_path):
|
||||||
|
subprocess.run(["python3", create_config_path], check=True)
|
||||||
|
logger.info("Successfully regenerated go2rtc config")
|
||||||
|
else:
|
||||||
|
logger.warning(f"Could not find {create_config_path}")
|
||||||
|
|
||||||
|
# Restart go2rtc service
|
||||||
|
try:
|
||||||
|
response = requests.post("http://127.0.0.1:1984/api/restart", timeout=5)
|
||||||
|
if response.status_code == 200:
|
||||||
|
logger.info("Successfully restarted go2rtc service")
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to restart go2rtc service: {response.status_code}"
|
||||||
|
)
|
||||||
|
except requests.RequestException as e:
|
||||||
|
logger.error(f"Error restarting go2rtc service: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error reloading go2rtc: {e}")
|
||||||
|
@ -102,6 +102,7 @@ class EmbeddingMaintainer(threading.Thread):
|
|||||||
[
|
[
|
||||||
CameraConfigUpdateEnum.add,
|
CameraConfigUpdateEnum.add,
|
||||||
CameraConfigUpdateEnum.remove,
|
CameraConfigUpdateEnum.remove,
|
||||||
|
CameraConfigUpdateEnum.edit,
|
||||||
CameraConfigUpdateEnum.object_genai,
|
CameraConfigUpdateEnum.object_genai,
|
||||||
CameraConfigUpdateEnum.review_genai,
|
CameraConfigUpdateEnum.review_genai,
|
||||||
CameraConfigUpdateEnum.semantic_search,
|
CameraConfigUpdateEnum.semantic_search,
|
||||||
|
216
scripts/create_go2rtc_config.py
Normal file
216
scripts/create_go2rtc_config.py
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Creates a go2rtc config file with proper permissions."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
try:
|
||||||
|
from ruamel.yaml import YAML
|
||||||
|
except ImportError:
|
||||||
|
print("Installing required packages...")
|
||||||
|
import subprocess
|
||||||
|
subprocess.check_call([sys.executable, "-m", "pip", "install", "ruamel.yaml"])
|
||||||
|
from ruamel.yaml import YAML
|
||||||
|
|
||||||
|
# Add frigate to path
|
||||||
|
sys.path.insert(0, "/opt/frigate")
|
||||||
|
try:
|
||||||
|
from frigate.const import (
|
||||||
|
BIRDSEYE_PIPE,
|
||||||
|
DEFAULT_FFMPEG_VERSION,
|
||||||
|
INCLUDED_FFMPEG_VERSIONS,
|
||||||
|
LIBAVFORMAT_VERSION_MAJOR,
|
||||||
|
)
|
||||||
|
from frigate.ffmpeg_presets import parse_preset_hardware_acceleration_encode
|
||||||
|
from frigate.util.config import find_config_file
|
||||||
|
except ImportError:
|
||||||
|
# Fallback if imports fail
|
||||||
|
print("Warning: Could not import from frigate module. Using fallback values.")
|
||||||
|
BIRDSEYE_PIPE = "/tmp/birdseye"
|
||||||
|
DEFAULT_FFMPEG_VERSION = "v5.1.2"
|
||||||
|
INCLUDED_FFMPEG_VERSIONS = ["v5.1.2", "v4.4"]
|
||||||
|
LIBAVFORMAT_VERSION_MAJOR = 59
|
||||||
|
|
||||||
|
def parse_preset_hardware_acceleration_encode(ffmpeg_path, hwaccel_args, input_args, output_args):
|
||||||
|
return f"{ffmpeg_path} {hwaccel_args} {input_args} {output_args}"
|
||||||
|
|
||||||
|
def find_config_file():
|
||||||
|
config_file = os.environ.get("CONFIG_FILE", "/config/config.yml")
|
||||||
|
if os.path.exists(config_file):
|
||||||
|
return config_file
|
||||||
|
|
||||||
|
# Try common locations
|
||||||
|
for ext in [".yml", ".yaml", ".json"]:
|
||||||
|
for path in ["/config/config", "/etc/frigate/config"]:
|
||||||
|
if os.path.exists(f"{path}{ext}"):
|
||||||
|
return f"{path}{ext}"
|
||||||
|
return "/config/config.yml"
|
||||||
|
|
||||||
|
# Remove frigate from path
|
||||||
|
if "/opt/frigate" in sys.path:
|
||||||
|
sys.path.remove("/opt/frigate")
|
||||||
|
|
||||||
|
yaml = YAML()
|
||||||
|
|
||||||
|
FRIGATE_ENV_VARS = {k: v for k, v in os.environ.items() if k.startswith("FRIGATE_")}
|
||||||
|
# read docker secret files as env vars too
|
||||||
|
if os.path.isdir("/run/secrets"):
|
||||||
|
for secret_file in os.listdir("/run/secrets"):
|
||||||
|
if secret_file.startswith("FRIGATE_"):
|
||||||
|
FRIGATE_ENV_VARS[secret_file] = (
|
||||||
|
Path(os.path.join("/run/secrets", secret_file)).read_text().strip()
|
||||||
|
)
|
||||||
|
|
||||||
|
config_file = find_config_file()
|
||||||
|
print(f"Using config file: {config_file}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(config_file) as f:
|
||||||
|
raw_config = f.read()
|
||||||
|
|
||||||
|
if config_file.endswith((".yaml", ".yml")):
|
||||||
|
config: dict[str, Any] = yaml.load(raw_config)
|
||||||
|
elif config_file.endswith(".json"):
|
||||||
|
config: dict[str, Any] = json.loads(raw_config)
|
||||||
|
except FileNotFoundError:
|
||||||
|
print(f"Config file not found: {config_file}")
|
||||||
|
config: dict[str, Any] = {}
|
||||||
|
|
||||||
|
go2rtc_config: dict[str, Any] = config.get("go2rtc", {})
|
||||||
|
|
||||||
|
# Need to enable CORS for go2rtc so the frigate integration / card work automatically
|
||||||
|
if go2rtc_config.get("api") is None:
|
||||||
|
go2rtc_config["api"] = {"origin": "*"}
|
||||||
|
elif go2rtc_config["api"].get("origin") is None:
|
||||||
|
go2rtc_config["api"]["origin"] = "*"
|
||||||
|
|
||||||
|
# Need to set default location for HA config
|
||||||
|
if go2rtc_config.get("hass") is None:
|
||||||
|
go2rtc_config["hass"] = {"config": "/homeassistant"}
|
||||||
|
|
||||||
|
# we want to ensure that logs are easy to read
|
||||||
|
if go2rtc_config.get("log") is None:
|
||||||
|
go2rtc_config["log"] = {"format": "text"}
|
||||||
|
elif go2rtc_config["log"].get("format") is None:
|
||||||
|
go2rtc_config["log"]["format"] = "text"
|
||||||
|
|
||||||
|
# ensure there is a default webrtc config
|
||||||
|
if go2rtc_config.get("webrtc") is None:
|
||||||
|
go2rtc_config["webrtc"] = {}
|
||||||
|
|
||||||
|
if go2rtc_config["webrtc"].get("candidates") is None:
|
||||||
|
default_candidates = []
|
||||||
|
# use internal candidate if it was discovered when running through the add-on
|
||||||
|
internal_candidate = os.environ.get("FRIGATE_GO2RTC_WEBRTC_CANDIDATE_INTERNAL")
|
||||||
|
if internal_candidate is not None:
|
||||||
|
default_candidates.append(internal_candidate)
|
||||||
|
# should set default stun server so webrtc can work
|
||||||
|
default_candidates.append("stun:8555")
|
||||||
|
|
||||||
|
go2rtc_config["webrtc"]["candidates"] = default_candidates
|
||||||
|
|
||||||
|
if go2rtc_config.get("rtsp", {}).get("username") is not None:
|
||||||
|
go2rtc_config["rtsp"]["username"] = go2rtc_config["rtsp"]["username"].format(
|
||||||
|
**FRIGATE_ENV_VARS
|
||||||
|
)
|
||||||
|
|
||||||
|
if go2rtc_config.get("rtsp", {}).get("password") is not None:
|
||||||
|
go2rtc_config["rtsp"]["password"] = go2rtc_config["rtsp"]["password"].format(
|
||||||
|
**FRIGATE_ENV_VARS
|
||||||
|
)
|
||||||
|
|
||||||
|
# ensure ffmpeg path is set correctly
|
||||||
|
path = config.get("ffmpeg", {}).get("path", "default")
|
||||||
|
if path == "default":
|
||||||
|
ffmpeg_path = f"/usr/lib/ffmpeg/{DEFAULT_FFMPEG_VERSION}/bin/ffmpeg"
|
||||||
|
elif path in INCLUDED_FFMPEG_VERSIONS:
|
||||||
|
ffmpeg_path = f"/usr/lib/ffmpeg/{path}/bin/ffmpeg"
|
||||||
|
else:
|
||||||
|
ffmpeg_path = f"{path}/bin/ffmpeg"
|
||||||
|
|
||||||
|
if go2rtc_config.get("ffmpeg") is None:
|
||||||
|
go2rtc_config["ffmpeg"] = {"bin": ffmpeg_path}
|
||||||
|
elif go2rtc_config["ffmpeg"].get("bin") is None:
|
||||||
|
go2rtc_config["ffmpeg"]["bin"] = ffmpeg_path
|
||||||
|
|
||||||
|
# need to replace ffmpeg command when using ffmpeg4
|
||||||
|
if LIBAVFORMAT_VERSION_MAJOR < 59:
|
||||||
|
rtsp_args = "-fflags nobuffer -flags low_delay -stimeout 10000000 -user_agent go2rtc/ffmpeg -rtsp_transport tcp -i {input}"
|
||||||
|
if go2rtc_config.get("ffmpeg") is None:
|
||||||
|
go2rtc_config["ffmpeg"] = {"rtsp": rtsp_args}
|
||||||
|
elif go2rtc_config["ffmpeg"].get("rtsp") is None:
|
||||||
|
go2rtc_config["ffmpeg"]["rtsp"] = rtsp_args
|
||||||
|
|
||||||
|
for name in go2rtc_config.get("streams", {}):
|
||||||
|
stream = go2rtc_config["streams"][name]
|
||||||
|
|
||||||
|
if isinstance(stream, str):
|
||||||
|
try:
|
||||||
|
go2rtc_config["streams"][name] = go2rtc_config["streams"][name].format(
|
||||||
|
**FRIGATE_ENV_VARS
|
||||||
|
)
|
||||||
|
except KeyError as e:
|
||||||
|
print(
|
||||||
|
"[ERROR] Invalid substitution found, see https://docs.frigate.video/configuration/restream#advanced-restream-configurations for more info."
|
||||||
|
)
|
||||||
|
sys.exit(e)
|
||||||
|
|
||||||
|
elif isinstance(stream, list):
|
||||||
|
for i, stream in enumerate(stream):
|
||||||
|
try:
|
||||||
|
go2rtc_config["streams"][name][i] = stream.format(**FRIGATE_ENV_VARS)
|
||||||
|
except KeyError as e:
|
||||||
|
print(
|
||||||
|
"[ERROR] Invalid substitution found, see https://docs.frigate.video/configuration/restream#advanced-restream-configurations for more info."
|
||||||
|
)
|
||||||
|
sys.exit(e)
|
||||||
|
|
||||||
|
# add birdseye restream stream if enabled
|
||||||
|
if config.get("birdseye", {}).get("restream", False):
|
||||||
|
birdseye: dict[str, Any] = config.get("birdseye")
|
||||||
|
|
||||||
|
input = f"-f rawvideo -pix_fmt yuv420p -video_size {birdseye.get('width', 1280)}x{birdseye.get('height', 720)} -r 10 -i {BIRDSEYE_PIPE}"
|
||||||
|
ffmpeg_cmd = f"exec:{parse_preset_hardware_acceleration_encode(ffmpeg_path, config.get('ffmpeg', {}).get('hwaccel_args', ''), input, '-rtsp_transport tcp -f rtsp {output}')}"
|
||||||
|
|
||||||
|
if go2rtc_config.get("streams"):
|
||||||
|
go2rtc_config["streams"]["birdseye"] = ffmpeg_cmd
|
||||||
|
else:
|
||||||
|
go2rtc_config["streams"] = {"birdseye": ffmpeg_cmd}
|
||||||
|
|
||||||
|
# Write go2rtc_config to multiple possible locations with proper permissions
|
||||||
|
config_paths = [
|
||||||
|
"/dev/shm/go2rtc.yaml", # Primary location
|
||||||
|
"/tmp/go2rtc.yaml", # Fallback location 1
|
||||||
|
os.path.join(os.path.dirname(os.path.abspath(__file__)), "go2rtc.yaml") # Fallback location 2
|
||||||
|
]
|
||||||
|
|
||||||
|
success = False
|
||||||
|
for config_path in config_paths:
|
||||||
|
try:
|
||||||
|
# Try to create directory if it doesn't exist
|
||||||
|
os.makedirs(os.path.dirname(config_path), exist_ok=True)
|
||||||
|
|
||||||
|
# Write to a temporary file first
|
||||||
|
temp_path = f"{config_path}.tmp"
|
||||||
|
with open(temp_path, "w") as f:
|
||||||
|
yaml.dump(go2rtc_config, f)
|
||||||
|
|
||||||
|
# Make the file world-readable and writable
|
||||||
|
os.chmod(temp_path, 0o666)
|
||||||
|
|
||||||
|
# Move the temporary file to the final location
|
||||||
|
shutil.move(temp_path, config_path)
|
||||||
|
|
||||||
|
print(f"Successfully wrote go2rtc config to {config_path}")
|
||||||
|
success = True
|
||||||
|
break
|
||||||
|
except (PermissionError, OSError) as e:
|
||||||
|
print(f"Failed to write to {config_path}: {e}")
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
print("ERROR: Could not write go2rtc config to any location")
|
||||||
|
sys.exit(1)
|
@ -192,6 +192,7 @@
|
|||||||
"edit": "Edit Camera",
|
"edit": "Edit Camera",
|
||||||
"description": "Configure camera settings including stream inputs and roles.",
|
"description": "Configure camera settings including stream inputs and roles.",
|
||||||
"name": "Camera Name",
|
"name": "Camera Name",
|
||||||
|
"nameOnlyChangeToFriendlyName": "This will only change the Friendly Name.",
|
||||||
"nameRequired": "Camera name is required",
|
"nameRequired": "Camera name is required",
|
||||||
"nameLength": "Camera name must be less than 24 characters.",
|
"nameLength": "Camera name must be less than 24 characters.",
|
||||||
"namePlaceholder": "e.g., front_door",
|
"namePlaceholder": "e.g., front_door",
|
||||||
@ -200,7 +201,7 @@
|
|||||||
"inputs": "Input Streams",
|
"inputs": "Input Streams",
|
||||||
"go2rtc": {
|
"go2rtc": {
|
||||||
"title": "Use go2rtc stream",
|
"title": "Use go2rtc stream",
|
||||||
"description": "will use go2rtc",
|
"description": "It will utilize go2rtc, enabling a smoother live viewing experience and allowing you to hear the camera audio.",
|
||||||
"compatibility": {
|
"compatibility": {
|
||||||
"title": "Video codec compatibility (Advanced)",
|
"title": "Video codec compatibility (Advanced)",
|
||||||
"description": "Here you can configure common go2rtc FFmpeg encoding parameters. Some of these parameters may help resolve certain video or audio issues.",
|
"description": "Here you can configure common go2rtc FFmpeg encoding parameters. Some of these parameters may help resolve certain video or audio issues.",
|
||||||
|
@ -250,7 +250,7 @@ export default function CameraEditForm({
|
|||||||
if (cameraName && config?.cameras[cameraName]) {
|
if (cameraName && config?.cameras[cameraName]) {
|
||||||
const camera = config.cameras[cameraName];
|
const camera = config.cameras[cameraName];
|
||||||
defaultValues.enabled = camera.enabled ?? true;
|
defaultValues.enabled = camera.enabled ?? true;
|
||||||
defaultValues.ffmpeg.inputs = camera.ffmpeg?.inputs?.length
|
defaultValues.ffmpeg.inputs = camera.ffmpeg?.inputs
|
||||||
? camera.ffmpeg.inputs.map((input) => {
|
? camera.ffmpeg.inputs.map((input) => {
|
||||||
const isGo2rtcPath = input.path.match(
|
const isGo2rtcPath = input.path.match(
|
||||||
/^rtsp:\/\/127\.0\.0\.1:8554\/(.+)$/,
|
/^rtsp:\/\/127\.0\.0\.1:8554\/(.+)$/,
|
||||||
@ -263,10 +263,7 @@ export default function CameraEditForm({
|
|||||||
if (go2rtcStreamName && config.go2rtc?.streams) {
|
if (go2rtcStreamName && config.go2rtc?.streams) {
|
||||||
Object.entries(config.go2rtc.streams).forEach(
|
Object.entries(config.go2rtc.streams).forEach(
|
||||||
([streamKey, streamConfig]) => {
|
([streamKey, streamConfig]) => {
|
||||||
if (
|
if (streamKey === go2rtcStreamName) {
|
||||||
streamKey === go2rtcStreamName ||
|
|
||||||
streamKey.startsWith(`${cameraName}_`)
|
|
||||||
) {
|
|
||||||
if (Array.isArray(streamConfig) && streamConfig.length >= 1) {
|
if (Array.isArray(streamConfig) && streamConfig.length >= 1) {
|
||||||
originalPath = streamConfig[0] || "";
|
originalPath = streamConfig[0] || "";
|
||||||
ffmpegParams = streamConfig[1] || "";
|
ffmpegParams = streamConfig[1] || "";
|
||||||
@ -319,7 +316,9 @@ export default function CameraEditForm({
|
|||||||
let friendly_name: string | undefined = undefined;
|
let friendly_name: string | undefined = undefined;
|
||||||
const isValidName = /^[a-zA-Z0-9_-]+$/.test(values.cameraName);
|
const isValidName = /^[a-zA-Z0-9_-]+$/.test(values.cameraName);
|
||||||
if (!isValidName) {
|
if (!isValidName) {
|
||||||
finalCameraName = generateFixedHash(finalCameraName);
|
finalCameraName = cameraName
|
||||||
|
? cameraName
|
||||||
|
: generateFixedHash(finalCameraName);
|
||||||
friendly_name = values.cameraName;
|
friendly_name = values.cameraName;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -366,6 +365,8 @@ export default function CameraEditForm({
|
|||||||
// Add update_topic for new cameras
|
// Add update_topic for new cameras
|
||||||
if (!cameraName) {
|
if (!cameraName) {
|
||||||
requestBody.update_topic = `config/cameras/${finalCameraName}/add`;
|
requestBody.update_topic = `config/cameras/${finalCameraName}/add`;
|
||||||
|
} else {
|
||||||
|
requestBody.update_topic = `config/cameras/${finalCameraName}/edit`;
|
||||||
}
|
}
|
||||||
|
|
||||||
axios
|
axios
|
||||||
@ -399,51 +400,7 @@ export default function CameraEditForm({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onSubmit = (values: FormValues) => {
|
const onSubmit = (values: FormValues) => {
|
||||||
if (cameraName && values.cameraName !== cameraName) {
|
saveCameraConfig(values);
|
||||||
// If camera name changed, delete old camera config
|
|
||||||
const deleteRequestBody: ConfigSetBody = {
|
|
||||||
requires_restart: 1,
|
|
||||||
config_data: {
|
|
||||||
cameras: {
|
|
||||||
[cameraName]: "",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
update_topic: `config/cameras/${cameraName}/remove`,
|
|
||||||
};
|
|
||||||
const camera = config?.cameras[cameraName];
|
|
||||||
if (values.ffmpeg.inputs.some((input) => input.go2rtc)) {
|
|
||||||
camera?.ffmpeg.inputs.map((input) => {
|
|
||||||
const isGo2rtcPath = input.path.match(
|
|
||||||
/^rtsp:\/\/127\.0\.0\.1:8554\/(.+)$/,
|
|
||||||
);
|
|
||||||
const go2rtcStreamName = isGo2rtcPath ? isGo2rtcPath[1] : "";
|
|
||||||
deleteRequestBody.config_data.go2rtc = {
|
|
||||||
streams: {
|
|
||||||
[go2rtcStreamName]: "",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
axios
|
|
||||||
.put("config/set", deleteRequestBody)
|
|
||||||
.then(() => saveCameraConfig(values))
|
|
||||||
.catch((error) => {
|
|
||||||
const errorMessage =
|
|
||||||
error.response?.data?.message ||
|
|
||||||
error.response?.data?.detail ||
|
|
||||||
"Unknown error";
|
|
||||||
toast.error(
|
|
||||||
t("toast.save.error.title", { errorMessage, ns: "common" }),
|
|
||||||
{ position: "top-center" },
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setIsLoading(false);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
saveCameraConfig(values);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Determine available roles for new streams
|
// Determine available roles for new streams
|
||||||
@ -486,11 +443,19 @@ export default function CameraEditForm({
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>{t("camera.cameraConfig.name")}</FormLabel>
|
<FormLabel>{t("camera.cameraConfig.name")}</FormLabel>
|
||||||
|
{cameraName ? (
|
||||||
|
<>
|
||||||
|
<div className="my-3 text-sm text-muted-foreground">
|
||||||
|
{t("camera.cameraConfig.nameOnlyChangeToFriendlyName")}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
""
|
||||||
|
)}
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder={t("camera.cameraConfig.namePlaceholder")}
|
placeholder={t("camera.cameraConfig.namePlaceholder")}
|
||||||
{...field}
|
{...field}
|
||||||
disabled={!!cameraName} // Prevent editing name for existing cameras
|
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
|
83
web/src/components/ui/select-input.tsx
Normal file
83
web/src/components/ui/select-input.tsx
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Input, InputProps } from "./input"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue
|
||||||
|
} from "./select"
|
||||||
|
|
||||||
|
export interface SelectInputProps extends InputProps {
|
||||||
|
options: { value: string; label: string }[]
|
||||||
|
onSelectChange?: (value: string) => void
|
||||||
|
selectPlaceholder?: string
|
||||||
|
selectClassName?: string
|
||||||
|
selectPosition?: "before" | "after"
|
||||||
|
}
|
||||||
|
|
||||||
|
const SelectInput = React.forwardRef<HTMLInputElement, SelectInputProps>(
|
||||||
|
({
|
||||||
|
className,
|
||||||
|
options,
|
||||||
|
onSelectChange,
|
||||||
|
selectPlaceholder = "Select...",
|
||||||
|
selectClassName = "w-[180px]",
|
||||||
|
selectPosition = "after",
|
||||||
|
...props
|
||||||
|
}, ref) => {
|
||||||
|
const [selectedValue, setSelectedValue] = React.useState<string>("")
|
||||||
|
|
||||||
|
const handleValueChange = (value: string) => {
|
||||||
|
setSelectedValue(value)
|
||||||
|
if (onSelectChange) {
|
||||||
|
onSelectChange(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{selectPosition === "before" && (
|
||||||
|
<Select value={selectedValue} onValueChange={handleValueChange}>
|
||||||
|
<SelectTrigger className={selectClassName}>
|
||||||
|
<SelectValue placeholder={selectPlaceholder} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{options.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Input className={className} ref={ref} {...props} />
|
||||||
|
|
||||||
|
{selectPosition === "after" && (
|
||||||
|
<Select value={selectedValue} onValueChange={handleValueChange}>
|
||||||
|
<SelectTrigger className={selectClassName}>
|
||||||
|
<SelectValue placeholder={selectPlaceholder} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{options.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
SelectInput.displayName = "SelectInput"
|
||||||
|
|
||||||
|
export { SelectInput }
|
Loading…
Reference in New Issue
Block a user