homesec 1.2.0__py3-none-any.whl → 1.2.2__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.
@@ -0,0 +1,180 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import subprocess
5
+ from pathlib import Path
6
+ from typing import Protocol
7
+
8
+ from homesec.sources.rtsp.clock import Clock
9
+ from homesec.sources.rtsp.utils import _format_cmd, _is_timeout_option_error, _redact_rtsp_url
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class Recorder(Protocol):
15
+ def start(self, output_file: Path, stderr_log: Path) -> subprocess.Popen[bytes] | None: ...
16
+
17
+ def stop(self, proc: subprocess.Popen[bytes], output_file: Path | None) -> None: ...
18
+
19
+ def is_alive(self, proc: subprocess.Popen[bytes]) -> bool: ...
20
+
21
+
22
+ class FfmpegRecorder:
23
+ def __init__(
24
+ self,
25
+ *,
26
+ rtsp_url: str,
27
+ ffmpeg_flags: list[str],
28
+ rtsp_connect_timeout_s: float,
29
+ rtsp_io_timeout_s: float,
30
+ clock: Clock,
31
+ ) -> None:
32
+ self._rtsp_url = rtsp_url
33
+ self._ffmpeg_flags = ffmpeg_flags
34
+ self._rtsp_connect_timeout_s = rtsp_connect_timeout_s
35
+ self._rtsp_io_timeout_s = rtsp_io_timeout_s
36
+ self._clock = clock
37
+
38
+ def start(self, output_file: Path, stderr_log: Path) -> subprocess.Popen[bytes] | None:
39
+ def _read_tail(path: Path, max_bytes: int = 4000) -> str:
40
+ try:
41
+ data = path.read_bytes()
42
+ except Exception as exc:
43
+ logger.warning("Failed to read recording stderr tail: %s", exc, exc_info=True)
44
+ return ""
45
+ if len(data) <= max_bytes:
46
+ return data.decode(errors="replace")
47
+ return data[-max_bytes:].decode(errors="replace")
48
+
49
+ cmd_base = [
50
+ "ffmpeg",
51
+ "-rtsp_transport",
52
+ "tcp",
53
+ "-rtsp_flags",
54
+ "prefer_tcp",
55
+ "-user_agent",
56
+ "Lavf",
57
+ ]
58
+
59
+ user_flags = self._ffmpeg_flags
60
+ has_stimeout = any(x == "-stimeout" for x in user_flags)
61
+ has_rw_timeout = any(x == "-rw_timeout" for x in user_flags)
62
+ timeout_us_connect = str(int(max(0.1, self._rtsp_connect_timeout_s) * 1_000_000))
63
+ timeout_us_io = str(int(max(0.1, self._rtsp_io_timeout_s) * 1_000_000))
64
+
65
+ timeout_args: list[str] = []
66
+ if not has_stimeout and self._rtsp_connect_timeout_s > 0:
67
+ timeout_args.extend(["-stimeout", timeout_us_connect])
68
+ if not has_rw_timeout and self._rtsp_io_timeout_s > 0:
69
+ timeout_args.extend(["-rw_timeout", timeout_us_io])
70
+
71
+ cmd_tail = ["-i", self._rtsp_url, "-c", "copy", "-f", "mp4", "-y"]
72
+
73
+ # Naive check to see if user overrode defaults
74
+ # If user supplies ANY -loglevel, we don't add ours.
75
+ # If user supplies ANY -fflags, we don't add ours (to avoid concatenation complexity).
76
+ # This allows full user control.
77
+ has_loglevel = any(x == "-loglevel" for x in user_flags)
78
+ if not has_loglevel:
79
+ cmd_tail.extend(["-loglevel", "warning"])
80
+
81
+ has_fflags = any(x == "-fflags" for x in user_flags)
82
+ if not has_fflags:
83
+ cmd_tail.extend(["-fflags", "+genpts+igndts"])
84
+
85
+ has_fps_mode = any(x == "-fps_mode" or x == "-vsync" for x in user_flags)
86
+ if not has_fps_mode:
87
+ cmd_tail.extend(["-vsync", "0"])
88
+
89
+ # Add user flags last so they can potentially override or add to the above
90
+ cmd_tail.extend(user_flags)
91
+ cmd_tail.extend([str(output_file)])
92
+
93
+ attempts: list[tuple[str, list[str]]] = []
94
+ if timeout_args:
95
+ attempts.append(("timeouts", timeout_args))
96
+ attempts.append(("no_timeouts" if timeout_args else "default", []))
97
+
98
+ for label, extra_args in attempts:
99
+ cmd = list(cmd_base) + list(extra_args) + cmd_tail
100
+
101
+ safe_cmd = list(cmd)
102
+ try:
103
+ idx = safe_cmd.index("-i")
104
+ safe_cmd[idx + 1] = _redact_rtsp_url(str(safe_cmd[idx + 1]))
105
+ except Exception as exc:
106
+ logger.warning("Failed to redact recording RTSP URL: %s", exc, exc_info=True)
107
+ logger.debug("Recording ffmpeg (%s): %s", label, _format_cmd(safe_cmd))
108
+
109
+ try:
110
+ with open(stderr_log, "w") as stderr_file:
111
+ proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=stderr_file)
112
+
113
+ self._clock.sleep(0.5)
114
+ if proc.poll() is None:
115
+ return proc
116
+
117
+ stderr_tail = _read_tail(stderr_log)
118
+ timeout_option_error = (
119
+ label == "timeouts"
120
+ and bool(stderr_tail)
121
+ and _is_timeout_option_error(stderr_tail)
122
+ )
123
+
124
+ if timeout_option_error:
125
+ logger.warning(
126
+ "Recording process died immediately (%s, exit code: %s); timeout options unsupported",
127
+ label,
128
+ proc.returncode,
129
+ )
130
+ logger.warning("Check logs at: %s", stderr_log)
131
+ else:
132
+ logger.error(
133
+ "Recording process died immediately (%s, exit code: %s)",
134
+ label,
135
+ proc.returncode,
136
+ )
137
+ logger.error("Check logs at: %s", stderr_log)
138
+
139
+ if stderr_tail:
140
+ redacted_tail = stderr_tail.replace(
141
+ self._rtsp_url, _redact_rtsp_url(self._rtsp_url)
142
+ )
143
+ if timeout_option_error:
144
+ logger.warning("Recording stderr tail (%s):\n%s", label, redacted_tail)
145
+ logger.warning(
146
+ "Recording ffmpeg missing timeout options; retrying without timeouts"
147
+ )
148
+ continue
149
+ logger.error("Recording stderr tail (%s):\n%s", label, redacted_tail)
150
+ if label == "timeouts":
151
+ return None
152
+ except Exception:
153
+ logger.exception("Failed to start recording")
154
+ return None
155
+
156
+ return None
157
+
158
+ def stop(self, proc: subprocess.Popen[bytes], output_file: Path | None) -> None:
159
+ try:
160
+ if proc.poll() is None:
161
+ proc.terminate()
162
+ proc.wait(timeout=5)
163
+ except subprocess.TimeoutExpired:
164
+ logger.warning("Recording process did not terminate, killing (PID: %s)", proc.pid)
165
+ try:
166
+ proc.kill()
167
+ proc.wait(timeout=2)
168
+ except Exception:
169
+ logger.exception("Failed to kill recording process (PID: %s)", proc.pid)
170
+ except Exception:
171
+ logger.exception("Failed while stopping recording process (PID: %s)", proc.pid)
172
+
173
+ logger.debug(
174
+ "Stopped recording: %s",
175
+ output_file,
176
+ extra={"recording_id": output_file.name if output_file else None},
177
+ )
178
+
179
+ def is_alive(self, proc: subprocess.Popen[bytes]) -> bool:
180
+ return proc.poll() is None
@@ -0,0 +1,35 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import shlex
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+
9
+ def _redact_rtsp_url(url: str) -> str:
10
+ if "://" not in url:
11
+ return url
12
+ scheme, rest = url.split("://", 1)
13
+ if "@" not in rest:
14
+ return url
15
+ _creds, host = rest.split("@", 1)
16
+ return f"{scheme}://***:***@{host}"
17
+
18
+
19
+ def _format_cmd(cmd: list[str]) -> str:
20
+ try:
21
+ return shlex.join([str(x) for x in cmd])
22
+ except Exception as exc:
23
+ logger.warning("Failed to format command with shlex.join: %s", exc, exc_info=True)
24
+ return " ".join([str(x) for x in cmd])
25
+
26
+
27
+ def _is_timeout_option_error(stderr_text: str) -> bool:
28
+ text = stderr_text.lower()
29
+ return ("rw_timeout" in text and ("not found" in text or "unrecognized option" in text)) or (
30
+ "stimeout" in text and ("not found" in text or "unrecognized option" in text)
31
+ )
32
+
33
+
34
+ def _next_backoff(backoff_s: float, cap_s: float, *, factor: float = 1.6) -> float:
35
+ return min(backoff_s * factor, cap_s)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: homesec
3
- Version: 1.2.0
3
+ Version: 1.2.2
4
4
  Summary: Pluggable async home security camera pipeline with detection, VLM analysis, and alerts.
