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/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