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/worker.py ADDED
@@ -0,0 +1,385 @@
1
+ """The clip-worker service — a review becomes a file at the canonical path.
2
+
3
+ This is the v0.1 happy path and deliberately nothing else. No upload, no retention, no
4
+ health surface. That narrowing is safe rather than merely convenient: with nothing deleting
5
+ anything, [INV-1](../../../docs/architecture/invariants.md) holds trivially and the two
6
+ riskiest failure modes in the design — deleting a clip that was never backed up, and a disk
7
+ that fills with no signal — are simply not present yet.
8
+
9
+ ## Two inputs, one worker
10
+
11
+ ```text
12
+ frigate/reviews (MQTT) ──→ ReviewDispatcher ──┐
13
+ ├──→ decide() ──→ ClipMaterialiser
14
+ GET /api/review (sweep) ────────────────────────┘
15
+ ```
16
+
17
+ Neither alone is sufficient. MQTT alone loses clips whenever the broker or this process is
18
+ down, and Frigate closes a review on restart **without publishing `end` at all** — measured,
19
+ with the subscriber attached to a broker that never restarted (integration contract §3.5).
20
+ The sweep alone would work, but would delay every clip by its interval. So MQTT is the
21
+ latency optimisation and **the sweep is the guarantee**.
22
+
23
+ ## Why both run on one thread
24
+
25
+ They share a `sqlite3` connection and a materialiser, and a connection is not something to
26
+ hand between threads and hope. Rather than sprinkle locks or open a second write connection,
27
+ the MQTT callback does one thing — `queue.Queue.put_nowait`, which *is* thread-safe — and
28
+ **this thread owns every database write**.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import logging
34
+ import os
35
+ import signal
36
+ import socket
37
+ import sqlite3
38
+ import sys
39
+ import threading
40
+ from collections.abc import Callable, Mapping, Sequence
41
+ from dataclasses import dataclass
42
+ from pathlib import Path
43
+
44
+ from . import db, timeutil
45
+ from .config import CamerasConfig, ConfigError, load_cameras_config
46
+ from .dispatch import ReviewDispatcher
47
+ from .frigate import FrigateClient
48
+ from .logging import configure_logging
49
+ from .materialise import ClipMaterialiser, FrigateSource
50
+ from .mqtt import MqttUnavailable, ReviewSubscriber
51
+ from .reviews import ReviewCandidate, decide
52
+ from .sweep import ReviewSweep, SweepResult
53
+
54
+ __all__ = ["EXIT_CONFIG", "EXIT_UNAVAILABLE", "ClipWorker", "Settings", "main"]
55
+
56
+ logger = logging.getLogger(__name__)
57
+
58
+ #: `EX_CONFIG` / `EX_TEMPFAIL` from `sysexits.h`, per LLD 00 §3. The distinction is what
59
+ #: lets a supervisor decide whether restarting could help: a bad config file never becomes
60
+ #: good, a database that is briefly locked might.
61
+ EXIT_CONFIG = 78
62
+ EXIT_UNAVAILABLE = 75
63
+
64
+ DEFAULT_SWEEP_INTERVAL_S = 60.0
65
+
66
+
67
+ @dataclass(frozen=True, slots=True)
68
+ class Settings:
69
+ """Every input is an environment variable, so there are no CLI arguments to get wrong."""
70
+
71
+ cameras_path: Path
72
+ data_dir: Path
73
+ db_path: Path
74
+ frigate_url: str
75
+ #: `None` means sweep-only. Expressed as "no host configured" rather than
76
+ #: "mqtt_enabled: false" because an empty value and a false value would otherwise be
77
+ #: three states to reason about instead of two.
78
+ mqtt_host: str | None
79
+ mqtt_port: int
80
+ mqtt_topic_prefix: str
81
+ mqtt_username: str | None
82
+ mqtt_password: str | None
83
+ sweep_interval_s: float
84
+ log_level: str | None
85
+
86
+ @classmethod
87
+ def from_environ(cls, environ: Mapping[str, str] | None = None) -> Settings:
88
+ env = os.environ if environ is None else environ
89
+
90
+ config_dir = Path(env.get("CCTV_CONFIG_DIR", "/config"))
91
+ data_dir = Path(env.get("CCTV_DATA_DIR", "/data"))
92
+ # An empty string is treated as absent rather than as a host named "" — which is
93
+ # the shape `CCTV_MQTT_HOST=` takes in a `.env` file when someone comments it out
94
+ # with a value still on the line.
95
+ mqtt_host = (env.get("CCTV_MQTT_HOST") or "").strip() or None
96
+
97
+ return cls(
98
+ cameras_path=Path(env.get("CCTV_CAMERAS", str(config_dir / "cameras.yml"))),
99
+ data_dir=data_dir,
100
+ db_path=Path(env.get("CCTV_DB", str(data_dir / "cctv.db"))),
101
+ frigate_url=env.get("CCTV_FRIGATE_URL", "http://frigate:5000"),
102
+ mqtt_host=mqtt_host,
103
+ mqtt_port=int(env.get("CCTV_MQTT_PORT", "1883")),
104
+ mqtt_topic_prefix=env.get("CCTV_MQTT_TOPIC_PREFIX", "frigate"),
105
+ mqtt_username=(env.get("CCTV_MQTT_USERNAME") or "").strip() or None,
106
+ mqtt_password=env.get("CCTV_MQTT_PASSWORD") or None,
107
+ sweep_interval_s=float(env.get("CCTV_SWEEP_INTERVAL_S", str(DEFAULT_SWEEP_INTERVAL_S))),
108
+ log_level=env.get("CCTV_LOG_LEVEL") or None,
109
+ )
110
+
111
+
112
+ class ClipWorker:
113
+ """One thread that turns reviews into clips, from two sources."""
114
+
115
+ def __init__(
116
+ self,
117
+ *,
118
+ config: CamerasConfig,
119
+ conn: sqlite3.Connection,
120
+ sweep: ReviewSweep,
121
+ materialiser: ClipMaterialiser,
122
+ sweep_interval_s: float = DEFAULT_SWEEP_INTERVAL_S,
123
+ clock: Callable[[], float] = timeutil.now,
124
+ max_pending: int = 1000,
125
+ ) -> None:
126
+ if sweep_interval_s <= 0:
127
+ raise ValueError("sweep_interval_s must be positive.")
128
+
129
+ self.config = config
130
+ self.conn = conn
131
+ self.sweep = sweep
132
+ self.materialiser = materialiser
133
+ self.sweep_interval_s = sweep_interval_s
134
+ self._clock = clock
135
+ self._last_sweep: float | None = None
136
+
137
+ # The worker owns the dispatcher so the handler and the queue cannot be wired to
138
+ # different objects. The alternative — building the dispatcher in `main()` and the
139
+ # worker around it — needs the worker to exist first, which is circular.
140
+ self.dispatcher = ReviewDispatcher(handler=self.handle, max_pending=max_pending)
141
+
142
+ # -----------------------------------------------------------------------------------
143
+ # The handler. Runs on this thread, never on paho's.
144
+ # -----------------------------------------------------------------------------------
145
+
146
+ def handle(self, candidate: ReviewCandidate) -> None:
147
+ """Decide, then materialise, one candidate."""
148
+ camera = self.config.cameras.get(candidate.camera)
149
+ if camera is None or not camera.enabled:
150
+ # An unknown camera is dropped with a warning and no retry loop (M2.7).
151
+ # Retrying would not help: it will not be in `cameras.yml` on the next attempt
152
+ # either, and a retry loop here is one that runs forever.
153
+ logger.warning(
154
+ "review for a camera that is not enabled",
155
+ extra={"camera": candidate.camera, "review_id": candidate.review_id},
156
+ )
157
+ return
158
+
159
+ verdict = decide(
160
+ candidate,
161
+ severities=camera.clip.severities,
162
+ objects=camera.clip.objects,
163
+ known_review_ids=db.known_review_ids(self.conn, [candidate.review_id]),
164
+ )
165
+ if not verdict.materialise:
166
+ logger.info(
167
+ "review skipped",
168
+ extra={
169
+ "camera": candidate.camera,
170
+ "review_id": candidate.review_id,
171
+ "reason": verdict.reason,
172
+ },
173
+ )
174
+ return
175
+
176
+ outcome = self.materialiser.materialise(
177
+ candidate,
178
+ pre_roll_s=camera.clip.pre_roll_s,
179
+ post_roll_s=camera.clip.post_roll_s,
180
+ )
181
+ logger.info(
182
+ "review handled",
183
+ extra={
184
+ "camera": candidate.camera,
185
+ "review_id": candidate.review_id,
186
+ "status": outcome.status,
187
+ "reason": outcome.reason,
188
+ "path": outcome.path,
189
+ "coverage": outcome.coverage_ratio,
190
+ "attempts": outcome.fetch_attempts,
191
+ },
192
+ )
193
+
194
+ # -----------------------------------------------------------------------------------
195
+ # Scheduling
196
+ # -----------------------------------------------------------------------------------
197
+
198
+ def sweep_once(self) -> SweepResult | None:
199
+ """One reconciliation pass. Never raises: a failed sweep must not stop the worker."""
200
+ try:
201
+ result = self.sweep.run(config=self.config)
202
+ except Exception as exc:
203
+ # A sweep that raises must not stop the worker: the sweep is the guarantee, and
204
+ # a worker that exits because the guarantee failed guarantees nothing.
205
+ logger.exception("sweep raised", extra={"reason": str(exc)})
206
+ return None
207
+
208
+ if result.failure is not None:
209
+ logger.warning("sweep could not complete", extra={"reason": result.failure})
210
+ else:
211
+ logger.info(
212
+ "sweep: %d considered, %d materialised, %d skipped, %d deferred, %d failed",
213
+ result.considered,
214
+ result.materialised,
215
+ result.skipped,
216
+ result.deferred,
217
+ result.failed,
218
+ )
219
+ return result
220
+
221
+ def tick(self, *, timeout: float = 1.0) -> None:
222
+ """One unit of work: drain one queued candidate, and sweep when due.
223
+
224
+ `work_once` blocks for up to `timeout`, which is what stops this loop from spinning
225
+ when there is nothing to do — so the sweep is checked at most `timeout` late.
226
+ """
227
+ self.dispatcher.work_once(timeout=timeout)
228
+
229
+ now = self._clock()
230
+ if self._last_sweep is None or (now - self._last_sweep) >= self.sweep_interval_s:
231
+ # Recorded *before* running, so a sweep slower than the interval does not queue
232
+ # up a second one the moment it returns.
233
+ self._last_sweep = now
234
+ self.sweep_once()
235
+
236
+ def run(self, stop: threading.Event, *, timeout: float = 1.0) -> None:
237
+ """Block until `stop` is set.
238
+
239
+ A sweep runs immediately, before the loop. Anything that happened while this process
240
+ was down is already late, and MQTT takes a moment to establish — waiting a full
241
+ interval before recovering would mean every restart has a hole in it.
242
+ """
243
+ self.sweep_once()
244
+ self._last_sweep = self._clock()
245
+
246
+ while not stop.is_set():
247
+ try:
248
+ self.tick(timeout=timeout)
249
+ except Exception:
250
+ # Deliberately broad and deliberately not fatal. This is the outermost loop
251
+ # of a service that is expected to run for years; the alternative is that an
252
+ # unhandled edge case in one review takes down every camera until someone
253
+ # notices. The traceback is logged, so the bug is not hidden — only survived.
254
+ logger.exception("worker tick failed; continuing")
255
+
256
+
257
+ # ---------------------------------------------------------------------------------------
258
+ # Process
259
+ # ---------------------------------------------------------------------------------------
260
+
261
+
262
+ def _install_signal_handlers(stop: threading.Event) -> None:
263
+ """Turn SIGTERM/SIGINT into a cooperative shutdown.
264
+
265
+ Cooperative because the alternative is a `KeyboardInterrupt` raised *inside* a fetch,
266
+ which leaves a `.tmp` file and a half-written clip. The reaper would eventually clean it
267
+ up; stopping between units of work means it never happens.
268
+ """
269
+
270
+ def request_stop(signum: int, _frame: object) -> None:
271
+ logger.info("shutdown requested", extra={"reason": signal.Signals(signum).name})
272
+ stop.set()
273
+
274
+ for signum in (signal.SIGTERM, signal.SIGINT):
275
+ signal.signal(signum, request_stop)
276
+
277
+
278
+ def build_worker(settings: Settings, config: CamerasConfig, conn: sqlite3.Connection) -> ClipWorker:
279
+ """Wire the client, source, materialiser and sweep into one worker.
280
+
281
+ Separate from `main()` so a test can build the whole graph without a process.
282
+ """
283
+ client = FrigateClient(base_url=settings.frigate_url)
284
+ materialiser = ClipMaterialiser(
285
+ conn=conn,
286
+ source=FrigateSource(client),
287
+ data_dir=settings.data_dir,
288
+ location=config.site.location,
289
+ zone=config.site.timezone,
290
+ )
291
+ sweep = ReviewSweep(conn=conn, client=client, materialiser=materialiser)
292
+ return ClipWorker(
293
+ config=config,
294
+ conn=conn,
295
+ sweep=sweep,
296
+ materialiser=materialiser,
297
+ sweep_interval_s=settings.sweep_interval_s,
298
+ )
299
+
300
+
301
+ def main(argv: Sequence[str] | None = None) -> int:
302
+ """LLD 00 §3's common service skeleton, minus the parts v0.1 does not have."""
303
+ del argv # every input is an environment variable; see Settings
304
+
305
+ settings = Settings.from_environ()
306
+
307
+ try:
308
+ configure_logging(settings.log_level)
309
+ except ValueError as exc:
310
+ print(f"cctv-clip-worker: {exc}", file=sys.stderr)
311
+ return EXIT_CONFIG
312
+
313
+ logger.info("starting", extra={"host": socket.gethostname()})
314
+
315
+ try:
316
+ config = load_cameras_config(settings.cameras_path)
317
+ except (ConfigError, OSError) as exc:
318
+ logger.error(
319
+ "configuration is unusable",
320
+ extra={"path": str(settings.cameras_path), "reason": str(exc)},
321
+ )
322
+ return EXIT_CONFIG
323
+
324
+ for warning in config.warnings:
325
+ logger.warning(warning)
326
+
327
+ if not config.enabled_cameras():
328
+ logger.error("no cameras are enabled, so nothing can ever be recorded")
329
+ return EXIT_CONFIG
330
+
331
+ try:
332
+ conn = db.open_db(settings.db_path)
333
+ version = db.migrate(conn)
334
+ except (db.DbError, sqlite3.Error) as exc:
335
+ logger.error(
336
+ "state store is unusable",
337
+ extra={"path": str(settings.db_path), "reason": str(exc)},
338
+ )
339
+ return EXIT_UNAVAILABLE
340
+
341
+ logger.info("state store ready", extra={"state": f"schema-{version}"})
342
+
343
+ for name, camera in config.enabled_cameras().items():
344
+ db.ensure_camera(conn, name=name, display_name=camera.display_name, enabled=True)
345
+
346
+ worker = build_worker(settings, config, conn)
347
+
348
+ stop = threading.Event()
349
+ _install_signal_handlers(stop)
350
+
351
+ subscriber: ReviewSubscriber | None = None
352
+ if settings.mqtt_host is not None:
353
+ subscriber = ReviewSubscriber(
354
+ dispatcher=worker.dispatcher,
355
+ host=settings.mqtt_host,
356
+ port=settings.mqtt_port,
357
+ topic_prefix=settings.mqtt_topic_prefix,
358
+ username=settings.mqtt_username,
359
+ password=settings.mqtt_password,
360
+ )
361
+ try:
362
+ subscriber.start()
363
+ except MqttUnavailable as exc:
364
+ logger.error("cannot subscribe to the broker", extra={"reason": str(exc)})
365
+ conn.close()
366
+ return EXIT_UNAVAILABLE
367
+ else:
368
+ logger.warning(
369
+ "CCTV_MQTT_HOST is unset: sweep-only, so every clip waits up to %s s to be noticed",
370
+ settings.sweep_interval_s,
371
+ )
372
+
373
+ try:
374
+ worker.run(stop)
375
+ finally:
376
+ if subscriber is not None:
377
+ subscriber.stop()
378
+ conn.close()
379
+ logger.info("stopped")
380
+
381
+ return 0
382
+
383
+
384
+ if __name__ == "__main__": # pragma: no cover
385
+ raise SystemExit(main())