5
5
  Project-URL: Homepage, https://github.com/lan17/homesec
6
6
  Project-URL: Source, https://github.com/lan17/homesec
@@ -243,25 +243,70 @@ Description-Content-Type: text/markdown
243
243
 
244
244
  # HomeSec
245
245
 
246
+ [![PyPI](https://img.shields.io/pypi/v/homesec)](https://pypi.org/project/homesec/)
246
247
  [![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE)
247
248
  [![Python: 3.10+](https://img.shields.io/badge/python-3.10%2B-blue)](https://www.python.org/)
248
249
  [![Typing: Typed](https://img.shields.io/badge/typing-typed-2b825b)](https://peps.python.org/pep-0561/)
249
250
  [![codecov](https://codecov.io/gh/lan17/HomeSec/branch/main/graph/badge.svg)](https://codecov.io/gh/lan17/HomeSec)
250
251
 
251
- HomeSec is a self-hosted, extensible network video recorder that puts you in control. Store clips wherever you want, analyze them with AI, and get smart notifications—all while keeping your footage private and off third-party clouds.
252
+ HomeSec is a self-hosted, extensible video pipeline for home security cameras. You can connect cameras directly via RTSP, receive clips over FTP, or implement your own ClipSource. From there, the pipeline filters events with AI and sends smart notifications. Your footage stays private and off third-party clouds.
253
+
254
+ ## Design Principles
255
+
256
+ - **Local-Only Data Processing**: Video footage remains on the local network by default. Cloud usage (Storage, VLM/OpenAI) is strictly opt-in.
257
+ - **Modular Architecture**: All major components (sources, filters, analyzers, notifiers) are decoupled plugins defined by strict interfaces. If you want to use a different AI model or storage backend, you can swap it out with a few lines of Python.
258
+ - **Resilience**: The primary resilience feature is backing up clips to storage. The pipeline handles intermittent stream failures and network instability without crashing or stalling.
259
+
260
+ ## Pipeline at a glance
261
+
262
+
263
+
264
+ ```mermaid
265
+ graph TD
266
+ %% Layout Wrapper for horizontal alignment
267
+ subgraph Wrapper [" "]
268
+ direction LR
269
+ style Wrapper fill:none,stroke:none
270
+
271
+ S[Clip Source]
272
+
273
+ subgraph Pipeline [Media Processing Pipeline]
274
+ direction TB
275
+ C(Clip File) --> U([Upload to Storage])
276
+ C --> F([Detect objects: YOLO])
277
+ F -->|Detected objects| AI{Trigger classes filter}
278
+ AI -->|Yes| V([VLM Analysis])
279
+ AI -->|No| D([Discard])
280
+ V -->|Risk level, detected objects| P{Alert Policy filter}
281
+ P -->|No| D
282
+ P -->|YES| N[Notifiers]
283
+ end
284
+
285
+ S -->|New Clip File| Pipeline
286
+
287
+ PG[(Postgres)]
288
+ Pipeline -.->|State & Events| PG
289
+ end
290
+ ```
291
+
292
+ - **Parallel Processing**: Upload and filter run in parallel.
293
+ - **Resilience**: Upload failures do not block alerts; filter failures stop expensive VLM calls.
294
+ - **State**: Metadata is stored in Postgres (`clip_states` + `clip_events`) for full observability.
252
295
 
253
- Under the hood, it's a pluggable async pipeline for home security cameras. It records short clips, runs object detection, optionally calls a vision-language model ([VLM](https://en.wikipedia.org/wiki/Vision%E2%80%93language_model)) for a structured summary, and sends alerts via [MQTT](https://en.wikipedia.org/wiki/MQTT) or email. The design prioritizes reliability and extensibility.
254
296
 
255
297
  ## Table of Contents
256
298
 
257
299
  - [Highlights](#highlights)
258
300
  - [Pipeline at a glance](#pipeline-at-a-glance)
259
301
  - [Quickstart](#quickstart)
302
+ - [30-Second Start (Docker)](#30-second-start-docker)
303
+ - [Manual Setup](#manual-setup)
260
304
  - [Configuration](#configuration)
261
- - [Extensible by design](#extensible-by-design)
262
- - [CLI](#cli)
263
- - [Built-in plugins](#built-in-plugins)
264
- - [Writing a plugin](#writing-a-plugin)
305
+ - [Commands](#commands)
306
+ - [Plugins](#plugins)
307
+ - [Built-in plugins](#built-in-plugins)
308
+ - [Plugin interfaces](#plugin-interfaces)
309
+ - [Writing a custom plugin](#writing-a-custom-plugin)
265
310
  - [Observability](#observability)
266
311
  - [Development](#development)
267
312
  - [Contributing](#contributing)
@@ -270,227 +315,231 @@ Under the hood, it's a pluggable async pipeline for home security cameras. It re
270
315
  ## Highlights
271
316
 
272
317
  - Multiple pluggable video clip sources: [RTSP](https://en.wikipedia.org/wiki/Real-Time_Streaming_Protocol) motion detection, [FTP](https://en.wikipedia.org/wiki/File_Transfer_Protocol) uploads, or a watched folder
273
- - Parallel upload + filter ([YOLOv8](https://en.wikipedia.org/wiki/You_Only_Look_Once)) with frame sampling and early exit
318
+ - Parallel upload + filter ([YOLO](https://en.wikipedia.org/wiki/You_Only_Look_Once)) with frame sampling and early exit
274
319
  - OpenAI-compatible VLM analysis with structured output
275
320
  - Policy-driven alerts with per-camera overrides
276
321
  - Fan-out notifiers (MQTT for Home Assistant, SendGrid email)
277
322
  - Postgres-backed state + events with graceful degradation
278
- - Built around small, stable interfaces so new plugins drop in cleanly
279
323
  - Health endpoint plus optional Postgres telemetry logging
280
324
 
281
- ## Pipeline at a glance
282
325
 
283
- ```
284
- ClipSource -> (Upload + Filter) -> VLM (optional) -> Alert Policy -> Notifier(s)
285
- ```
286
-
287
- - Upload and filter run in parallel; VLM runs only when trigger classes are detected.
288
- - Upload failures do not block alerts; filter failures stop processing.
289
- - State is stored in Postgres (`clip_states` + `clip_events`) when available.
290
326
 
291
327
  ## Quickstart
292
328
 
293
- ### Requirements
329
+ ### 30-Second Start (Docker)
330
+ The fastest way to see it in action. Includes a pre-configured Postgres and a dummy local source.
331
+
332
+ ```bash
333
+ git clone https://github.com/lan17/homesec.git
334
+ cd homesec
335
+ make up
336
+ ```
337
+ *Modify `config/config.yaml` to add your real cameras, then restart.*
294
338
 
295
- - Raspberry Pi 4 (or equivalent) or higher; any x86_64 system works as well
296
- - Docker and Docker Compose
297
- - Optional: MQTT broker, Dropbox credentials, OpenAI-compatible API key
339
+ ### Manual Setup
340
+ For standard production usage without Docker Compose:
298
341
 
299
- ### Setup
342
+ 1. **Prerequisites**:
343
+ - Python 3.10+
344
+ - ffmpeg
345
+ - PostgreSQL (running and accessible)
300
346
 
301
- 1. Create a config file:
347
+ 2. **Install**
302
348
  ```bash
303
- cp config/example.yaml config/config.yaml
304
- # Edit config/config.yaml with your settings
349
+ pip install homesec
305
350
  ```
306
- 2. Set environment variables:
351
+
352
+ 3. **Configure**
307
353
  ```bash
308
- cp .env.example .env
309
- # Edit .env with your credentials
354
+ # Download example config & env
355
+ curl -O https://raw.githubusercontent.com/lan17/homesec/main/config/example.yaml
356
+ mv example.yaml config.yaml
357
+
358
+ curl -O https://raw.githubusercontent.com/lan17/homesec/main/.env.example
359
+ mv .env.example .env
360
+
361
+ # Setup environment (DB_DSN is required)
362
+ # Edit .env to set your secrets!
363
+ export DB_DSN="postgresql://user:pass@localhost/homesec"
310
364
  ```
311
- 3. Start HomeSec + Postgres:
365
+
366
+ 4. **Run**
312
367
  ```bash
313
- make up
368
+ homesec run --config config.yaml
314
369
  ```
315
- 4. Stop:
370
+
371
+ ### Developer Setup
372
+ If you are contributing or running from source:
373
+
374
+ 1. **Install dependencies**
316
375
  ```bash
317
- make down
376
+ uv sync
318
377
  ```
319
378
 
320
- ### Running without Docker
379
+ 2. **Start Infrastructure**
380
+ ```bash
381
+ make db # Starts just Postgres in Docker
382
+ ```
321
383
 
322
- If you prefer to run locally:
384
+ 3. **Run**
385
+ ```bash
386
+ uv run python -m homesec.cli run --config config/config.yaml
387
+ ```
323
388
 
324
- 1. Install Python 3.10+ and ffmpeg
325
- 2. `uv sync`
326
- 3. `make db` (starts Postgres)
327
- 4. `make run`
328
389
 
329
390
  ## Configuration
330
391
 
331
- Configs are YAML and validated with Pydantic. See `config/example.yaml` for all options.
392
+ Configuration is YAML-based and strictly validated. Secrets (API keys, passwords) should always be loaded from environment variables (`_env` suffix).
332
393
 
333
- Minimal example (RTSP + Dropbox + MQTT):
394
+ ### Configuration Examples
334
395
 
335
- ```yaml
336
- version: 1
396
+ #### 1. The "Power User" (Robust RTSP)
397
+ Best for real-world setups with flaky cameras.
337
398
 
399
+ ```yaml
338
400
  cameras:
339
- - name: front_door
401
+ - name: driveway
340
402
  source:
341
403
  type: rtsp
342
404
  config:
343
- rtsp_url_env: FRONT_DOOR_RTSP_URL
405
+ rtsp_url_env: DRIVEWAY_RTSP_URL
344
406
  output_dir: "./recordings"
407
+ stream:
408
+ # Critical for camera compatibility:
409
+ ffmpeg_flags: ["-rtsp_transport", "tcp", "-vsync", "0"]
410
+ reconnect:
411
+ backoff_s: 5
345
412
 
413
+ filter:
414
+ plugin: yolo
415
+ config:
416
+ classes: ["person", "car"]
417
+ min_confidence: 0.6
418
+ ```
419
+
420
+ In your `.env`:
421
+ ```bash
422
+ DRIVEWAY_RTSP_URL="rtsp://user:pass@192.168.1.100:554/stream"
423
+ ```
424
+
425
+ #### 2. The "Cloud Storage" (Dropbox)
426
+ Uploads to Cloud but keeps analysis local.
427
+
428
+ ```yaml
346
429
  storage:
347
430
  backend: dropbox
348
431
  dropbox:
349
- root: "/homecam"
350
432
  token_env: DROPBOX_TOKEN
351
- app_key_env: DROPBOX_APP_KEY
352
- app_secret_env: DROPBOX_APP_SECRET
353
- refresh_token_env: DROPBOX_REFRESH_TOKEN
354
-
355
- state_store:
356
- dsn_env: DB_DSN
433
+ root: "/SecurityCam"
357
434
 
358
435
  notifiers:
359
- - backend: mqtt
360
- config:
361
- host: "localhost"
362
- port: 1883
436
+ - backend: sendgrid_email
437
+ config:
438
+ api_key_env: SENDGRID_API_KEY
439
+ to_emails: ["me@example.com"]
440
+ ```
363
441
 
364
- filter:
365
- plugin: yolo
366
- config:
367
- classes: ["person"]
368
- min_confidence: 0.5
369
-
370
- vlm:
371
- backend: openai
372
- llm:
373
- api_key_env: OPENAI_API_KEY
374
- model: gpt-4o
375
-
376
- alert_policy:
377
- backend: default
378
- enabled: true
379
- config:
380
- min_risk_level: medium
442
+ In your `.env`:
443
+ ```bash
444
+ DROPBOX_TOKEN="sl.Al..."
445
+ SENDGRID_API_KEY="SG.xyz..."
446
+ ```
447
+
448
+ See [`config/example.yaml`](config/example.yaml) for a complete reference of all options.
449
+
450
+ ### Tips
451
+
452
+ - **Secrets**: Never put secrets in YAML. Use env vars (`*_env`) and set them in your shell or `.env`.
453
+ - **Notifiers**: At least one notifier (mqtt/email) must be enabled unless `alert_policy.enabled` is false.
454
+ - **YOLO Classes**: Built-in classes include `person`, `car`, `truck`, `motorcycle`, `bicycle`, `dog`, `cat`, `bird`, `backpack`, `handbag`, `suitcase`.
381
455
 
382
- per_camera_alert:
383
- front_door:
384
- min_risk_level: low
385
- notify_on_activity_types: ["person_at_door", "delivery"]
456
+ After installation, the `homesec` command is available:
457
+
458
+ ```bash
459
+ homesec --help
386
460
  ```
387
461
 
388
- A few things worth knowing:
389
- - Secrets never go in YAML. Use env var names (`*_env`) and set values in your shell or `.env`.
390
- - At least one notifier must be enabled (`mqtt` or `sendgrid_email`).
391
- - Built-in YOLO classes: `person`, `car`, `truck`, `motorcycle`, `bicycle`,
392
- `dog`, `cat`, `bird`, `backpack`, `handbag`, `suitcase`.
393
- - Local storage for development:
462
+ ### Commands
394
463
 
395
- ```yaml
396
- storage:
397
- backend: local
398
- local:
399
- root: "./storage"
464
+ **Run the pipeline:**
465
+ ```bash
466
+ homesec run --config config.yaml
400
467
  ```
401
468
 
402
- - Set `alert_policy.enabled: false` to disable notifications (a noop policy is used).
403
- - For a quick local run, pair `local_folder` with `local` storage and drop a clip
404
- into `recordings/`.
405
-
406
- ## Extensible by design
407
-
408
- HomeSec is intentionally modular. Each major capability is an interface
409
- (`ClipSource`, `StorageBackend`, `ObjectFilter`, `VLMAnalyzer`, `AlertPolicy`,
410
- `Notifier`) defined in `src/homesec/interfaces.py`, and plugins are discovered at
411
- runtime via entry points. This keeps the core pipeline small while making it
412
- easy to add new backends without editing core code.
413
-
414
- What this means in practice:
415
- - Swap storage or notifications by changing config, not code.
416
- - Add a new plugin type as a separate package and register it.
417
- - Keep config validation strict by pairing each plugin with a Pydantic model.
418
-
419
- Extension points (all pluggable):
420
- - Sources: RTSP motion detection, FTP uploads, local folders
421
- - Storage backends: Dropbox, local disk, or your own
422
- - Filters: object detection (YOLO or custom models)
423
- - VLM analyzers: OpenAI-compatible APIs or local models
424
- - Alert policies: per-camera rules and thresholds
425
- - Notifiers: MQTT, email, or anything else you can send from Python
426
-
427
- ## CLI
428
-
429
- - Run the pipeline:
430
- `uv run python -m homesec.cli run --config config/config.yaml --log_level INFO`
431
- - Validate config:
432
- `uv run python -m homesec.cli validate --config config/config.yaml`
433
- - Cleanup (reanalyze and optionally delete empty clips):
434
- `uv run python -m homesec.cli cleanup --config config/config.yaml --older_than_days 7 --dry_run True`
435
-
436
- ## Built-in plugins
437
-
438
- - Filters: `yolo`
439
- - VLM analyzers: `openai` (OpenAI-compatible API)
440
- - Storage: `dropbox`, `local`
441
- - Notifiers: `mqtt`, `sendgrid_email`
442
- - Alert policies: `default`, `noop`
443
-
444
- ## Writing a plugin
445
-
446
- HomeSec discovers plugins via entry points in the `homesec.plugins` group. A plugin
447
- module just needs to import and register itself.
448
-
449
- Each plugin provides:
450
- - A unique name (used in config)
451
- - A Pydantic config model for validation
452
- - A factory that builds the concrete implementation
453
-
454
- ```python
455
- # my_package/filters/custom.py
456
- from pydantic import BaseModel
457
- from homesec.interfaces import ObjectFilter
458
- from homesec.plugins.filters import FilterPlugin, filter_plugin
459
-
460
- class CustomConfig(BaseModel):
461
- threshold: float = 0.5
462
-
463
- class CustomFilter(ObjectFilter):
464
- ...
465
-
466
- @filter_plugin(name="custom")
467
- def register() -> FilterPlugin:
468
- return FilterPlugin(
469
- name="custom",
470
- config_model=CustomConfig,
471
- factory=lambda cfg: CustomFilter(cfg),
472
- )
469
+ **Validate config:**
470
+ ```bash
471
+ homesec validate --config config.yaml
473
472
  ```
474
473
 
475
- ```toml
476
- # pyproject.toml
477
- [project.entry-points."homesec.plugins"]
478
- my_filters = "my_package.filters.custom"
474
+ **Cleanup old clips** (reanalyze and optionally delete empty clips):
475
+ ```bash
476
+ homesec cleanup --config config.yaml --older_than_days 7 --dry_run=False
479
477
  ```
480
478
 
479
+ Use `homesec <command> --help` for detailed options on each command.
480
+
481
+ ## Plugins
482
+
483
+ ### Extensible by design
484
+
485
+ We designed HomeSec to be modular. Each major capability is an interface (`ClipSource`, `StorageBackend`, `ObjectFilter`, `VLMAnalyzer`, `AlertPolicy`, `Notifier`) defined in `src/homesec/interfaces.py`. This means you can swap out components (like replacing YOLO with a different detector) without changing the core pipeline.
486
+
487
+ HomeSec uses a plugin architecture where every component is discovered at runtime via entry points.
488
+
489
+ ### Built-in plugins
490
+
491
+ | Type | Plugins |
492
+ |------|---------|
493
+ | Sources | [`rtsp`](src/homesec/sources/rtsp/core.py), [`ftp`](src/homesec/sources/ftp.py), [`local_folder`](src/homesec/sources/local_folder.py) |
494
+ | Filters | [`yolo`](src/homesec/plugins/filters/yolo.py) |
495
+ | Storage | [`dropbox`](src/homesec/plugins/storage/dropbox.py), [`local`](src/homesec/plugins/storage/local.py) |
496
+ | VLM analyzers | [`openai`](src/homesec/plugins/analyzers/openai.py) |
497
+ | Notifiers | [`mqtt`](src/homesec/plugins/notifiers/mqtt.py), [`sendgrid_email`](src/homesec/plugins/notifiers/sendgrid_email.py) |
498
+ | Alert policies | [`default`](src/homesec/plugins/alert_policies/default.py), [`noop`](src/homesec/plugins/alert_policies/noop.py) |
499
+
500
+ ### Plugin interfaces
501
+
502
+ All interfaces are defined in [`src/homesec/interfaces.py`](src/homesec/interfaces.py).
503
+
504
+ | Type | Interface | Decorator |
505
+ |------|-----------|-----------|
506
+ | Sources | `ClipSource` | `@source_plugin` |
507
+ | Filters | `ObjectFilter` | `@filter_plugin` |
508
+ | Storage | `StorageBackend` | `@storage_plugin` |
509
+ | VLM analyzers | `VLMAnalyzer` | `@vlm_plugin` |
510
+ | Notifiers | `Notifier` | `@notifier_plugin` |
511
+ | Alert policies | `AlertPolicy` | `@alert_policy_plugin` |
512
+
513
+ ### Writing a custom plugin
514
+
515
+ Extending HomeSec is designed to be easy. You can write custom sources, filters, storage backends, and more.
516
+
517
+ 👉 **See [PLUGIN_DEVELOPMENT.md](PLUGIN_DEVELOPMENT.md) for a complete guide.**
518
+
481
519
  ## Observability
482
520
 
483
- - Health endpoint: `GET /health` (configurable in `health.host`/`health.port`)
484
- - Optional telemetry logs to Postgres when `DB_DSN` is set:
485
- - Start local DB: `make db`
486
- - Run migrations: `make db-migrate`
521
+ - Health endpoint: `GET /health` (configurable via `health.host`/`health.port` in config)
522
+ - Telemetry logs to Postgres when `DB_DSN` is set
487
523
 
488
524
  ## Development
489
525
 
526
+ ### Setup
527
+
528
+ 1. Clone the repository
529
+ 2. Install [uv](https://docs.astral.sh/uv/) for dependency management
530
+ 3. `uv sync` to install dependencies
531
+ 4. `make db` to start Postgres locally
532
+
533
+ ### Commands
534
+
490
535
  - Run tests: `make test`
491
536
  - Run type checking (strict): `make typecheck`
492
537
  - Run both: `make check`
493
- - Tests must include Given/When/Then comments.
538
+ - Run the pipeline: `make run`
539
+
540
+ ### Notes
541
+
542
+ - Tests must include Given/When/Then comments
494
543
  - Architecture notes: `DESIGN.md`
495
544
 
496
545
  ## Contributing