homesec 0.1.0__tar.gz
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.
- homesec-0.1.0/.env.example +58 -0
- homesec-0.1.0/.gitignore +4 -0
- homesec-0.1.0/AGENTS.md +419 -0
- homesec-0.1.0/DESIGN.md +1185 -0
- homesec-0.1.0/LICENSE +201 -0
- homesec-0.1.0/Makefile +61 -0
- homesec-0.1.0/PKG-INFO +446 -0
- homesec-0.1.0/README.md +203 -0
- homesec-0.1.0/alembic/env.py +70 -0
- homesec-0.1.0/alembic.ini +38 -0
- homesec-0.1.0/config/example.yaml +85 -0
- homesec-0.1.0/config/local.yaml +68 -0
- homesec-0.1.0/config/production.yaml +113 -0
- homesec-0.1.0/config/sample.yaml +66 -0
- homesec-0.1.0/docker-compose.postgres.yml +17 -0
- homesec-0.1.0/pyproject.toml +86 -0
- homesec-0.1.0/src/homesec/__init__.py +20 -0
- homesec-0.1.0/src/homesec/app.py +393 -0
- homesec-0.1.0/src/homesec/cli.py +159 -0
- homesec-0.1.0/src/homesec/config/__init__.py +18 -0
- homesec-0.1.0/src/homesec/config/loader.py +109 -0
- homesec-0.1.0/src/homesec/config/validation.py +82 -0
- homesec-0.1.0/src/homesec/errors.py +71 -0
- homesec-0.1.0/src/homesec/health/__init__.py +5 -0
- homesec-0.1.0/src/homesec/health/server.py +226 -0
- homesec-0.1.0/src/homesec/interfaces.py +249 -0
- homesec-0.1.0/src/homesec/logging_setup.py +176 -0
- homesec-0.1.0/src/homesec/maintenance/__init__.py +1 -0
- homesec-0.1.0/src/homesec/maintenance/cleanup_clips.py +632 -0
- homesec-0.1.0/src/homesec/models/__init__.py +79 -0
- homesec-0.1.0/src/homesec/models/alert.py +32 -0
- homesec-0.1.0/src/homesec/models/clip.py +71 -0
- homesec-0.1.0/src/homesec/models/config.py +362 -0
- homesec-0.1.0/src/homesec/models/events.py +184 -0
- homesec-0.1.0/src/homesec/models/filter.py +62 -0
- homesec-0.1.0/src/homesec/models/source.py +77 -0
- homesec-0.1.0/src/homesec/models/storage.py +12 -0
- homesec-0.1.0/src/homesec/models/vlm.py +99 -0
- homesec-0.1.0/src/homesec/pipeline/__init__.py +6 -0
- homesec-0.1.0/src/homesec/pipeline/alert_policy.py +5 -0
- homesec-0.1.0/src/homesec/pipeline/core.py +639 -0
- homesec-0.1.0/src/homesec/plugins/__init__.py +62 -0
- homesec-0.1.0/src/homesec/plugins/alert_policies/__init__.py +80 -0
- homesec-0.1.0/src/homesec/plugins/alert_policies/default.py +111 -0
- homesec-0.1.0/src/homesec/plugins/alert_policies/noop.py +60 -0
- homesec-0.1.0/src/homesec/plugins/analyzers/__init__.py +126 -0
- homesec-0.1.0/src/homesec/plugins/analyzers/openai.py +446 -0
- homesec-0.1.0/src/homesec/plugins/filters/__init__.py +124 -0
- homesec-0.1.0/src/homesec/plugins/filters/yolo.py +317 -0
- homesec-0.1.0/src/homesec/plugins/notifiers/__init__.py +80 -0
- homesec-0.1.0/src/homesec/plugins/notifiers/mqtt.py +189 -0
- homesec-0.1.0/src/homesec/plugins/notifiers/multiplex.py +106 -0
- homesec-0.1.0/src/homesec/plugins/notifiers/sendgrid_email.py +228 -0
- homesec-0.1.0/src/homesec/plugins/storage/__init__.py +116 -0
- homesec-0.1.0/src/homesec/plugins/storage/dropbox.py +272 -0
- homesec-0.1.0/src/homesec/plugins/storage/local.py +108 -0
- homesec-0.1.0/src/homesec/plugins/utils.py +63 -0
- homesec-0.1.0/src/homesec/py.typed +0 -0
- homesec-0.1.0/src/homesec/repository/__init__.py +5 -0
- homesec-0.1.0/src/homesec/repository/clip_repository.py +552 -0
- homesec-0.1.0/src/homesec/sources/__init__.py +17 -0
- homesec-0.1.0/src/homesec/sources/base.py +224 -0
- homesec-0.1.0/src/homesec/sources/ftp.py +209 -0
- homesec-0.1.0/src/homesec/sources/local_folder.py +238 -0
- homesec-0.1.0/src/homesec/sources/rtsp.py +1251 -0
- homesec-0.1.0/src/homesec/state/__init__.py +10 -0
- homesec-0.1.0/src/homesec/state/postgres.py +501 -0
- homesec-0.1.0/src/homesec/storage_paths.py +46 -0
- homesec-0.1.0/src/homesec/telemetry/__init__.py +0 -0
- homesec-0.1.0/src/homesec/telemetry/db/__init__.py +1 -0
- homesec-0.1.0/src/homesec/telemetry/db/log_table.py +16 -0
- homesec-0.1.0/src/homesec/telemetry/db_log_handler.py +246 -0
- homesec-0.1.0/src/homesec/telemetry/postgres_settings.py +42 -0
- homesec-0.1.0/tests/__init__.py +1 -0
- homesec-0.1.0/tests/conftest.py +9 -0
- homesec-0.1.0/tests/homesec/__init__.py +1 -0
- homesec-0.1.0/tests/homesec/conftest.py +97 -0
- homesec-0.1.0/tests/homesec/mocks/__init__.py +17 -0
- homesec-0.1.0/tests/homesec/mocks/event_store.py +54 -0
- homesec-0.1.0/tests/homesec/mocks/filter.py +67 -0
- homesec-0.1.0/tests/homesec/mocks/notifier.py +58 -0
- homesec-0.1.0/tests/homesec/mocks/state_store.py +83 -0
- homesec-0.1.0/tests/homesec/mocks/storage.py +110 -0
- homesec-0.1.0/tests/homesec/mocks/vlm.py +64 -0
- homesec-0.1.0/tests/homesec/test_alert_policy.py +157 -0
- homesec-0.1.0/tests/homesec/test_app.py +217 -0
- homesec-0.1.0/tests/homesec/test_cleanup_clips.py +222 -0
- homesec-0.1.0/tests/homesec/test_clip_repository.py +299 -0
- homesec-0.1.0/tests/homesec/test_clip_sources.py +336 -0
- homesec-0.1.0/tests/homesec/test_config.py +423 -0
- homesec-0.1.0/tests/homesec/test_dropbox_storage.py +293 -0
- homesec-0.1.0/tests/homesec/test_event_store.py +197 -0
- homesec-0.1.0/tests/homesec/test_health.py +252 -0
- homesec-0.1.0/tests/homesec/test_integration.py +313 -0
- homesec-0.1.0/tests/homesec/test_local_folder_deduplication.py +433 -0
- homesec-0.1.0/tests/homesec/test_mqtt_notifier.py +122 -0
- homesec-0.1.0/tests/homesec/test_notifiers.py +188 -0
- homesec-0.1.0/tests/homesec/test_openai_vlm.py +151 -0
- homesec-0.1.0/tests/homesec/test_pipeline.py +1091 -0
- homesec-0.1.0/tests/homesec/test_pipeline_events.py +310 -0
- homesec-0.1.0/tests/homesec/test_plugin_registration.py +410 -0
- homesec-0.1.0/tests/homesec/test_rtsp_helpers.py +79 -0
- homesec-0.1.0/tests/homesec/test_state_store.py +261 -0
- homesec-0.1.0/tests/homesec/test_yolo_filter.py +148 -0
- homesec-0.1.0/uv.lock +3722 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Copy to .env and fill in values. Do not commit .env.
|
|
2
|
+
|
|
3
|
+
# Preferred (simple) auth:
|
|
4
|
+
# DROPBOX_TOKEN="..."
|
|
5
|
+
|
|
6
|
+
# OR OAuth refresh flow (what motion_recorder uses):
|
|
7
|
+
DROPBOX_APP_KEY="..."
|
|
8
|
+
DROPBOX_APP_SECRET="..."
|
|
9
|
+
DROPBOX_REFRESH_TOKEN="..."
|
|
10
|
+
|
|
11
|
+
# Destination folder in Dropbox (must already exist)
|
|
12
|
+
DROPBOX_PATH="/front_door_camera_snapshots"
|
|
13
|
+
|
|
14
|
+
# Camera RTSP URLs
|
|
15
|
+
FRONT_DOOR_RTSP_URL="rtsp://user:pass@192.168.1.100:554/cam/realmonitor?channel=1&subtype=0"
|
|
16
|
+
# FRONT_DOOR_RTSP_SUB_URL="rtsp://user:pass@192.168.1.100:554/cam/realmonitor?channel=1&subtype=1"
|
|
17
|
+
|
|
18
|
+
# FTP (if using FTP source with anonymous=false)
|
|
19
|
+
# Reference these names in cameras[].source.config.username_env/password_env
|
|
20
|
+
FTP_USERNAME="..."
|
|
21
|
+
FTP_PASSWORD="..."
|
|
22
|
+
|
|
23
|
+
# VLM API
|
|
24
|
+
OPENAI_API_KEY="..."
|
|
25
|
+
|
|
26
|
+
# MQTT (if using MQTT notifier)
|
|
27
|
+
MQTT_USERNAME="..."
|
|
28
|
+
MQTT_PASSWORD="..."
|
|
29
|
+
|
|
30
|
+
# SendGrid email (if using sendgrid_email notifier)
|
|
31
|
+
SENDGRID_API_KEY="..."
|
|
32
|
+
|
|
33
|
+
# Twilio SMS (if using Twilio notifier)
|
|
34
|
+
TWILIO_ACCOUNT_SID="..."
|
|
35
|
+
TWILIO_AUTH_TOKEN="..."
|
|
36
|
+
|
|
37
|
+
# --- Telemetry logging (optional) ---
|
|
38
|
+
# If DB_DSN is set, the process will attempt to write structured JSON logs to Postgres.
|
|
39
|
+
# Run a local Postgres with: make db-up
|
|
40
|
+
POSTGRES_USER="telemetry"
|
|
41
|
+
POSTGRES_PASSWORD="telemetry"
|
|
42
|
+
POSTGRES_DB="telemetry"
|
|
43
|
+
POSTGRES_PORT="5432"
|
|
44
|
+
|
|
45
|
+
# Example (local docker):
|
|
46
|
+
DB_DSN="postgresql+asyncpg://telemetry:telemetry@127.0.0.1:5432/telemetry"
|
|
47
|
+
|
|
48
|
+
# Optional tuning:
|
|
49
|
+
# DB_LOG_LEVEL="INFO" # only logs >= this level go to DB
|
|
50
|
+
# DB_LOG_QUEUE_SIZE="5000"
|
|
51
|
+
# DB_LOG_BATCH_SIZE="100"
|
|
52
|
+
# DB_LOG_FLUSH_S="1.0"
|
|
53
|
+
# DB_LOG_BACKOFF_INITIAL_S="1.0"
|
|
54
|
+
# DB_LOG_BACKOFF_MAX_S="30.0"
|
|
55
|
+
# DB_LOG_DROP_POLICY="drop_new" # or "drop_oldest"
|
|
56
|
+
|
|
57
|
+
# Console formatting (stdout)
|
|
58
|
+
# CONSOLE_LOG_FORMAT="%(asctime)s %(levelname)s [%(camera_name)s] %(module)s:%(lineno)d %(message)s"
|
homesec-0.1.0/.gitignore
ADDED
homesec-0.1.0/AGENTS.md
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
# HomeSec Development Guidelines
|
|
2
|
+
|
|
3
|
+
**Last reviewed:** 2025-01-04
|
|
4
|
+
**Purpose:** Critical patterns to prevent runtime bugs when extending HomeSec. For architecture overview, see `DESIGN.md`.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Absolute Rules
|
|
9
|
+
|
|
10
|
+
- **Strict type checking required**: Run `make typecheck` before committing. Error-as-value pattern requires explicit type narrowing via match/isinstance.
|
|
11
|
+
- **Program to interfaces**: Use factory/registry helpers (e.g., `load_filter_plugin()`). Avoid direct instantiation of plugins.
|
|
12
|
+
- **Repository pattern**: Use `ClipRepository` for all state/event writes. Never touch `StateStore`/`EventStore` directly.
|
|
13
|
+
- **Preserve stack traces**: Custom errors must set `self.__cause__ = cause` to preserve original exception.
|
|
14
|
+
- **Tests must use Given/When/Then comments**: Every test case must include these comments (new or edited).
|
|
15
|
+
- **Postgres for state**: Use `clip_states` table with `clip_id` (primary key) + `data` (jsonb) for evolvable schema.
|
|
16
|
+
- **Pydantic everywhere**: Validate config, DB payloads, VLM outputs, and MQTT payloads with Pydantic models.
|
|
17
|
+
- **Clarify before complexity**: Ask user for clarification when simpler design may exist. Don't proceed with complex workarounds.
|
|
18
|
+
- **Product priorities**: Recording + uploading (P0) must work even if Postgres is down. Analysis/notifications are best-effort (P1).
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Core Pattern Templates
|
|
23
|
+
|
|
24
|
+
### 1. Error-as-Value + Type Narrowing
|
|
25
|
+
|
|
26
|
+
**Rule:** For partial failures, return `Result | ErrorType` instead of raising exceptions. Always narrow types with match or isinstance.
|
|
27
|
+
|
|
28
|
+
**✅ Template: Error-as-Value with Type Narrowing**
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
# Define custom error that preserves stack traces
|
|
32
|
+
class FilterError(PipelineError):
|
|
33
|
+
def __init__(self, clip_id: str, plugin_name: str, cause: Exception):
|
|
34
|
+
super().__init__(f"Filter failed for {clip_id}", stage="filter", clip_id=clip_id)
|
|
35
|
+
self.plugin_name = plugin_name
|
|
36
|
+
self.__cause__ = cause # ← Preserves full stack trace
|
|
37
|
+
|
|
38
|
+
# Stage returns error as value, not raise
|
|
39
|
+
async def _filter_stage(self, clip: Clip) -> FilterResult | FilterError:
|
|
40
|
+
try:
|
|
41
|
+
async with self._sem_filter:
|
|
42
|
+
return await self._filter.detect(clip.local_path)
|
|
43
|
+
except Exception as e:
|
|
44
|
+
return FilterError(clip.clip_id, self._config.filter.plugin, cause=e)
|
|
45
|
+
|
|
46
|
+
# Caller uses match for type narrowing
|
|
47
|
+
filter_result = await self._filter_stage(clip)
|
|
48
|
+
|
|
49
|
+
match filter_result:
|
|
50
|
+
case FilterError() as err:
|
|
51
|
+
# Type narrowed to FilterError
|
|
52
|
+
logger.error("Filter failed: %s", err.cause, exc_info=err.cause)
|
|
53
|
+
await self._repository.record_filter_failed(...)
|
|
54
|
+
return # Abort on critical failure
|
|
55
|
+
|
|
56
|
+
case FilterResult() as result:
|
|
57
|
+
# Type narrowed to FilterResult
|
|
58
|
+
logger.info("Detected: %s", result.detected_classes)
|
|
59
|
+
await self._repository.record_filter_completed(...)
|
|
60
|
+
|
|
61
|
+
# Partial failure example: upload fails but filter succeeds
|
|
62
|
+
match await self._upload_stage(clip):
|
|
63
|
+
case UploadError() as err:
|
|
64
|
+
logger.warning("Upload failed (continuing): %s", err.cause)
|
|
65
|
+
upload_failed = True
|
|
66
|
+
case UploadOutcome() as outcome:
|
|
67
|
+
storage_uri = outcome.storage_uri
|
|
68
|
+
upload_failed = False
|
|
69
|
+
|
|
70
|
+
# Continue processing even though upload failed
|
|
71
|
+
match await self._filter_stage(clip):
|
|
72
|
+
case FilterError():
|
|
73
|
+
return # Critical failure - abort
|
|
74
|
+
case FilterResult():
|
|
75
|
+
# Can still notify with upload_failed=True
|
|
76
|
+
pass
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
**❌ Anti-Pattern: Raising Exceptions**
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
# BAD: Raises on failure, can't handle partial failures
|
|
83
|
+
async def _filter_stage(self, clip: Clip) -> FilterResult:
|
|
84
|
+
return await self._filter.detect(clip.local_path) # Raises!
|
|
85
|
+
|
|
86
|
+
# Caller can't distinguish upload vs filter failures
|
|
87
|
+
try:
|
|
88
|
+
await self._upload_stage(clip)
|
|
89
|
+
await self._filter_stage(clip)
|
|
90
|
+
except Exception:
|
|
91
|
+
# Both failed? Just one? Can't tell!
|
|
92
|
+
return
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
**❌ Anti-Pattern: Missing Type Narrowing**
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
# BAD: No type narrowing - runtime crash
|
|
99
|
+
result = await self._filter_stage(clip) # Type: FilterResult | FilterError
|
|
100
|
+
logger.info("Detected: %s", result.detected_classes) # ← CRASH if FilterError!
|
|
101
|
+
|
|
102
|
+
# GOOD: Use isinstance if not using match
|
|
103
|
+
if isinstance(result, FilterError):
|
|
104
|
+
logger.error("Failed: %s", result.cause)
|
|
105
|
+
return
|
|
106
|
+
# Type narrowed to FilterResult here
|
|
107
|
+
logger.info("Detected: %s", result.detected_classes) # ✅ Safe
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
**When to use exceptions vs error-as-value:**
|
|
111
|
+
|
|
112
|
+
- **Exceptions**: Programmer errors (ValueError, TypeError), unrecoverable failures (out of disk), abort-entire-operation
|
|
113
|
+
- **Error-as-value**: Partial failures (upload fails but continue), expected failures (network timeout), fine-grained error handling
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
### 2. Async Pattern Selection
|
|
118
|
+
|
|
119
|
+
**Rule:** Choose async pattern based on whether work is CPU-bound, I/O-bound, or blocking sync.
|
|
120
|
+
|
|
121
|
+
**Quick Reference:**
|
|
122
|
+
|
|
123
|
+
| Work Type | Pattern | Example |
|
|
124
|
+
|-----------|---------|---------|
|
|
125
|
+
| CPU/GPU-bound | `ProcessPoolExecutor` | YOLO inference, video processing |
|
|
126
|
+
| I/O-bound | `aiohttp` (async library) | OpenAI API calls, webhooks |
|
|
127
|
+
| Blocking sync | `run_in_executor(None, fn)` | Dropbox SDK, file I/O |
|
|
128
|
+
|
|
129
|
+
**Template: CPU-Bound (ProcessPoolExecutor)**
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
class YOLOv8Filter:
|
|
133
|
+
def __init__(self, config: FilterConfig):
|
|
134
|
+
self._executor = ProcessPoolExecutor(max_workers=config.max_workers)
|
|
135
|
+
|
|
136
|
+
async def detect(self, video_path: Path) -> FilterResult:
|
|
137
|
+
loop = asyncio.get_running_loop()
|
|
138
|
+
# Worker must be module-level function (picklable)
|
|
139
|
+
return await loop.run_in_executor(
|
|
140
|
+
self._executor,
|
|
141
|
+
_detect_worker,
|
|
142
|
+
str(video_path), # Args must be picklable
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
async def shutdown(self, timeout: float | None = None):
|
|
146
|
+
self._executor.shutdown(wait=True, cancel_futures=False)
|
|
147
|
+
|
|
148
|
+
# Module-level worker for pickle
|
|
149
|
+
def _detect_worker(video_path: str) -> FilterResult:
|
|
150
|
+
import torch
|
|
151
|
+
model = YOLO("model.pt").to("cuda" if torch.cuda.is_available() else "cpu")
|
|
152
|
+
# ... inference ...
|
|
153
|
+
return FilterResult(detected_classes=["person"], confidence=0.9)
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
**Template: I/O-Bound (aiohttp)**
|
|
157
|
+
|
|
158
|
+
```python
|
|
159
|
+
class OpenAIVLM:
|
|
160
|
+
def __init__(self, config: VLMConfig):
|
|
161
|
+
self._session: aiohttp.ClientSession | None = None
|
|
162
|
+
|
|
163
|
+
async def analyze(self, video_path: Path) -> AnalysisResult:
|
|
164
|
+
if self._session is None:
|
|
165
|
+
timeout = aiohttp.ClientTimeout(total=30.0)
|
|
166
|
+
self._session = aiohttp.ClientSession(timeout=timeout)
|
|
167
|
+
|
|
168
|
+
async with self._session.post(url, json=payload, headers=headers) as resp:
|
|
169
|
+
data = await resp.json()
|
|
170
|
+
|
|
171
|
+
return AnalysisResult.model_validate(data)
|
|
172
|
+
|
|
173
|
+
async def shutdown(self, timeout: float | None = None):
|
|
174
|
+
if self._session:
|
|
175
|
+
await self._session.close()
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
**Template: Blocking Sync (run_in_executor)**
|
|
179
|
+
|
|
180
|
+
```python
|
|
181
|
+
class DropboxStorage:
|
|
182
|
+
async def put_file(self, local_path: Path, dest: str) -> StorageUploadResult:
|
|
183
|
+
def _upload():
|
|
184
|
+
with local_path.open("rb") as f:
|
|
185
|
+
return self._dbx.files_upload(f.read(), dest) # Blocking SDK call
|
|
186
|
+
|
|
187
|
+
loop = asyncio.get_running_loop()
|
|
188
|
+
result = await loop.run_in_executor(None, _upload) # None = default ThreadPoolExecutor
|
|
189
|
+
|
|
190
|
+
return StorageUploadResult(storage_uri=f"dropbox:{result.path_display}")
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
---
|
|
194
|
+
|
|
195
|
+
### 3. Repository Pattern
|
|
196
|
+
|
|
197
|
+
**Rule:** Use `ClipRepository` for all state/event persistence. Never touch `StateStore`/`EventStore` directly.
|
|
198
|
+
|
|
199
|
+
**✅ Template: Use Repository**
|
|
200
|
+
|
|
201
|
+
```python
|
|
202
|
+
# In pipeline - use repository methods
|
|
203
|
+
await self._repository.record_filter_completed(
|
|
204
|
+
clip_id=clip.clip_id,
|
|
205
|
+
result=filter_result,
|
|
206
|
+
duration_ms=int(duration * 1000),
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
# Repository coordinates state + event atomically:
|
|
210
|
+
# 1. Updates state.filter_result = result
|
|
211
|
+
# 2. Appends FilterCompletedEvent(...)
|
|
212
|
+
# 3. Handles retries with exponential backoff
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
**❌ Anti-Pattern: Direct State/Event Manipulation**
|
|
216
|
+
|
|
217
|
+
```python
|
|
218
|
+
# BAD: Manually updating state and events
|
|
219
|
+
state = await self._state_store.get(clip_id)
|
|
220
|
+
state.filter_result = result
|
|
221
|
+
await self._state_store.upsert(clip_id, state)
|
|
222
|
+
|
|
223
|
+
event = FilterCompletedEvent(...)
|
|
224
|
+
await self._event_store.append(event) # What if this fails? State already updated!
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
**Why:** Repository ensures state and events stay consistent, provides retry logic, and prevents forgetting to emit events.
|
|
228
|
+
|
|
229
|
+
**Reference:** See `src/homesec/repository/clip_repository.py` for all available methods (`record_upload_completed`, `record_vlm_started`, etc.).
|
|
230
|
+
|
|
231
|
+
---
|
|
232
|
+
|
|
233
|
+
### 4. Plugin Registration
|
|
234
|
+
|
|
235
|
+
**Rule:** Use entry point discovery for all plugin types. Allows users to add custom plugins without modifying core code.
|
|
236
|
+
|
|
237
|
+
**✅ Template: Entry Point Discovery**
|
|
238
|
+
|
|
239
|
+
```python
|
|
240
|
+
# In src/homesec/plugins/notifiers/__init__.py
|
|
241
|
+
from dataclasses import dataclass
|
|
242
|
+
from importlib.metadata import entry_points
|
|
243
|
+
from pydantic import BaseModel
|
|
244
|
+
|
|
245
|
+
@dataclass(frozen=True)
|
|
246
|
+
class NotifierPlugin:
|
|
247
|
+
name: str
|
|
248
|
+
config_model: type[BaseModel]
|
|
249
|
+
factory: Callable[[BaseModel], Notifier]
|
|
250
|
+
|
|
251
|
+
NOTIFIER_REGISTRY: dict[str, NotifierPlugin] = {}
|
|
252
|
+
|
|
253
|
+
def register_notifier(name: str, config_model: type, factory: Callable):
|
|
254
|
+
NOTIFIER_REGISTRY[name] = NotifierPlugin(name, config_model, factory)
|
|
255
|
+
|
|
256
|
+
def discover_notifiers(import_paths: list[str]):
|
|
257
|
+
# Discover from pyproject.toml entry points
|
|
258
|
+
for ep in entry_points(group="homesec.notifiers"):
|
|
259
|
+
register_fn = ep.load()
|
|
260
|
+
register_fn()
|
|
261
|
+
|
|
262
|
+
# In custom plugin package:
|
|
263
|
+
def register():
|
|
264
|
+
from homesec.plugins.notifiers import register_notifier
|
|
265
|
+
register_notifier("mqtt", MQTTConfig, lambda cfg: MQTTNotifier(cfg))
|
|
266
|
+
|
|
267
|
+
# In user's pyproject.toml:
|
|
268
|
+
# [project.entry-points."homesec.notifiers"]
|
|
269
|
+
# mqtt = "homesec_mqtt:register"
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
**Note:** All plugin types (filters, VLMs, storage, notifiers, alert policies) use this unified pattern. For shared utilities, see `src/homesec/plugins/utils.py` which provides `iter_entry_points()` and `load_plugin_from_entry_point()` helpers.
|
|
273
|
+
|
|
274
|
+
**Reference:** See any plugin `__init__.py` file for complete implementations: `src/homesec/plugins/filters/__init__.py`, `src/homesec/plugins/analyzers/__init__.py`, `src/homesec/plugins/storage/__init__.py`, `src/homesec/plugins/notifiers/__init__.py`, or `src/homesec/plugins/alert_policies/__init__.py`.
|
|
275
|
+
|
|
276
|
+
---
|
|
277
|
+
|
|
278
|
+
### 5. Testing Requirements
|
|
279
|
+
|
|
280
|
+
**Rule:** All tests must use Given/When/Then comments. Use mocks from `tests/homesec/mocks/`.
|
|
281
|
+
|
|
282
|
+
**✅ Template: Test with Given/When/Then**
|
|
283
|
+
|
|
284
|
+
```python
|
|
285
|
+
async def test_filter_stage_success():
|
|
286
|
+
# Given: A clip with person detected
|
|
287
|
+
clip = Clip(clip_id="test-001", camera_name="front_door", ...)
|
|
288
|
+
mock_filter = MockFilter(result=FilterResult(detected_classes=["person"], confidence=0.9))
|
|
289
|
+
pipeline = ClipPipeline(filter_plugin=mock_filter, ...)
|
|
290
|
+
|
|
291
|
+
# When: Processing clip through filter stage
|
|
292
|
+
result = await pipeline._filter_stage(clip)
|
|
293
|
+
|
|
294
|
+
# Then: Should return FilterResult with detected person
|
|
295
|
+
assert isinstance(result, FilterResult)
|
|
296
|
+
assert "person" in result.detected_classes
|
|
297
|
+
assert mock_filter.detect_count == 1
|
|
298
|
+
|
|
299
|
+
async def test_filter_stage_failure():
|
|
300
|
+
# Given: A filter that simulates failure
|
|
301
|
+
mock_filter = MockFilter(simulate_failure=True)
|
|
302
|
+
pipeline = ClipPipeline(filter_plugin=mock_filter, ...)
|
|
303
|
+
|
|
304
|
+
# When: Processing clip through filter stage
|
|
305
|
+
result = await pipeline._filter_stage(clip)
|
|
306
|
+
|
|
307
|
+
# Then: Should return FilterError with cause preserved
|
|
308
|
+
assert isinstance(result, FilterError)
|
|
309
|
+
assert result.cause is not None
|
|
310
|
+
assert result.clip_id == clip.clip_id
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
**Available Mocks:** `MockFilter`, `MockVLM`, `MockStorage`, `MockNotifier`, `MockStateStore`, `MockEventStore`
|
|
314
|
+
All mocks support `simulate_failure=True` and track call counts.
|
|
315
|
+
|
|
316
|
+
---
|
|
317
|
+
|
|
318
|
+
## Project Context
|
|
319
|
+
|
|
320
|
+
### Key Directories
|
|
321
|
+
|
|
322
|
+
**Source code:**
|
|
323
|
+
- `src/homesec/interfaces.py` - Protocol definitions for all plugin types
|
|
324
|
+
- `src/homesec/pipeline/core.py` - ClipPipeline orchestrator (main business logic)
|
|
325
|
+
- `src/homesec/app.py` - Application class (component wiring and lifecycle)
|
|
326
|
+
- `src/homesec/models/` - Pydantic models (Config, ClipStateData, events, etc.)
|
|
327
|
+
- `src/homesec/plugins/` - Plugin implementations (filters, VLMs, notifiers, storage, alert_policies)
|
|
328
|
+
- `src/homesec/repository/` - ClipRepository for coordinated state + event writes
|
|
329
|
+
- `src/homesec/state/` - StateStore and EventStore implementations
|
|
330
|
+
- `src/homesec/sources/` - Clip source implementations (RTSP, FTP, LocalFolder)
|
|
331
|
+
|
|
332
|
+
**Tests:**
|
|
333
|
+
- `tests/homesec/mocks/` - Mock implementations of all interfaces
|
|
334
|
+
- `tests/homesec/test_pipeline.py` - Comprehensive pipeline tests
|
|
335
|
+
- `tests/homesec/test_integration.py` - End-to-end tests
|
|
336
|
+
|
|
337
|
+
**Legacy (ignore for new development):**
|
|
338
|
+
- `src/motion_recorder.py`, `src/ftp_dropbox_server.py`, `src/human_filter/`, `src/evals/` - Deprecated
|
|
339
|
+
|
|
340
|
+
### Build & Test Commands
|
|
341
|
+
|
|
342
|
+
```bash
|
|
343
|
+
uv sync # Install dependencies
|
|
344
|
+
make typecheck # Run mypy --strict (mandatory before commit)
|
|
345
|
+
make test # Run pytest
|
|
346
|
+
make check # Run both typecheck + test
|
|
347
|
+
make db-up # Start Postgres for development
|
|
348
|
+
make db-migrate # Run Alembic migrations
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
### Pattern Usage Examples
|
|
352
|
+
|
|
353
|
+
**Error-as-value:** `src/homesec/pipeline/core.py::_filter_stage()`, `_upload_stage()`, `_vlm_stage()`
|
|
354
|
+
**Async patterns:** `src/homesec/plugins/filters/yolo.py`, `src/homesec/plugins/analyzers/openai.py`, `src/homesec/plugins/storage/dropbox.py`
|
|
355
|
+
**Repository:** `src/homesec/pipeline/core.py` (search for `self._repository.record_`)
|
|
356
|
+
**Plugin registration:** `src/homesec/plugins/notifiers/__init__.py`, `src/homesec/plugins/alert_policies/__init__.py`
|
|
357
|
+
**Testing:** `tests/homesec/test_pipeline.py`
|
|
358
|
+
|
|
359
|
+
---
|
|
360
|
+
|
|
361
|
+
## Style & Security
|
|
362
|
+
|
|
363
|
+
### Modern Python (3.10+)
|
|
364
|
+
|
|
365
|
+
- **Match statements** for pattern matching (preferred over if/elif with isinstance)
|
|
366
|
+
- **Union types** with `|` instead of `Union` (e.g., `str | None` not `Optional[str]`)
|
|
367
|
+
- **Type narrowing** via match or isinstance for union types
|
|
368
|
+
- **Type hints everywhere** - `make typecheck` enforces this
|
|
369
|
+
|
|
370
|
+
### Naming Conventions
|
|
371
|
+
|
|
372
|
+
- `snake_case` for functions/variables/modules
|
|
373
|
+
- `PascalCase` for classes
|
|
374
|
+
- `UPPER_SNAKE_CASE` for constants
|
|
375
|
+
- 4-space indentation
|
|
376
|
+
|
|
377
|
+
### Security
|
|
378
|
+
|
|
379
|
+
```python
|
|
380
|
+
# ✅ GOOD: Reference env var name in config
|
|
381
|
+
storage:
|
|
382
|
+
backend: dropbox
|
|
383
|
+
dropbox:
|
|
384
|
+
token_env: "DROPBOX_TOKEN" # Config stores env var NAME
|
|
385
|
+
|
|
386
|
+
# ❌ BAD: Never commit secrets
|
|
387
|
+
token: "sl.ABC123..." # Don't do this!
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
- Never commit: `.env`, tokens, RTSP credentials, database DSNs
|
|
391
|
+
- Validate external inputs with Pydantic before processing
|
|
392
|
+
- Never log secrets - redact sensitive data in error messages
|
|
393
|
+
|
|
394
|
+
### Commit Style
|
|
395
|
+
|
|
396
|
+
- Short, task-focused messages (follow repo history)
|
|
397
|
+
- Optional prefixes: `docs:`, `fix:`, `test:`
|
|
398
|
+
- Include issue refs: `(#42)`
|
|
399
|
+
- Run `make check` before committing
|
|
400
|
+
|
|
401
|
+
---
|
|
402
|
+
|
|
403
|
+
## Quick Reference Links
|
|
404
|
+
|
|
405
|
+
**Architecture & Design:**
|
|
406
|
+
- See `DESIGN.md` for full architecture overview and design decisions
|
|
407
|
+
- See `src/homesec/interfaces.py` for complete interface definitions
|
|
408
|
+
|
|
409
|
+
**Plugin Development:**
|
|
410
|
+
- Notifier example: `src/homesec/plugins/notifiers/mqtt.py`
|
|
411
|
+
- Filter example: `src/homesec/plugins/filters/yolo.py`
|
|
412
|
+
- VLM example: `src/homesec/plugins/analyzers/openai.py`
|
|
413
|
+
- Storage example: `src/homesec/plugins/storage/dropbox.py`
|
|
414
|
+
- Alert policy example: `src/homesec/plugins/alert_policies/default.py`
|
|
415
|
+
|
|
416
|
+
**Testing Examples:**
|
|
417
|
+
- Pipeline tests: `tests/homesec/test_pipeline.py`
|
|
418
|
+
- Mock usage: `tests/homesec/conftest.py`
|
|
419
|
+
- Integration tests: `tests/homesec/test_integration.py`
|