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_cli.py ADDED
@@ -0,0 +1,217 @@
1
+ """`cctv-config` — validate our configuration, and generate Frigate's from it.
2
+
3
+ [INV-3](../../../docs/architecture/invariants.md) says camera configuration exists in
4
+ exactly one place, `config/cameras.yml`, and that everything else is generated. This is the
5
+ "everything else".
6
+
7
+ ```bash
8
+ cctv-config validate --cameras config/cameras.yml
9
+ cctv-config generate --cameras config/cameras.yml --out config/frigate/config.yml
10
+ ```
11
+
12
+ `validate` runs the same projection as `generate`, so the retention-headroom rule is
13
+ enforced at validation time rather than at install time — a site that would lose footage
14
+ should find out before it is running, not after an outage.
15
+
16
+ ## What is v0.1 and what is not
17
+
18
+ The generator emits a **generic/CPU host profile**: `ffmpeg` inputs from the camera URLs, a
19
+ CPU detector, detection resolution. Hardware acceleration profiles (`hwaccel_args`, Coral
20
+ detectors, model selection) belong to
21
+ [LLD 07](../../../docs/architecture/lld/07-installer-and-host-profiles.md) and are not
22
+ guessed at here. A generated config that is obviously missing a profile is better than one
23
+ that quietly picked the wrong one.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import argparse
29
+ import sys
30
+ from pathlib import Path
31
+ from typing import Any
32
+
33
+ import yaml
34
+
35
+ from .config import CamerasConfig, ConfigError, load_cameras_config, to_frigate
36
+
37
+ __all__ = ["build_frigate_config", "main", "render"]
38
+
39
+ #: Written into the file itself, not just into this module. Two invariants are at stake and
40
+ #: neither survives as a comment in a code file that a site operator will never read:
41
+ #: INV-3 (this file is derived, so editing it is wasted work) and INV-2 (it carries
42
+ #: credentials, so it must not be committed).
43
+ _HEADER = """\
44
+ # GENERATED FILE — DO NOT EDIT.
45
+ #
46
+ # Written by `cctv-config generate` from {cameras_path}.
47
+ # Camera configuration lives in exactly one place (INV-3). This file is derived from it and
48
+ # any change made here is lost the next time it is generated.
49
+ #
50
+ # THIS FILE CONTAINS CAMERA CREDENTIALS.
51
+ # RTSP URLs carry a username and password, so this file must never enter version control
52
+ # (INV-2). It is listed in .gitignore; keep it there.
53
+ #
54
+ # `auth.enabled: false` below is safe ONLY because Frigate's internal port (5000) is never
55
+ # published outside the compose network — Frigate resolves requests arriving on it as
56
+ # anonymous admin. Publishing that port would turn this file into a remote admin bypass.
57
+ """
58
+
59
+
60
+ def build_frigate_config(config: CamerasConfig) -> dict[str, Any]:
61
+ """Our config plus the v0.1 host profile, as Frigate's config. Deterministic."""
62
+ projected = to_frigate(config)
63
+
64
+ # Reassembled rather than appended to, so the emitted file reads top-down in the order an
65
+ # operator wants to check it: process-wide settings first, then the cameras. `to_frigate`
66
+ # emits `cameras` first because that is the order *it* settles them in, which is a
67
+ # different question.
68
+ cameras = projected.pop("cameras", {})
69
+
70
+ tracked = {
71
+ label for camera in config.enabled_cameras().values() for label in camera.detect.objects
72
+ }
73
+
74
+ out: dict[str, Any] = {
75
+ # Frigate rewrites this itself. Emitted so the file is not read as an ancient config
76
+ # on first start; revisit when the pinned Frigate version moves.
77
+ "version": "0.18-0",
78
+ "mqtt": {"enabled": True, "host": "mosquitto", "port": 1883},
79
+ "auth": {"enabled": False},
80
+ "detectors": {"cpu": {"type": "cpu", "num_threads": 2}},
81
+ "objects": {"track": sorted(tracked)},
82
+ }
83
+ # No `timezone` here, and that is load-bearing: Frigate 0.18 rejects the whole config if
84
+ # a top-level `timezone` is present and starts in safe mode with no cameras. See
85
+ # `to_frigate` for why.
86
+ out["cameras"] = cameras
87
+
88
+ # Anything `to_frigate` learns to emit later still reaches the file. Dropping unknown
89
+ # keys here would make this function silently lossy the next time that one grows.
90
+ out.update(projected)
91
+
92
+ for name, camera in config.enabled_cameras().items():
93
+ entry: dict[str, Any] = cameras.setdefault(name, {})
94
+
95
+ # A sub-stream, when the camera has one, is what detection should use: it is already
96
+ # the low-resolution stream the camera produces for this purpose, so decoding it is
97
+ # cheaper than downscaling the main one. With no sub-stream, the main input takes
98
+ # both roles — one input, because two decoders reading the same URL is pure waste.
99
+ inputs: list[dict[str, Any]] = [{"path": camera.rtsp_main, "roles": ["record"]}]
100
+ if camera.rtsp_sub:
101
+ inputs.append({"path": camera.rtsp_sub, "roles": ["detect"]})
102
+ else:
103
+ inputs[0]["roles"].append("detect")
104
+ entry["ffmpeg"] = {"inputs": inputs}
105
+
106
+ entry["detect"] = {
107
+ "enabled": camera.detect.enabled,
108
+ "width": camera.detect.width,
109
+ "height": camera.detect.height,
110
+ "fps": camera.detect.fps,
111
+ "min_initialized": camera.detect.min_initialized,
112
+ }
113
+ entry["objects"] = {
114
+ "track": list(camera.detect.objects),
115
+ "filters": {
116
+ label: {"min_score": camera.detect.min_score} for label in camera.detect.objects
117
+ },
118
+ }
119
+ entry["snapshots"] = {
120
+ "enabled": camera.snapshots.enabled,
121
+ "retain": {"default": camera.snapshots.retain_days},
122
+ }
123
+
124
+ return out
125
+
126
+
127
+ def render(config: CamerasConfig, *, cameras_path: Path) -> str:
128
+ """The full text of the generated config, header included."""
129
+ body = yaml.safe_dump(build_frigate_config(config), sort_keys=False, default_flow_style=False)
130
+ return _HEADER.format(cameras_path=cameras_path) + body
131
+
132
+
133
+ # ---------------------------------------------------------------------------------------
134
+ # CLI
135
+ # ---------------------------------------------------------------------------------------
136
+
137
+
138
+ def _load(path: Path) -> CamerasConfig | None:
139
+ try:
140
+ config = load_cameras_config(path)
141
+ except (ConfigError, OSError) as exc:
142
+ print(f"error: {exc}", file=sys.stderr)
143
+ return None
144
+
145
+ for warning in config.warnings:
146
+ print(f"warning: {warning}", file=sys.stderr)
147
+ return config
148
+
149
+
150
+ def _cmd_validate(args: argparse.Namespace) -> int:
151
+ config = _load(args.cameras)
152
+ if config is None:
153
+ return 1
154
+
155
+ # Projecting is the point: `to_frigate` enforces the retention-headroom rule (INV-9),
156
+ # so validating without generating would let an unworkable site pass.
157
+ try:
158
+ build_frigate_config(config)
159
+ except ConfigError as exc:
160
+ print(f"error: {exc}", file=sys.stderr)
161
+ return 1
162
+
163
+ names = sorted(config.enabled_cameras())
164
+ if not names:
165
+ print("error: no cameras are enabled", file=sys.stderr)
166
+ return 1
167
+
168
+ print(f"ok: {len(names)} camera(s) enabled: {', '.join(names)}")
169
+ print(f"ok: timezone {config.site.timezone.key}")
170
+ return 0
171
+
172
+
173
+ def _cmd_generate(args: argparse.Namespace) -> int:
174
+ config = _load(args.cameras)
175
+ if config is None:
176
+ return 1
177
+
178
+ try:
179
+ text = render(config, cameras_path=args.cameras)
180
+ except ConfigError as exc:
181
+ print(f"error: {exc}", file=sys.stderr)
182
+ return 1
183
+
184
+ out: Path = args.out
185
+ out.parent.mkdir(parents=True, exist_ok=True)
186
+ out.write_text(text, encoding="utf-8")
187
+
188
+ # Permissions are tightened because the file holds camera credentials. This is not a
189
+ # substitute for keeping it out of version control, and it is not claimed to be:
190
+ # it only narrows who on the host can read it.
191
+ out.chmod(0o600)
192
+ print(f"wrote {out}")
193
+ return 0
194
+
195
+
196
+ def main(argv: list[str] | None = None) -> int:
197
+ parser = argparse.ArgumentParser(
198
+ prog="cctv-config",
199
+ description="Validate cameras.yml and generate Frigate's config from it.",
200
+ )
201
+ sub = parser.add_subparsers(dest="command", required=True)
202
+
203
+ validate = sub.add_parser("validate", help="check the configuration without writing anything")
204
+ validate.add_argument("--cameras", type=Path, required=True, help="path to cameras.yml")
205
+ validate.set_defaults(func=_cmd_validate)
206
+
207
+ generate = sub.add_parser("generate", help="write Frigate's config")
208
+ generate.add_argument("--cameras", type=Path, required=True, help="path to cameras.yml")
209
+ generate.add_argument("--out", type=Path, required=True, help="where to write it")
210
+ generate.set_defaults(func=_cmd_generate)
211
+
212
+ args = parser.parse_args(argv)
213
+ return int(args.func(args))
214
+
215
+
216
+ if __name__ == "__main__": # pragma: no cover
217
+ raise SystemExit(main())