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_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())
|