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.
Files changed (105) hide show
  1. homesec-0.1.0/.env.example +58 -0
  2. homesec-0.1.0/.gitignore +4 -0
  3. homesec-0.1.0/AGENTS.md +419 -0
  4. homesec-0.1.0/DESIGN.md +1185 -0
  5. homesec-0.1.0/LICENSE +201 -0
  6. homesec-0.1.0/Makefile +61 -0
  7. homesec-0.1.0/PKG-INFO +446 -0
  8. homesec-0.1.0/README.md +203 -0
  9. homesec-0.1.0/alembic/env.py +70 -0
  10. homesec-0.1.0/alembic.ini +38 -0
  11. homesec-0.1.0/config/example.yaml +85 -0
  12. homesec-0.1.0/config/local.yaml +68 -0
  13. homesec-0.1.0/config/production.yaml +113 -0
  14. homesec-0.1.0/config/sample.yaml +66 -0
  15. homesec-0.1.0/docker-compose.postgres.yml +17 -0
  16. homesec-0.1.0/pyproject.toml +86 -0
  17. homesec-0.1.0/src/homesec/__init__.py +20 -0
  18. homesec-0.1.0/src/homesec/app.py +393 -0
  19. homesec-0.1.0/src/homesec/cli.py +159 -0
  20. homesec-0.1.0/src/homesec/config/__init__.py +18 -0
  21. homesec-0.1.0/src/homesec/config/loader.py +109 -0
  22. homesec-0.1.0/src/homesec/config/validation.py +82 -0
  23. homesec-0.1.0/src/homesec/errors.py +71 -0
  24. homesec-0.1.0/src/homesec/health/__init__.py +5 -0
  25. homesec-0.1.0/src/homesec/health/server.py +226 -0
  26. homesec-0.1.0/src/homesec/interfaces.py +249 -0
  27. homesec-0.1.0/src/homesec/logging_setup.py +176 -0
  28. homesec-0.1.0/src/homesec/maintenance/__init__.py +1 -0
  29. homesec-0.1.0/src/homesec/maintenance/cleanup_clips.py +632 -0
  30. homesec-0.1.0/src/homesec/models/__init__.py +79 -0
  31. homesec-0.1.0/src/homesec/models/alert.py +32 -0
  32. homesec-0.1.0/src/homesec/models/clip.py +71 -0
  33. homesec-0.1.0/src/homesec/models/config.py +362 -0
  34. homesec-0.1.0/src/homesec/models/events.py +184 -0
  35. homesec-0.1.0/src/homesec/models/filter.py +62 -0
  36. homesec-0.1.0/src/homesec/models/source.py +77 -0
  37. homesec-0.1.0/src/homesec/models/storage.py +12 -0
  38. homesec-0.1.0/src/homesec/models/vlm.py +99 -0
  39. homesec-0.1.0/src/homesec/pipeline/__init__.py +6 -0
  40. homesec-0.1.0/src/homesec/pipeline/alert_policy.py +5 -0
  41. homesec-0.1.0/src/homesec/pipeline/core.py +639 -0
  42. homesec-0.1.0/src/homesec/plugins/__init__.py +62 -0
  43. homesec-0.1.0/src/homesec/plugins/alert_policies/__init__.py +80 -0
  44. homesec-0.1.0/src/homesec/plugins/alert_policies/default.py +111 -0
  45. homesec-0.1.0/src/homesec/plugins/alert_policies/noop.py +60 -0
  46. homesec-0.1.0/src/homesec/plugins/analyzers/__init__.py +126 -0
  47. homesec-0.1.0/src/homesec/plugins/analyzers/openai.py +446 -0
  48. homesec-0.1.0/src/homesec/plugins/filters/__init__.py +124 -0
  49. homesec-0.1.0/src/homesec/plugins/filters/yolo.py +317 -0
  50. homesec-0.1.0/src/homesec/plugins/notifiers/__init__.py +80 -0
  51. homesec-0.1.0/src/homesec/plugins/notifiers/mqtt.py +189 -0
  52. homesec-0.1.0/src/homesec/plugins/notifiers/multiplex.py +106 -0
  53. homesec-0.1.0/src/homesec/plugins/notifiers/sendgrid_email.py +228 -0
  54. homesec-0.1.0/src/homesec/plugins/storage/__init__.py +116 -0
  55. homesec-0.1.0/src/homesec/plugins/storage/dropbox.py +272 -0
  56. homesec-0.1.0/src/homesec/plugins/storage/local.py +108 -0
  57. homesec-0.1.0/src/homesec/plugins/utils.py +63 -0
  58. homesec-0.1.0/src/homesec/py.typed +0 -0
  59. homesec-0.1.0/src/homesec/repository/__init__.py +5 -0
  60. homesec-0.1.0/src/homesec/repository/clip_repository.py +552 -0
  61. homesec-0.1.0/src/homesec/sources/__init__.py +17 -0
  62. homesec-0.1.0/src/homesec/sources/base.py +224 -0
  63. homesec-0.1.0/src/homesec/sources/ftp.py +209 -0
  64. homesec-0.1.0/src/homesec/sources/local_folder.py +238 -0
  65. homesec-0.1.0/src/homesec/sources/rtsp.py +1251 -0
  66. homesec-0.1.0/src/homesec/state/__init__.py +10 -0
  67. homesec-0.1.0/src/homesec/state/postgres.py +501 -0
  68. homesec-0.1.0/src/homesec/storage_paths.py +46 -0
  69. homesec-0.1.0/src/homesec/telemetry/__init__.py +0 -0
  70. homesec-0.1.0/src/homesec/telemetry/db/__init__.py +1 -0
  71. homesec-0.1.0/src/homesec/telemetry/db/log_table.py +16 -0
  72. homesec-0.1.0/src/homesec/telemetry/db_log_handler.py +246 -0
  73. homesec-0.1.0/src/homesec/telemetry/postgres_settings.py +42 -0
  74. homesec-0.1.0/tests/__init__.py +1 -0
  75. homesec-0.1.0/tests/conftest.py +9 -0
  76. homesec-0.1.0/tests/homesec/__init__.py +1 -0
  77. homesec-0.1.0/tests/homesec/conftest.py +97 -0
  78. homesec-0.1.0/tests/homesec/mocks/__init__.py +17 -0
  79. homesec-0.1.0/tests/homesec/mocks/event_store.py +54 -0
  80. homesec-0.1.0/tests/homesec/mocks/filter.py +67 -0
  81. homesec-0.1.0/tests/homesec/mocks/notifier.py +58 -0
  82. homesec-0.1.0/tests/homesec/mocks/state_store.py +83 -0
  83. homesec-0.1.0/tests/homesec/mocks/storage.py +110 -0
  84. homesec-0.1.0/tests/homesec/mocks/vlm.py +64 -0
  85. homesec-0.1.0/tests/homesec/test_alert_policy.py +157 -0
  86. homesec-0.1.0/tests/homesec/test_app.py +217 -0
  87. homesec-0.1.0/tests/homesec/test_cleanup_clips.py +222 -0
  88. homesec-0.1.0/tests/homesec/test_clip_repository.py +299 -0
  89. homesec-0.1.0/tests/homesec/test_clip_sources.py +336 -0
  90. homesec-0.1.0/tests/homesec/test_config.py +423 -0
  91. homesec-0.1.0/tests/homesec/test_dropbox_storage.py +293 -0
  92. homesec-0.1.0/tests/homesec/test_event_store.py +197 -0
  93. homesec-0.1.0/tests/homesec/test_health.py +252 -0
  94. homesec-0.1.0/tests/homesec/test_integration.py +313 -0
  95. homesec-0.1.0/tests/homesec/test_local_folder_deduplication.py +433 -0
  96. homesec-0.1.0/tests/homesec/test_mqtt_notifier.py +122 -0
  97. homesec-0.1.0/tests/homesec/test_notifiers.py +188 -0
  98. homesec-0.1.0/tests/homesec/test_openai_vlm.py +151 -0
  99. homesec-0.1.0/tests/homesec/test_pipeline.py +1091 -0
  100. homesec-0.1.0/tests/homesec/test_pipeline_events.py +310 -0
  101. homesec-0.1.0/tests/homesec/test_plugin_registration.py +410 -0
  102. homesec-0.1.0/tests/homesec/test_rtsp_helpers.py +79 -0
  103. homesec-0.1.0/tests/homesec/test_state_store.py +261 -0
  104. homesec-0.1.0/tests/homesec/test_yolo_filter.py +148 -0
  105. 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"
@@ -0,0 +1,4 @@
1
+ video_cache/
2
+ *.pt
3
+ .env
4
+ __pycache__/
@@ -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`