mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-11-01 11:20:32 +03:00
Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3aba3063e1 | ||
|
|
acb54394c3 | ||
|
|
9a20138b75 | ||
|
|
fafbe3c1e2 | ||
|
|
d4580fe85e | ||
|
|
4a6161156d | ||
|
|
2eef1d25a0 | ||
|
|
cb8ab39f83 | ||
|
|
1dde6a65bb | ||
|
|
df6e8ea70d | ||
|
|
1d9478453c | ||
|
|
b504c94670 | ||
|
|
c19cb04cc5 | ||
|
|
e89fb56f99 | ||
|
|
48d609e345 | ||
|
|
138c525dbc | ||
|
|
a9889ddb5f | ||
|
|
e3095d944d | ||
|
|
3cc4764c85 | ||
|
|
efa1afa81f | ||
|
|
c1e8e30312 | ||
|
|
26836878ec | ||
|
|
832cd0d711 | ||
|
|
f58a7ace43 | ||
|
|
00ca2f73c2 |
+2
-21
@@ -41,18 +41,6 @@ class MqttClient(Communicator):
|
||||
self.publish("available", "stopped", retain=True)
|
||||
self.client.disconnect()
|
||||
|
||||
def _notifications_enabled_in_config(self) -> bool:
|
||||
"""Whether notifications are configured globally or on any camera.
|
||||
|
||||
Notifications can be enabled per camera with the global config left
|
||||
disabled, so the global topics must consider both (matching how
|
||||
app.py decides to create the WebPushClient).
|
||||
"""
|
||||
return self.config.notifications.enabled_in_config or any(
|
||||
cam.enabled and cam.notifications.enabled_in_config
|
||||
for cam in self.config.cameras.values()
|
||||
)
|
||||
|
||||
def _set_initial_topics(self) -> None:
|
||||
"""Set initial state topics."""
|
||||
for camera_name, camera in self.config.cameras.items():
|
||||
@@ -169,7 +157,7 @@ class MqttClient(Communicator):
|
||||
retain=True,
|
||||
)
|
||||
|
||||
if self._notifications_enabled_in_config():
|
||||
if self.config.notifications.enabled_in_config:
|
||||
self.publish(
|
||||
"notifications/state",
|
||||
"ON" if self.config.notifications.enabled else "OFF",
|
||||
@@ -268,7 +256,6 @@ class MqttClient(Communicator):
|
||||
"review_detections",
|
||||
"object_descriptions",
|
||||
"review_descriptions",
|
||||
"notifications",
|
||||
]
|
||||
|
||||
for name in self.config.cameras.keys():
|
||||
@@ -278,12 +265,6 @@ class MqttClient(Communicator):
|
||||
self.on_mqtt_command,
|
||||
)
|
||||
|
||||
# notifications suspend doesn't follow the /set topic pattern
|
||||
self.client.message_callback_add(
|
||||
f"{self.mqtt_config.topic_prefix}/{name}/notifications/suspend",
|
||||
self.on_mqtt_command,
|
||||
)
|
||||
|
||||
if self.config.cameras[name].onvif.host:
|
||||
self.client.message_callback_add(
|
||||
f"{self.mqtt_config.topic_prefix}/{name}/ptz",
|
||||
@@ -308,7 +289,7 @@ class MqttClient(Communicator):
|
||||
self.on_mqtt_command,
|
||||
)
|
||||
|
||||
if self._notifications_enabled_in_config():
|
||||
if self.config.notifications.enabled_in_config:
|
||||
self.client.message_callback_add(
|
||||
f"{self.mqtt_config.topic_prefix}/notifications/set",
|
||||
self.on_mqtt_command,
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
"""Tests for MQTT command topic callback registration."""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from frigate.comms.mqtt import MqttClient
|
||||
|
||||
|
||||
def _make_camera_mock(
|
||||
*,
|
||||
enabled: bool = True,
|
||||
notifications_enabled_in_config: bool = False,
|
||||
) -> MagicMock:
|
||||
"""Build a camera config mock with the fields _start() reads."""
|
||||
camera = MagicMock()
|
||||
camera.enabled = enabled
|
||||
camera.notifications.enabled_in_config = notifications_enabled_in_config
|
||||
camera.onvif.host = None
|
||||
camera.motion.mask = {}
|
||||
camera.objects.mask = {}
|
||||
camera.zones = {}
|
||||
return camera
|
||||
|
||||
|
||||
def _registered_topics(
|
||||
cameras: dict[str, MagicMock],
|
||||
*,
|
||||
global_notifications_enabled_in_config: bool = False,
|
||||
) -> set[str]:
|
||||
"""Start an MqttClient against a mocked paho client and collect the
|
||||
topics registered via message_callback_add."""
|
||||
config = MagicMock()
|
||||
config.cameras = cameras
|
||||
config.notifications.enabled_in_config = global_notifications_enabled_in_config
|
||||
config.mqtt.topic_prefix = "frigate"
|
||||
config.mqtt.client_id = "frigate"
|
||||
config.mqtt.user = None
|
||||
config.mqtt.tls_ca_certs = None
|
||||
config.mqtt.tls_insecure = None
|
||||
|
||||
with patch("frigate.comms.mqtt.mqtt.Client") as client_cls:
|
||||
mqtt_client = MqttClient(config)
|
||||
mqtt_client.subscribe(MagicMock())
|
||||
|
||||
paho_client = client_cls.return_value
|
||||
return {call.args[0] for call in paho_client.message_callback_add.call_args_list}
|
||||
|
||||
|
||||
class TestMqttTopicRegistration(unittest.TestCase):
|
||||
def test_camera_notification_topics_registered(self):
|
||||
"""Per-camera notification set/suspend must be registered so paho
|
||||
routes them to the dispatcher (unregistered topics drop silently)."""
|
||||
topics = _registered_topics(
|
||||
{"front_door": _make_camera_mock(notifications_enabled_in_config=True)}
|
||||
)
|
||||
|
||||
self.assertIn("frigate/front_door/notifications/set", topics)
|
||||
self.assertIn("frigate/front_door/notifications/suspend", topics)
|
||||
|
||||
def test_global_set_registered_with_camera_only_notifications(self):
|
||||
"""The global topic must work when notifications are enabled only at
|
||||
the camera level, matching the WebPushClient gating in app.py."""
|
||||
topics = _registered_topics(
|
||||
{"front_door": _make_camera_mock(notifications_enabled_in_config=True)},
|
||||
global_notifications_enabled_in_config=False,
|
||||
)
|
||||
|
||||
self.assertIn("frigate/notifications/set", topics)
|
||||
|
||||
def test_global_set_registered_with_global_notifications(self):
|
||||
topics = _registered_topics(
|
||||
{"front_door": _make_camera_mock()},
|
||||
global_notifications_enabled_in_config=True,
|
||||
)
|
||||
|
||||
self.assertIn("frigate/notifications/set", topics)
|
||||
|
||||
def test_global_set_not_registered_when_notifications_unconfigured(self):
|
||||
topics = _registered_topics(
|
||||
{"front_door": _make_camera_mock()},
|
||||
global_notifications_enabled_in_config=False,
|
||||
)
|
||||
|
||||
self.assertNotIn("frigate/notifications/set", topics)
|
||||
|
||||
def test_disabled_camera_does_not_enable_global_set(self):
|
||||
topics = _registered_topics(
|
||||
{
|
||||
"front_door": _make_camera_mock(
|
||||
enabled=False, notifications_enabled_in_config=True
|
||||
)
|
||||
},
|
||||
global_notifications_enabled_in_config=False,
|
||||
)
|
||||
|
||||
self.assertNotIn("frigate/notifications/set", topics)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,183 +0,0 @@
|
||||
"""Test in-place yaml config updates."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
from frigate.util.builtin import update_yaml_file_bulk
|
||||
|
||||
|
||||
class TestUpdateYaml(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.yaml = YAML()
|
||||
fd, self.config_path = tempfile.mkstemp(suffix=".yml")
|
||||
os.close(fd)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
os.unlink(self.config_path)
|
||||
|
||||
def _write(self, text: str) -> None:
|
||||
with open(self.config_path, "w") as f:
|
||||
f.write(text)
|
||||
|
||||
def _read(self) -> str:
|
||||
with open(self.config_path) as f:
|
||||
return f.read()
|
||||
|
||||
def _load(self):
|
||||
with open(self.config_path) as f:
|
||||
return self.yaml.load(f)
|
||||
|
||||
def test_delete_key(self):
|
||||
"""Deleting a key removes it and leaves valid yaml."""
|
||||
self._write(
|
||||
"cameras:\n"
|
||||
" cam1:\n"
|
||||
" objects:\n"
|
||||
" filters:\n"
|
||||
" car:\n"
|
||||
" mask: 0,0.45,0.245,0.45\n"
|
||||
)
|
||||
update_yaml_file_bulk(
|
||||
self.config_path, {"cameras.cam1.objects.filters.car.mask": ""}
|
||||
)
|
||||
data = self._load()
|
||||
assert "mask" not in data["cameras"]["cam1"]["objects"]["filters"]["car"]
|
||||
|
||||
def test_delete_commented_key_emptying_map(self):
|
||||
"""Deleting the only key of a map whose key carries comments must not
|
||||
emit unparseable yaml (orphaned comment tokens above a flow-style {})."""
|
||||
self._write(
|
||||
"cameras:\n"
|
||||
" cam1:\n"
|
||||
" objects:\n"
|
||||
" filters:\n"
|
||||
" car:\n"
|
||||
" # cars parked across the street\n"
|
||||
" # second comment line\n"
|
||||
" mask: 0,0.45,0.245,0.45\n"
|
||||
" motion:\n"
|
||||
" mask: 0,0.449,0.686,0.395\n"
|
||||
)
|
||||
update_yaml_file_bulk(
|
||||
self.config_path, {"cameras.cam1.objects.filters.car.mask": ""}
|
||||
)
|
||||
# must re-parse cleanly
|
||||
data = self._load()
|
||||
assert "mask" not in data["cameras"]["cam1"]["objects"]["filters"]["car"]
|
||||
assert data["cameras"]["cam1"]["motion"]["mask"] == "0,0.449,0.686,0.395"
|
||||
# the orphaned comments must be gone from the file, not just parseable
|
||||
content = self._read()
|
||||
assert "cars parked across the street" not in content
|
||||
assert "second comment line" not in content
|
||||
|
||||
def test_delete_last_named_mask_emptying_map(self):
|
||||
"""The path the current UI actually sends: a named object mask deleted
|
||||
down to an empty `mask` map, with a comment inside that map."""
|
||||
self._write(
|
||||
"cameras:\n"
|
||||
" cam1:\n"
|
||||
" objects:\n"
|
||||
" filters:\n"
|
||||
" car:\n"
|
||||
" mask:\n"
|
||||
" # ignore the neighbor's driveway\n"
|
||||
" driveway:\n"
|
||||
" coordinates: 0,0.1,0.2,0.3\n"
|
||||
)
|
||||
update_yaml_file_bulk(
|
||||
self.config_path,
|
||||
{"cameras.cam1.objects.filters.car.mask.driveway": ""},
|
||||
)
|
||||
data = self._load()
|
||||
assert data["cameras"]["cam1"]["objects"]["filters"]["car"]["mask"] == {}
|
||||
assert "ignore the neighbor's driveway" not in self._read()
|
||||
|
||||
def test_delete_last_commented_list_item(self):
|
||||
"""Deleting the last element of a commented sequence must not emit
|
||||
an orphaned comment above a flow-style [] at column 0."""
|
||||
self._write(
|
||||
"cameras:\n"
|
||||
" cam1:\n"
|
||||
" motion:\n"
|
||||
" mask:\n"
|
||||
" # driveway motion mask\n"
|
||||
" - 0,0.4,0.6,0.4\n"
|
||||
)
|
||||
update_yaml_file_bulk(self.config_path, {"cameras.cam1.motion.mask.0": ""})
|
||||
data = self._load()
|
||||
assert data["cameras"]["cam1"]["motion"]["mask"] == []
|
||||
assert "driveway motion mask" not in self._read()
|
||||
|
||||
def test_delete_list_item_preserves_remaining(self):
|
||||
"""Deleting one element of a sequence keeps the others and stays valid."""
|
||||
self._write(
|
||||
"cameras:\n"
|
||||
" cam1:\n"
|
||||
" motion:\n"
|
||||
" mask:\n"
|
||||
" - 0,0.4,0.6,0.4\n"
|
||||
" - 0,0.1,0.2,0.3\n"
|
||||
)
|
||||
update_yaml_file_bulk(self.config_path, {"cameras.cam1.motion.mask.0": ""})
|
||||
data = self._load()
|
||||
assert data["cameras"]["cam1"]["motion"]["mask"] == ["0,0.1,0.2,0.3"]
|
||||
|
||||
def test_delete_key_preserves_siblings(self):
|
||||
"""Deleting one key among several keeps the sibling entries and any
|
||||
comments on keys preceding the deleted one."""
|
||||
self._write(
|
||||
"cameras:\n"
|
||||
" cam1:\n"
|
||||
" objects:\n"
|
||||
" filters:\n"
|
||||
" car:\n"
|
||||
" # mask drawn around the parked suv\n"
|
||||
" mask: 0,0.45,0.245,0.45\n"
|
||||
" threshold: 0.8\n"
|
||||
)
|
||||
update_yaml_file_bulk(
|
||||
self.config_path, {"cameras.cam1.objects.filters.car.threshold": ""}
|
||||
)
|
||||
data = self._load()
|
||||
car = data["cameras"]["cam1"]["objects"]["filters"]["car"]
|
||||
assert "threshold" not in car
|
||||
assert car["mask"] == "0,0.45,0.245,0.45"
|
||||
assert "# mask drawn around the parked suv" in self._read()
|
||||
|
||||
def test_delete_first_commented_key_keeps_map_valid(self):
|
||||
"""Deleting a commented key from a map that still has other keys
|
||||
leaves the remaining entries intact and the file parseable."""
|
||||
self._write(
|
||||
"cameras:\n"
|
||||
" cam1:\n"
|
||||
" objects:\n"
|
||||
" filters:\n"
|
||||
" car:\n"
|
||||
" # comment on the deleted key\n"
|
||||
" mask: 0,0.45,0.245,0.45\n"
|
||||
" threshold: 0.8\n"
|
||||
)
|
||||
update_yaml_file_bulk(
|
||||
self.config_path, {"cameras.cam1.objects.filters.car.mask": ""}
|
||||
)
|
||||
data = self._load()
|
||||
car = data["cameras"]["cam1"]["objects"]["filters"]["car"]
|
||||
assert "mask" not in car
|
||||
assert car["threshold"] == 0.8
|
||||
|
||||
def test_update_value_preserves_comments(self):
|
||||
"""Updating a value keeps surrounding comments intact."""
|
||||
self._write(
|
||||
"cameras:\n cam1:\n detect:\n # tuned for the pi\n fps: 4\n"
|
||||
)
|
||||
update_yaml_file_bulk(self.config_path, {"cameras.cam1.detect.fps": 5})
|
||||
data = self._load()
|
||||
assert data["cameras"]["cam1"]["detect"]["fps"] == 5
|
||||
assert "# tuned for the pi" in self._read()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -293,51 +293,26 @@ def update_yaml_file_bulk(file_path: str, updates: Dict[str, Any]):
|
||||
logger.error(f"Unable to write to Frigate config file {file_path}: {e}")
|
||||
|
||||
|
||||
def clear_orphaned_comments(collection, parent, parent_key) -> None:
|
||||
"""Drop stale ruamel comment tokens after a deletion empties a collection.
|
||||
|
||||
When the last entry of a mapping or sequence is removed, any comments that
|
||||
lived inside that collection's block are orphaned. ruamel then emits them
|
||||
above a flow-style `{}`/`[]` dedented to column 0, which is unparseable and
|
||||
corrupts the config. Clearing the emptied collection's own comment metadata
|
||||
(and the parent's entry pointing at it) keeps the dump valid. Non-empty
|
||||
collections are left untouched so comments on remaining siblings survive.
|
||||
"""
|
||||
if not hasattr(collection, "ca") or len(collection) != 0:
|
||||
return
|
||||
|
||||
collection.ca.items.clear()
|
||||
collection.ca.comment = None
|
||||
if parent is not None and hasattr(parent, "ca"):
|
||||
parent.ca.items.pop(parent_key, None)
|
||||
|
||||
|
||||
def update_yaml(data, key_path, new_value):
|
||||
temp = data
|
||||
parent = None
|
||||
parent_key = None
|
||||
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)
|
||||
parent, parent_key = temp[key[0]], key[1]
|
||||
temp = temp[key[0]][key[1]]
|
||||
else:
|
||||
if key not in temp or temp[key] is None:
|
||||
temp[key] = {}
|
||||
parent, parent_key = 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]]
|
||||
clear_orphaned_comments(temp[last_key[0]], temp, last_key[0])
|
||||
else:
|
||||
del temp[last_key]
|
||||
clear_orphaned_comments(temp, parent, parent_key)
|
||||
else:
|
||||
if isinstance(last_key, tuple):
|
||||
if last_key[0] not in temp:
|
||||
|
||||
@@ -36,9 +36,6 @@
|
||||
"ask_an": "Cet objet est-il un(e) <code>{{label}}</code> ?",
|
||||
"ask_a": "Cet objet est-il un(e) <code>{{label}}</code> ?",
|
||||
"ask_full": "Cet objet est-il un(e) <code>{{translatedLabel}}</code> ?"
|
||||
},
|
||||
"toast": {
|
||||
"error": "Impossible d'envoyer à Frigate+. Merci de vérifier votre réseau puis de réessayer."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -71,15 +68,7 @@
|
||||
},
|
||||
"success": "Exportation démarrée avec succès. Consultez le fichier sur la page des exportations.",
|
||||
"view": "Vue",
|
||||
"queued": "Exportation en attente. Consultez la progression sur la page des exportations.",
|
||||
"batchSuccess_one": "Un export démarré. Ouverture du dossier en cours.",
|
||||
"batchSuccess_many": "{{count}} exports démarrés. Ouverture du dossier en cours.",
|
||||
"batchSuccess_other": "{{count}} exports démarrés. Ouverture du dossier en cours.",
|
||||
"batchPartial": "{{successful}} exports ont démarré sur un total de : {{total}}. Échecs : {{failedCameras}}",
|
||||
"batchFailed": "Échec du démarrage de {{total}} exports. Échecs : {{failedCameras}}",
|
||||
"batchQueuedSuccess_one": "Un export rajouté au traitement. Ouverture du dossier en cours.",
|
||||
"batchQueuedSuccess_many": "{{count}} exports rajoutés au traitement. Ouverture du dossier en cours.",
|
||||
"batchQueuedSuccess_other": "{{count}} exports rajoutés au traitement. Ouverture du dossier en cours."
|
||||
"queued": "Exportation en attente. Consultez la progression sur la page des exportations."
|
||||
},
|
||||
"select": "Sélectionner",
|
||||
"name": {
|
||||
@@ -118,14 +107,7 @@
|
||||
"queueingButton": "Mise en file d'attente des exports...",
|
||||
"exportButton_one": "Exporter {{count}} caméra",
|
||||
"exportButton_many": "Exporter {{count}} caméras",
|
||||
"exportButton_other": "Exporter {{count}} caméras",
|
||||
"searchOrSelectGroup": "Chercher ou sélectionner un groupe de caméras...",
|
||||
"selectAll": "Sélectionner toutes les cameras",
|
||||
"clearSelection": "Effacer la sélection",
|
||||
"selectWithActivity": "Caméras avec des objets suivis",
|
||||
"selectGroup": "Sélectionner un groupe",
|
||||
"noMatchingCameras": "Aucune caméra ne correspond à votre recherche",
|
||||
"selectedCount": "{{selected}} / {{total}} sélectionnées"
|
||||
"exportButton_other": "Exporter {{count}} caméras"
|
||||
},
|
||||
"multi": {
|
||||
"title_one": "Export {{count}} revue",
|
||||
|
||||
@@ -98,7 +98,7 @@
|
||||
"confirmDelete": {
|
||||
"title": "Потврдите брисање",
|
||||
"desc": {
|
||||
"selected": "Да ли сте сигурни да желите да обришете све видео снимке повезане са овом ставком? <br /><br /> Држите притиснут <em>Shift</em> тастер да прескочите овај дијалог у будућности."
|
||||
"selected": "Да ли сте сигурни да желите да обришете све видео снимке повезане са овом ставком? <br /><br /> Држите притиснут <em>Shift</em> тастер да прескочите овај дијалог у будућности."
|
||||
},
|
||||
"toast": {
|
||||
"success": "Видео снимак повезан са изабраним ставкама за преглед успешно је обрисан.",
|
||||
|
||||
@@ -2193,7 +2193,7 @@
|
||||
},
|
||||
"retainMode": {
|
||||
"all": "全部",
|
||||
"motion": "画面变动",
|
||||
"motion": "运动",
|
||||
"active_objects": "活动目标"
|
||||
},
|
||||
"previewQuality": {
|
||||
|
||||
Reference in New Issue
Block a user