cctv-stack 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cctv/__init__.py +19 -0
- cctv/atomic.py +184 -0
- cctv/cleanup.py +1014 -0
- cctv/clip.py +278 -0
- cctv/config.py +742 -0
- cctv/config_cli.py +217 -0
- cctv/db.py +1478 -0
- cctv/dispatch.py +270 -0
- cctv/ffprobe.py +246 -0
- cctv/frigate.py +411 -0
- cctv/health.py +413 -0
- cctv/logging.py +157 -0
- cctv/materialise.py +488 -0
- cctv/migrations/0001_init.sql +199 -0
- cctv/migrations/0002_finding_kinds.sql +35 -0
- cctv/mqtt.py +171 -0
- cctv/paths.py +290 -0
- cctv/reviews.py +279 -0
- cctv/storage.py +751 -0
- cctv/sweep.py +254 -0
- cctv/timeutil.py +154 -0
- cctv/uploader.py +1417 -0
- cctv/worker.py +385 -0
- cctv_stack-0.3.0.dist-info/METADATA +410 -0
- cctv_stack-0.3.0.dist-info/RECORD +28 -0
- cctv_stack-0.3.0.dist-info/WHEEL +4 -0
- cctv_stack-0.3.0.dist-info/entry_points.txt +5 -0
- cctv_stack-0.3.0.dist-info/licenses/LICENSE +201 -0
cctv/config.py
ADDED
|
@@ -0,0 +1,742 @@
|
|
|
1
|
+
"""Configuration: one source of truth, and everything else generated.
|
|
2
|
+
|
|
3
|
+
`config/cameras.yml` is the single place a camera is defined
|
|
4
|
+
([INV-3](../../../docs/architecture/invariants.md)). Frigate's config, the monitoring
|
|
5
|
+
config, and the notify config are all *generated* from it — deterministically, same input
|
|
6
|
+
to byte-identical output. A hand edit to a generated file is lost on the next install.
|
|
7
|
+
|
|
8
|
+
The schema is [`docs/architecture/contracts/cameras-config-schema.md`]. Four rules from
|
|
9
|
+
there are worth stating because they are the ones that surprise people:
|
|
10
|
+
|
|
11
|
+
1. **Unknown keys are a hard error.** A typo that silently does nothing is worse than a
|
|
12
|
+
failed install, because the operator believes a setting is in force when it is not.
|
|
13
|
+
2. **Lists are replaced, never concatenated.** `objects: [person]` on one camera replaces
|
|
14
|
+
the default list entirely. Silent list merging is the top cause of "why is it detecting
|
|
15
|
+
cars?".
|
|
16
|
+
3. **`null` means "unset, use the inherited value"** — not "set to nothing".
|
|
17
|
+
4. **A `${VAR}` that is not set is a hard error**, never an empty string. An empty camera
|
|
18
|
+
password produces a confusing offline camera instead of an honest config failure.
|
|
19
|
+
|
|
20
|
+
This module is deliberately dependency-light: plain dataclasses and hand-written
|
|
21
|
+
validation, no schema framework. The error messages are the interface an operator actually
|
|
22
|
+
reads at install time, and a framework's generic errors are worse at that job than a dozen
|
|
23
|
+
explicit checks.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import os
|
|
29
|
+
import re
|
|
30
|
+
from collections.abc import Mapping, Sequence
|
|
31
|
+
from dataclasses import dataclass
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Any
|
|
34
|
+
from zoneinfo import ZoneInfo
|
|
35
|
+
|
|
36
|
+
import yaml
|
|
37
|
+
|
|
38
|
+
from .paths import PathError, validate_camera_name, validate_location
|
|
39
|
+
from .timeutil import TimeConfigError, resolve_zone
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"CameraConfig",
|
|
43
|
+
"CamerasConfig",
|
|
44
|
+
"ClipConfig",
|
|
45
|
+
"ConfigError",
|
|
46
|
+
"DetectConfig",
|
|
47
|
+
"NotifyConfig",
|
|
48
|
+
"RecordConfig",
|
|
49
|
+
"RetentionConfig",
|
|
50
|
+
"SiteConfig",
|
|
51
|
+
"SnapshotsConfig",
|
|
52
|
+
"load_cameras_config",
|
|
53
|
+
"to_frigate",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
RECORD_MODES = frozenset({"motion", "continuous", "alerts", "detections", "off"})
|
|
57
|
+
SEVERITIES = frozenset({"alert", "detection"})
|
|
58
|
+
|
|
59
|
+
#: Below this many days of Frigate-side recording, a clip-worker outage of the same
|
|
60
|
+
#: length causes permanent loss, because the footage is gone before we fetch it
|
|
61
|
+
#: ([INV-9](../../../docs/architecture/invariants.md)). The generator refuses to emit a
|
|
62
|
+
#: shorter window without an explicit override.
|
|
63
|
+
DEFAULT_MIN_HEADROOM_DAYS = 2
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class ConfigError(ValueError):
|
|
67
|
+
"""Invalid configuration. Raised at load, never at run time — exit code 78."""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ---------------------------------------------------------------------------------------
|
|
71
|
+
# The resolved model. Frozen: config is read once and never mutated by a worker.
|
|
72
|
+
# ---------------------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True, slots=True)
|
|
76
|
+
class SiteConfig:
|
|
77
|
+
location: str
|
|
78
|
+
timezone: ZoneInfo
|
|
79
|
+
display_timezone: ZoneInfo
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True, slots=True)
|
|
83
|
+
class DetectConfig:
|
|
84
|
+
enabled: bool = True
|
|
85
|
+
objects: tuple[str, ...] = ("person",)
|
|
86
|
+
width: int = 1280
|
|
87
|
+
height: int = 720
|
|
88
|
+
fps: int = 5
|
|
89
|
+
min_score: float = 0.4
|
|
90
|
+
min_initialized: int = 2
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(frozen=True, slots=True)
|
|
94
|
+
class RecordConfig:
|
|
95
|
+
"""How Frigate retains *its* footage. Not our clip retention — see `RetentionConfig`."""
|
|
96
|
+
|
|
97
|
+
mode: str = "motion"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True, slots=True)
|
|
101
|
+
class ClipConfig:
|
|
102
|
+
"""Our clip shape. `pre_roll_s` and `post_roll_s` are ours alone; `visit_gap_s` is
|
|
103
|
+
projected into Frigate because it defines what a visit *is*
|
|
104
|
+
([ADR-0016](../../../docs/adr/0016-clip-per-review-item.md))."""
|
|
105
|
+
|
|
106
|
+
pre_roll_s: float = 5.0
|
|
107
|
+
post_roll_s: float = 10.0
|
|
108
|
+
#: `None` means "do not emit the key", so Frigate's own defaults apply — 40 s for
|
|
109
|
+
#: alerts, 30 s for detections. Emitting our own default would silently change
|
|
110
|
+
#: behaviour the operator did not ask to change.
|
|
111
|
+
visit_gap_s: int | None = None
|
|
112
|
+
severities: tuple[str, ...] = ("alert", "detection")
|
|
113
|
+
objects: tuple[str, ...] = ("person",)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass(frozen=True, slots=True)
|
|
117
|
+
class RetentionConfig:
|
|
118
|
+
local_days: int = 90
|
|
119
|
+
cloud_days: int = 60
|
|
120
|
+
upload: bool = True
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass(frozen=True, slots=True)
|
|
124
|
+
class SnapshotsConfig:
|
|
125
|
+
enabled: bool = True
|
|
126
|
+
retain_days: int = 30
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass(frozen=True, slots=True)
|
|
130
|
+
class NotifyConfig:
|
|
131
|
+
enabled: bool = True
|
|
132
|
+
labels: tuple[str, ...] = ("person",)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass(frozen=True, slots=True)
|
|
136
|
+
class CameraConfig:
|
|
137
|
+
name: str
|
|
138
|
+
display_name: str
|
|
139
|
+
enabled: bool
|
|
140
|
+
rtsp_main: str
|
|
141
|
+
rtsp_sub: str | None
|
|
142
|
+
detect: DetectConfig
|
|
143
|
+
record: RecordConfig
|
|
144
|
+
clip: ClipConfig
|
|
145
|
+
retention: RetentionConfig
|
|
146
|
+
snapshots: SnapshotsConfig
|
|
147
|
+
notify: NotifyConfig
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@dataclass(frozen=True, slots=True)
|
|
151
|
+
class CamerasConfig:
|
|
152
|
+
site: SiteConfig
|
|
153
|
+
cameras: Mapping[str, CameraConfig]
|
|
154
|
+
#: Non-fatal findings. Collected rather than logged so that the loader stays pure and
|
|
155
|
+
#: the installer can present them all at once instead of one per re-run.
|
|
156
|
+
warnings: tuple[str, ...] = ()
|
|
157
|
+
min_headroom_days: int = DEFAULT_MIN_HEADROOM_DAYS
|
|
158
|
+
|
|
159
|
+
def enabled_cameras(self) -> Mapping[str, CameraConfig]:
|
|
160
|
+
return {n: c for n, c in self.cameras.items() if c.enabled}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# ---------------------------------------------------------------------------------------
|
|
164
|
+
# Loading
|
|
165
|
+
# ---------------------------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
_ENV_RE = re.compile(r"\$\{(?P<name>[A-Za-z_][A-Za-z0-9_]*)\}")
|
|
168
|
+
_TOP_LEVEL_KEYS = frozenset({"site", "defaults", "cameras", "min_headroom_days"})
|
|
169
|
+
_CAMERA_KEYS = frozenset(
|
|
170
|
+
{
|
|
171
|
+
"display_name",
|
|
172
|
+
"enabled",
|
|
173
|
+
"rtsp",
|
|
174
|
+
"detect",
|
|
175
|
+
"record",
|
|
176
|
+
"snapshots",
|
|
177
|
+
"clip",
|
|
178
|
+
"retention",
|
|
179
|
+
"notify",
|
|
180
|
+
}
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
_YAML_BOOL_TAG = "tag:yaml.org,2002:bool"
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class _Yaml12Loader(yaml.SafeLoader):
|
|
188
|
+
"""`safe_load`, but with YAML 1.2 booleans instead of YAML 1.1's.
|
|
189
|
+
|
|
190
|
+
PyYAML implements YAML 1.1, where `off`, `on`, `yes` and `no` are booleans. That makes
|
|
191
|
+
`record: {mode: off}` load as `False` — a value *our own schema documents* silently
|
|
192
|
+
becoming a boolean. The same trap would turn a camera named `no` or a location named
|
|
193
|
+
`on` into a boolean, and the resulting error would point at the wrong place entirely.
|
|
194
|
+
|
|
195
|
+
YAML 1.2 dropped those spellings, and every modern parser (ruamel, Go's `yaml.v3`,
|
|
196
|
+
Kubernetes) follows it. This loader honours only `true` and `false`, so our config
|
|
197
|
+
means what the schema says it means.
|
|
198
|
+
|
|
199
|
+
Note the consequence: `enabled: no` is now the string `"no"` and is refused, because
|
|
200
|
+
guessing that a string means a boolean is how these bugs start. Write `false`.
|
|
201
|
+
"""
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# Copy before mutating: `yaml_implicit_resolvers` is shared, so editing it in place would
|
|
205
|
+
# change `SafeLoader` for everything else in the process, including libraries.
|
|
206
|
+
_Yaml12Loader.yaml_implicit_resolvers = {
|
|
207
|
+
first_char: [(tag, regexp) for tag, regexp in resolvers if tag != _YAML_BOOL_TAG]
|
|
208
|
+
for first_char, resolvers in yaml.SafeLoader.yaml_implicit_resolvers.items()
|
|
209
|
+
}
|
|
210
|
+
_Yaml12Loader.add_implicit_resolver(
|
|
211
|
+
_YAML_BOOL_TAG,
|
|
212
|
+
re.compile(r"^(?:true|True|TRUE|false|False|FALSE)$"),
|
|
213
|
+
list("tTfF"),
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def load_cameras_config(
|
|
218
|
+
path: str | os.PathLike[str],
|
|
219
|
+
*,
|
|
220
|
+
environ: Mapping[str, str] | None = None,
|
|
221
|
+
) -> CamerasConfig:
|
|
222
|
+
"""Read, validate, and resolve `cameras.yml`.
|
|
223
|
+
|
|
224
|
+
`environ` is injectable so tests never depend on the ambient environment, and so the
|
|
225
|
+
installer can pass an explicit set rather than inheriting whatever the shell has.
|
|
226
|
+
|
|
227
|
+
Raises:
|
|
228
|
+
ConfigError: anything at all is wrong. There is no partial success.
|
|
229
|
+
"""
|
|
230
|
+
environ = os.environ if environ is None else environ
|
|
231
|
+
source = Path(path)
|
|
232
|
+
|
|
233
|
+
try:
|
|
234
|
+
raw = yaml.load(source.read_text(encoding="utf-8"), Loader=_Yaml12Loader)
|
|
235
|
+
except FileNotFoundError as exc:
|
|
236
|
+
raise ConfigError(f"{source}: not found.") from exc
|
|
237
|
+
except yaml.YAMLError as exc:
|
|
238
|
+
raise ConfigError(f"{source}: not valid YAML — {exc}") from exc
|
|
239
|
+
|
|
240
|
+
if not isinstance(raw, Mapping):
|
|
241
|
+
raise ConfigError(
|
|
242
|
+
f"{source}: the top level must be a mapping with `site`, `defaults` and `cameras` keys."
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
data = _interpolate(raw, environ, where=str(source))
|
|
246
|
+
_check_keys(data, _TOP_LEVEL_KEYS, where=str(source))
|
|
247
|
+
|
|
248
|
+
if "site" not in data:
|
|
249
|
+
raise ConfigError(f"{source}: `site` is required — `location` and `timezone`.")
|
|
250
|
+
if "cameras" not in data or not data["cameras"]:
|
|
251
|
+
raise ConfigError(f"{source}: `cameras` must define at least one camera.")
|
|
252
|
+
|
|
253
|
+
warnings: list[str] = []
|
|
254
|
+
site = _parse_site(data["site"], warnings)
|
|
255
|
+
defaults = data.get("defaults") or {}
|
|
256
|
+
if not isinstance(defaults, Mapping):
|
|
257
|
+
raise ConfigError(f"{source}: `defaults` must be a mapping.")
|
|
258
|
+
|
|
259
|
+
cameras_raw = data["cameras"]
|
|
260
|
+
if not isinstance(cameras_raw, Mapping):
|
|
261
|
+
raise ConfigError(f"{source}: `cameras` must be a mapping of name to settings.")
|
|
262
|
+
|
|
263
|
+
cameras: dict[str, CameraConfig] = {}
|
|
264
|
+
for name, override in cameras_raw.items():
|
|
265
|
+
where = f"cameras.{name}"
|
|
266
|
+
if not isinstance(override, Mapping):
|
|
267
|
+
raise ConfigError(f"{where}: must be a mapping.")
|
|
268
|
+
merged = _deep_merge(defaults, override)
|
|
269
|
+
cameras[name] = _build_camera(name, merged, site, warnings, where)
|
|
270
|
+
|
|
271
|
+
min_headroom = data.get("min_headroom_days", DEFAULT_MIN_HEADROOM_DAYS)
|
|
272
|
+
if not isinstance(min_headroom, int) or isinstance(min_headroom, bool) or min_headroom < 1:
|
|
273
|
+
raise ConfigError(f"{source}: `min_headroom_days` must be an integer >= 1.")
|
|
274
|
+
|
|
275
|
+
return CamerasConfig(
|
|
276
|
+
site=site,
|
|
277
|
+
cameras=cameras,
|
|
278
|
+
warnings=tuple(warnings),
|
|
279
|
+
min_headroom_days=min_headroom,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _interpolate(value: Any, environ: Mapping[str, str], *, where: str) -> Any:
|
|
284
|
+
"""Resolve `${VAR}` from the environment, refusing to substitute an empty string."""
|
|
285
|
+
|
|
286
|
+
if isinstance(value, str):
|
|
287
|
+
|
|
288
|
+
def replace(match: re.Match[str]) -> str:
|
|
289
|
+
name = match["name"]
|
|
290
|
+
if name not in environ:
|
|
291
|
+
raise ConfigError(
|
|
292
|
+
f"{where}: ${{{name}}} is referenced but not set. Add it to `.env` "
|
|
293
|
+
f"(see `.env.example`). Substituting an empty string would produce a "
|
|
294
|
+
f"confusing failure later instead of an honest one now."
|
|
295
|
+
)
|
|
296
|
+
return environ[name]
|
|
297
|
+
|
|
298
|
+
return _ENV_RE.sub(replace, value)
|
|
299
|
+
if isinstance(value, Mapping):
|
|
300
|
+
return {k: _interpolate(v, environ, where=f"{where}.{k}") for k, v in value.items()}
|
|
301
|
+
if isinstance(value, list):
|
|
302
|
+
return [_interpolate(v, environ, where=f"{where}[]") for v in value]
|
|
303
|
+
return value
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _check_keys(data: Mapping[str, Any], allowed: frozenset[str], *, where: str) -> None:
|
|
307
|
+
unknown = sorted(set(data) - allowed)
|
|
308
|
+
if unknown:
|
|
309
|
+
raise ConfigError(
|
|
310
|
+
f"{where}: unknown key(s) {unknown}. Allowed: {sorted(allowed)}. "
|
|
311
|
+
f"Unknown keys are refused rather than ignored, because a typo that silently "
|
|
312
|
+
f"does nothing means believing a setting is in force when it is not."
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _deep_merge(base: Mapping[str, Any], override: Mapping[str, Any]) -> dict[str, Any]:
|
|
317
|
+
"""`defaults` under a camera. Nested mappings merge; lists and scalars replace.
|
|
318
|
+
|
|
319
|
+
`None` means "not set here" at **every** level, so it is dropped from the base as
|
|
320
|
+
well as skipped in the override. That is what makes `null` consistent: below `defaults`
|
|
321
|
+
there is something to inherit, and inside `defaults` there is not, so the built-in
|
|
322
|
+
default applies. Treating the two differently would be a trap.
|
|
323
|
+
"""
|
|
324
|
+
out: dict[str, Any] = {k: v for k, v in base.items() if v is not None}
|
|
325
|
+
for key, value in override.items():
|
|
326
|
+
if value is None:
|
|
327
|
+
continue
|
|
328
|
+
current = out.get(key)
|
|
329
|
+
if isinstance(current, Mapping) and isinstance(value, Mapping):
|
|
330
|
+
out[key] = _deep_merge(current, value)
|
|
331
|
+
else:
|
|
332
|
+
out[key] = value
|
|
333
|
+
return out
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _parse_site(data: Mapping[str, Any], warnings: list[str]) -> SiteConfig:
|
|
337
|
+
_check_keys(data, frozenset({"location", "timezone", "display_timezone"}), where="site")
|
|
338
|
+
|
|
339
|
+
if "location" not in data:
|
|
340
|
+
raise ConfigError("site.location is required.")
|
|
341
|
+
if "timezone" not in data:
|
|
342
|
+
raise ConfigError(
|
|
343
|
+
"site.timezone is required and is never guessed from the host. A container's "
|
|
344
|
+
"TZ is easy to get wrong across six services, and wrong here means silently "
|
|
345
|
+
"misplaced clips (ADR-0017)."
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
try:
|
|
349
|
+
location = validate_location(data["location"])
|
|
350
|
+
except PathError as exc:
|
|
351
|
+
raise ConfigError(f"site.location: {exc}") from exc
|
|
352
|
+
|
|
353
|
+
timezone = _zone(data["timezone"], "site.timezone")
|
|
354
|
+
|
|
355
|
+
if "display_timezone" in data:
|
|
356
|
+
display = _zone(data["display_timezone"], "site.display_timezone")
|
|
357
|
+
if display.key != timezone.key:
|
|
358
|
+
warnings.append(
|
|
359
|
+
f"site.display_timezone ({display.key}) differs from site.timezone "
|
|
360
|
+
f"({timezone.key}): clip filenames and log timestamps will disagree."
|
|
361
|
+
)
|
|
362
|
+
else:
|
|
363
|
+
display = timezone
|
|
364
|
+
|
|
365
|
+
return SiteConfig(location=location, timezone=timezone, display_timezone=display)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _zone(name: Any, where: str) -> ZoneInfo:
|
|
369
|
+
try:
|
|
370
|
+
return resolve_zone(name)
|
|
371
|
+
except TimeConfigError as exc:
|
|
372
|
+
raise ConfigError(f"{where}: {exc}") from exc
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _build_camera(
|
|
376
|
+
name: str,
|
|
377
|
+
data: Mapping[str, Any],
|
|
378
|
+
site: SiteConfig,
|
|
379
|
+
warnings: list[str],
|
|
380
|
+
where: str,
|
|
381
|
+
) -> CameraConfig:
|
|
382
|
+
try:
|
|
383
|
+
validate_camera_name(name)
|
|
384
|
+
except PathError as exc:
|
|
385
|
+
raise ConfigError(f"cameras: {exc}") from exc
|
|
386
|
+
|
|
387
|
+
_check_keys(data, _CAMERA_KEYS, where=where)
|
|
388
|
+
|
|
389
|
+
enabled = _bool(data.get("enabled", True), f"{where}.enabled")
|
|
390
|
+
display_name = data.get("display_name", name)
|
|
391
|
+
if not isinstance(display_name, str) or not display_name:
|
|
392
|
+
raise ConfigError(f"{where}.display_name must be a non-empty string.")
|
|
393
|
+
|
|
394
|
+
detect = _build_detect(data.get("detect") or {}, where)
|
|
395
|
+
|
|
396
|
+
rtsp = data.get("rtsp") or {}
|
|
397
|
+
if not isinstance(rtsp, Mapping):
|
|
398
|
+
raise ConfigError(f"{where}.rtsp must be a mapping with `main` and optional `sub`.")
|
|
399
|
+
_check_keys(rtsp, frozenset({"main", "sub"}), where=f"{where}.rtsp")
|
|
400
|
+
|
|
401
|
+
rtsp_main = rtsp.get("main")
|
|
402
|
+
rtsp_sub = rtsp.get("sub")
|
|
403
|
+
|
|
404
|
+
if enabled:
|
|
405
|
+
if not rtsp_main:
|
|
406
|
+
raise ConfigError(f"{where}.rtsp.main is required for an enabled camera.")
|
|
407
|
+
_require_rtsp_scheme(rtsp_main, f"{where}.rtsp.main")
|
|
408
|
+
if rtsp_sub:
|
|
409
|
+
_require_rtsp_scheme(rtsp_sub, f"{where}.rtsp.sub")
|
|
410
|
+
elif detect.enabled:
|
|
411
|
+
warnings.append(
|
|
412
|
+
f"{where}: no `rtsp.sub`, so detection runs on the main stream. That is "
|
|
413
|
+
f"usually several times the pixels needed and shows up as CPU load."
|
|
414
|
+
)
|
|
415
|
+
clip = _build_clip(data.get("clip") or {}, detect, warnings, where)
|
|
416
|
+
retention = _build_retention(data.get("retention") or {}, where)
|
|
417
|
+
snapshots = _build_snapshots(data.get("snapshots") or {}, where)
|
|
418
|
+
|
|
419
|
+
record_raw = data.get("record") or {}
|
|
420
|
+
if not isinstance(record_raw, Mapping):
|
|
421
|
+
raise ConfigError(f"{where}.record must be a mapping.")
|
|
422
|
+
_check_keys(record_raw, frozenset({"mode"}), where=f"{where}.record")
|
|
423
|
+
mode = record_raw.get("mode", "motion")
|
|
424
|
+
if mode not in RECORD_MODES:
|
|
425
|
+
raise ConfigError(f"{where}.record.mode: {mode!r} is not one of {sorted(RECORD_MODES)}.")
|
|
426
|
+
|
|
427
|
+
notify = _build_notify(data.get("notify") or {}, detect, where)
|
|
428
|
+
|
|
429
|
+
return CameraConfig(
|
|
430
|
+
name=name,
|
|
431
|
+
display_name=display_name,
|
|
432
|
+
enabled=enabled,
|
|
433
|
+
rtsp_main=rtsp_main or "",
|
|
434
|
+
rtsp_sub=rtsp_sub or None,
|
|
435
|
+
detect=detect,
|
|
436
|
+
record=RecordConfig(mode=mode),
|
|
437
|
+
clip=clip,
|
|
438
|
+
retention=retention,
|
|
439
|
+
snapshots=snapshots,
|
|
440
|
+
notify=notify,
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _build_detect(data: Mapping[str, Any], where: str) -> DetectConfig:
|
|
445
|
+
_check_keys(
|
|
446
|
+
data,
|
|
447
|
+
frozenset(
|
|
448
|
+
{
|
|
449
|
+
"enabled",
|
|
450
|
+
"objects",
|
|
451
|
+
"width",
|
|
452
|
+
"height",
|
|
453
|
+
"fps",
|
|
454
|
+
"min_score",
|
|
455
|
+
"min_initialized",
|
|
456
|
+
}
|
|
457
|
+
),
|
|
458
|
+
where=f"{where}.detect",
|
|
459
|
+
)
|
|
460
|
+
enabled = _bool(data.get("enabled", True), f"{where}.detect.enabled")
|
|
461
|
+
objects = _str_tuple(data.get("objects", ("person",)), f"{where}.detect.objects")
|
|
462
|
+
|
|
463
|
+
if enabled and not objects:
|
|
464
|
+
raise ConfigError(
|
|
465
|
+
f"{where}.detect.objects is empty while detection is enabled: nothing would "
|
|
466
|
+
f"ever be detected, and the camera would look healthy while doing nothing."
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
return DetectConfig(
|
|
470
|
+
enabled=enabled,
|
|
471
|
+
objects=objects,
|
|
472
|
+
width=_int(data.get("width", 1280), f"{where}.detect.width", low=64, high=8192),
|
|
473
|
+
height=_int(data.get("height", 720), f"{where}.detect.height", low=64, high=8192),
|
|
474
|
+
fps=_int(data.get("fps", 5), f"{where}.detect.fps", low=1, high=15),
|
|
475
|
+
min_score=_float(
|
|
476
|
+
data.get("min_score", 0.4), f"{where}.detect.min_score", low=0.0, high=1.0
|
|
477
|
+
),
|
|
478
|
+
min_initialized=_int(
|
|
479
|
+
data.get("min_initialized", 2), f"{where}.detect.min_initialized", low=1
|
|
480
|
+
),
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _build_clip(
|
|
485
|
+
data: Mapping[str, Any],
|
|
486
|
+
detect: DetectConfig,
|
|
487
|
+
warnings: list[str],
|
|
488
|
+
where: str,
|
|
489
|
+
) -> ClipConfig:
|
|
490
|
+
_check_keys(
|
|
491
|
+
data,
|
|
492
|
+
frozenset({"pre_roll_s", "post_roll_s", "visit_gap_s", "severities", "objects"}),
|
|
493
|
+
where=f"{where}.clip",
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
pre_roll = _float(data.get("pre_roll_s", 5.0), f"{where}.clip.pre_roll_s", low=0.0, high=60.0)
|
|
497
|
+
if pre_roll > 30:
|
|
498
|
+
warnings.append(
|
|
499
|
+
f"{where}.clip.pre_roll_s is {pre_roll}s. Pre-roll costs storage on every "
|
|
500
|
+
f"clip; above 30s it is usually a sign the value should be per-camera."
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
severities = _str_tuple(
|
|
504
|
+
data.get("severities", ("alert", "detection")), f"{where}.clip.severities"
|
|
505
|
+
)
|
|
506
|
+
unknown = sorted(set(severities) - SEVERITIES)
|
|
507
|
+
if unknown:
|
|
508
|
+
raise ConfigError(f"{where}.clip.severities: {unknown} not in {sorted(SEVERITIES)}.")
|
|
509
|
+
if not severities:
|
|
510
|
+
raise ConfigError(
|
|
511
|
+
f"{where}.clip.severities is empty: no severity would ever produce a clip."
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
objects = _str_tuple(data.get("objects", ("person",)), f"{where}.clip.objects")
|
|
515
|
+
if not objects:
|
|
516
|
+
raise ConfigError(f"{where}.clip.objects is empty: no review would be clipped.")
|
|
517
|
+
not_tracked = sorted(set(objects) - set(detect.objects))
|
|
518
|
+
if not_tracked:
|
|
519
|
+
raise ConfigError(
|
|
520
|
+
f"{where}.clip.objects: {not_tracked} is not in `detect.objects` "
|
|
521
|
+
f"({list(detect.objects)}). Frigate cannot report an object it was never asked "
|
|
522
|
+
f"to track, so a review containing it can never occur."
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
visit_gap_raw = data.get("visit_gap_s")
|
|
526
|
+
visit_gap = None
|
|
527
|
+
if visit_gap_raw is not None:
|
|
528
|
+
visit_gap = _int(visit_gap_raw, f"{where}.clip.visit_gap_s", low=5, high=3600)
|
|
529
|
+
|
|
530
|
+
return ClipConfig(
|
|
531
|
+
pre_roll_s=pre_roll,
|
|
532
|
+
post_roll_s=_float(
|
|
533
|
+
data.get("post_roll_s", 10.0),
|
|
534
|
+
f"{where}.clip.post_roll_s",
|
|
535
|
+
low=0.0,
|
|
536
|
+
high=120.0,
|
|
537
|
+
),
|
|
538
|
+
visit_gap_s=visit_gap,
|
|
539
|
+
severities=severities,
|
|
540
|
+
objects=objects,
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _build_retention(data: Mapping[str, Any], where: str) -> RetentionConfig:
|
|
545
|
+
_check_keys(
|
|
546
|
+
data,
|
|
547
|
+
frozenset({"local_days", "cloud_days", "upload"}),
|
|
548
|
+
where=f"{where}.retention",
|
|
549
|
+
)
|
|
550
|
+
return RetentionConfig(
|
|
551
|
+
local_days=_int(
|
|
552
|
+
data.get("local_days", 90),
|
|
553
|
+
f"{where}.retention.local_days",
|
|
554
|
+
low=1,
|
|
555
|
+
high=3650,
|
|
556
|
+
),
|
|
557
|
+
cloud_days=_int(
|
|
558
|
+
data.get("cloud_days", 60),
|
|
559
|
+
f"{where}.retention.cloud_days",
|
|
560
|
+
low=1,
|
|
561
|
+
high=3650,
|
|
562
|
+
),
|
|
563
|
+
upload=_bool(data.get("upload", True), f"{where}.retention.upload"),
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _build_snapshots(data: Mapping[str, Any], where: str) -> SnapshotsConfig:
|
|
568
|
+
_check_keys(data, frozenset({"enabled", "retain_days"}), where=f"{where}.snapshots")
|
|
569
|
+
return SnapshotsConfig(
|
|
570
|
+
enabled=_bool(data.get("enabled", True), f"{where}.snapshots.enabled"),
|
|
571
|
+
retain_days=_int(data.get("retain_days", 30), f"{where}.snapshots.retain_days", low=1),
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def _build_notify(data: Mapping[str, Any], detect: DetectConfig, where: str) -> NotifyConfig:
|
|
576
|
+
_check_keys(data, frozenset({"enabled", "labels"}), where=f"{where}.notify")
|
|
577
|
+
labels = _str_tuple(data.get("labels", ("person",)), f"{where}.notify.labels")
|
|
578
|
+
not_tracked = sorted(set(labels) - set(detect.objects))
|
|
579
|
+
if not_tracked:
|
|
580
|
+
raise ConfigError(
|
|
581
|
+
f"{where}.notify.labels: {not_tracked} is not in `detect.objects` "
|
|
582
|
+
f"({list(detect.objects)}); nothing would ever trigger it."
|
|
583
|
+
)
|
|
584
|
+
return NotifyConfig(
|
|
585
|
+
enabled=_bool(data.get("enabled", True), f"{where}.notify.enabled"),
|
|
586
|
+
labels=labels,
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
# ---------------------------------------------------------------------------------------
|
|
591
|
+
# Scalars. Every one reports where it came from, because that is the only useful thing
|
|
592
|
+
# a config error can say.
|
|
593
|
+
# ---------------------------------------------------------------------------------------
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def _require_rtsp_scheme(value: Any, where: str) -> None:
|
|
597
|
+
if not isinstance(value, str) or not value.startswith(("rtsp://", "rtsps://")):
|
|
598
|
+
raise ConfigError(f"{where}: must be an rtsp:// or rtsps:// URL.")
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def _bool(value: Any, where: str) -> bool:
|
|
602
|
+
if not isinstance(value, bool):
|
|
603
|
+
raise ConfigError(
|
|
604
|
+
f"{where}: must be `true` or `false`, got {value!r}. YAML 1.2 is in force, so "
|
|
605
|
+
f"`yes`, `no`, `on` and `off` are ordinary strings here — that is deliberate, "
|
|
606
|
+
f"because `record.mode: off` is a documented value and YAML 1.1 would have "
|
|
607
|
+
f"read it as a boolean."
|
|
608
|
+
)
|
|
609
|
+
return value
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def _int(value: Any, where: str, *, low: int, high: int | None = None) -> int:
|
|
613
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
614
|
+
raise ConfigError(f"{where}: must be an integer, got {value!r}.")
|
|
615
|
+
if value < low or (high is not None and value > high):
|
|
616
|
+
bound = f"{low}..{high}" if high is not None else f">= {low}"
|
|
617
|
+
raise ConfigError(f"{where}: {value} is outside {bound}.")
|
|
618
|
+
return value
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def _float(value: Any, where: str, *, low: float, high: float) -> float:
|
|
622
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
623
|
+
raise ConfigError(f"{where}: must be a number, got {value!r}.")
|
|
624
|
+
if not low <= float(value) <= high:
|
|
625
|
+
raise ConfigError(f"{where}: {value} is outside {low}..{high}.")
|
|
626
|
+
return float(value)
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def _str_tuple(value: Any, where: str) -> tuple[str, ...]:
|
|
630
|
+
if isinstance(value, str) or not isinstance(value, Sequence):
|
|
631
|
+
raise ConfigError(f"{where}: must be a list, got {value!r}.")
|
|
632
|
+
for item in value:
|
|
633
|
+
if not isinstance(item, str) or not item:
|
|
634
|
+
raise ConfigError(f"{where}: every entry must be a non-empty string.")
|
|
635
|
+
return tuple(value)
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
# ---------------------------------------------------------------------------------------
|
|
639
|
+
# Generation
|
|
640
|
+
# ---------------------------------------------------------------------------------------
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def to_frigate(config: CamerasConfig, *, allow_short_headroom: bool = False) -> dict[str, Any]:
|
|
644
|
+
"""Generate Frigate's config from ours. Deterministic: same input, same output.
|
|
645
|
+
|
|
646
|
+
**Partial on purpose.** This emits what the design has actually settled — the review
|
|
647
|
+
cutoff and Frigate-side `record:` retention. The parts that need the host profile
|
|
648
|
+
([`lld/07`](../../../docs/architecture/lld/07-installer-and-host-profiles.md))
|
|
649
|
+
— `ffmpeg` inputs and `hwaccel_args`, `detectors`, `model`, `objects.track` — land with
|
|
650
|
+
LLD 07, because emitting them requires knowing which accelerator exists. A generator
|
|
651
|
+
that guessed would be worse than one that is obviously incomplete.
|
|
652
|
+
|
|
653
|
+
**`site.timezone` is deliberately absent from the result.** Frigate 0.18 removed its
|
|
654
|
+
top-level `timezone` key, and rejects the *entire* config when one is present
|
|
655
|
+
(`Extra inputs are not permitted`) — after which it starts in safe mode with no cameras
|
|
656
|
+
at all. Frigate's own zone comes from the container's `TZ`, and ADR-0017 already treats
|
|
657
|
+
that as Frigate's business: our clip paths are rendered from `site.timezone`, and a
|
|
658
|
+
disagreement between the two is a `doctor.sh` check rather than something this file can
|
|
659
|
+
settle. Do not re-add it.
|
|
660
|
+
"""
|
|
661
|
+
out: dict[str, Any] = {
|
|
662
|
+
"cameras": {},
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
for name, camera in config.enabled_cameras().items():
|
|
666
|
+
entry: dict[str, Any] = {}
|
|
667
|
+
|
|
668
|
+
# The visit threshold is projected, not filtered, because it defines what a visit
|
|
669
|
+
# is — by the time we see a review it is already too late to regroup it.
|
|
670
|
+
if camera.clip.visit_gap_s is not None:
|
|
671
|
+
entry["review"] = {
|
|
672
|
+
"alerts": {"cutoff_time": camera.clip.visit_gap_s},
|
|
673
|
+
"detections": {"cutoff_time": camera.clip.visit_gap_s},
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
record = _record_block(camera.record.mode)
|
|
677
|
+
if record is not None:
|
|
678
|
+
headroom = _min_retention_days(record)
|
|
679
|
+
if headroom < config.min_headroom_days and not allow_short_headroom:
|
|
680
|
+
raise ConfigError(
|
|
681
|
+
f"cameras.{name}.record.mode: {camera.record.mode!r} yields {headroom} "
|
|
682
|
+
f"day(s) of Frigate-side recording, below min_headroom_days "
|
|
683
|
+
f"({config.min_headroom_days}). That window is the clip-worker's "
|
|
684
|
+
f"recovery budget (INV-9): an outage of the same length loses footage "
|
|
685
|
+
f"permanently. Raise it, or pass allow_short_headroom to accept the risk."
|
|
686
|
+
)
|
|
687
|
+
entry["record"] = record
|
|
688
|
+
|
|
689
|
+
out["cameras"][name] = entry
|
|
690
|
+
|
|
691
|
+
return out
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
#: `record.mode` → Frigate retention blocks. From `cameras-config-schema.md` §4.1.
|
|
695
|
+
_RECORD_BLOCKS: dict[str, dict[str, Any]] = {
|
|
696
|
+
"continuous": {
|
|
697
|
+
"enabled": True,
|
|
698
|
+
"continuous": {"days": 7},
|
|
699
|
+
"motion": {"days": 7},
|
|
700
|
+
"alerts": {"retain": {"days": 30}},
|
|
701
|
+
"detections": {"retain": {"days": 14}},
|
|
702
|
+
},
|
|
703
|
+
"motion": {
|
|
704
|
+
"enabled": True,
|
|
705
|
+
"continuous": {"days": 0},
|
|
706
|
+
"motion": {"days": 3},
|
|
707
|
+
"alerts": {"retain": {"days": 30}},
|
|
708
|
+
"detections": {"retain": {"days": 14}},
|
|
709
|
+
},
|
|
710
|
+
"alerts": {
|
|
711
|
+
"enabled": True,
|
|
712
|
+
"continuous": {"days": 0},
|
|
713
|
+
"alerts": {"retain": {"days": 30}},
|
|
714
|
+
},
|
|
715
|
+
"detections": {
|
|
716
|
+
"enabled": True,
|
|
717
|
+
"continuous": {"days": 0},
|
|
718
|
+
"detections": {"retain": {"days": 30}},
|
|
719
|
+
},
|
|
720
|
+
"off": {"enabled": False},
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def _record_block(mode: str) -> dict[str, Any] | None:
|
|
725
|
+
return _RECORD_BLOCKS[mode]
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def _min_retention_days(record: Mapping[str, Any]) -> int:
|
|
729
|
+
"""The shortest non-zero retention window in a generated `record:` block.
|
|
730
|
+
|
|
731
|
+
Zero means "off" for that category, so it does not bound anything — including it
|
|
732
|
+
would make every mode look like a zero-day window.
|
|
733
|
+
"""
|
|
734
|
+
days: list[int] = []
|
|
735
|
+
for key in ("continuous", "motion", "alerts", "detections"):
|
|
736
|
+
block = record.get(key)
|
|
737
|
+
if not isinstance(block, Mapping):
|
|
738
|
+
continue
|
|
739
|
+
value = block.get("days") or (block.get("retain") or {}).get("days")
|
|
740
|
+
if isinstance(value, int) and value > 0:
|
|
741
|
+
days.append(value)
|
|
742
|
+
return min(days) if days else 0
|