homesec 0.1.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.
Files changed (62) hide show
  1. homesec/__init__.py +20 -0
  2. homesec/app.py +393 -0
  3. homesec/cli.py +159 -0
  4. homesec/config/__init__.py +18 -0
  5. homesec/config/loader.py +109 -0
  6. homesec/config/validation.py +82 -0
  7. homesec/errors.py +71 -0
  8. homesec/health/__init__.py +5 -0
  9. homesec/health/server.py +226 -0
  10. homesec/interfaces.py +249 -0
  11. homesec/logging_setup.py +176 -0
  12. homesec/maintenance/__init__.py +1 -0
  13. homesec/maintenance/cleanup_clips.py +632 -0
  14. homesec/models/__init__.py +79 -0
  15. homesec/models/alert.py +32 -0
  16. homesec/models/clip.py +71 -0
  17. homesec/models/config.py +362 -0
  18. homesec/models/events.py +184 -0
  19. homesec/models/filter.py +62 -0
  20. homesec/models/source.py +77 -0
  21. homesec/models/storage.py +12 -0
  22. homesec/models/vlm.py +99 -0
  23. homesec/pipeline/__init__.py +6 -0
  24. homesec/pipeline/alert_policy.py +5 -0
  25. homesec/pipeline/core.py +639 -0
  26. homesec/plugins/__init__.py +62 -0
  27. homesec/plugins/alert_policies/__init__.py +80 -0
  28. homesec/plugins/alert_policies/default.py +111 -0
  29. homesec/plugins/alert_policies/noop.py +60 -0
  30. homesec/plugins/analyzers/__init__.py +126 -0
  31. homesec/plugins/analyzers/openai.py +446 -0
  32. homesec/plugins/filters/__init__.py +124 -0
  33. homesec/plugins/filters/yolo.py +317 -0
  34. homesec/plugins/notifiers/__init__.py +80 -0
  35. homesec/plugins/notifiers/mqtt.py +189 -0
  36. homesec/plugins/notifiers/multiplex.py +106 -0
  37. homesec/plugins/notifiers/sendgrid_email.py +228 -0
  38. homesec/plugins/storage/__init__.py +116 -0
  39. homesec/plugins/storage/dropbox.py +272 -0
  40. homesec/plugins/storage/local.py +108 -0
  41. homesec/plugins/utils.py +63 -0
  42. homesec/py.typed +0 -0
  43. homesec/repository/__init__.py +5 -0
  44. homesec/repository/clip_repository.py +552 -0
  45. homesec/sources/__init__.py +17 -0
  46. homesec/sources/base.py +224 -0
  47. homesec/sources/ftp.py +209 -0
  48. homesec/sources/local_folder.py +238 -0
  49. homesec/sources/rtsp.py +1251 -0
  50. homesec/state/__init__.py +10 -0
  51. homesec/state/postgres.py +501 -0
  52. homesec/storage_paths.py +46 -0
  53. homesec/telemetry/__init__.py +0 -0
  54. homesec/telemetry/db/__init__.py +1 -0
  55. homesec/telemetry/db/log_table.py +16 -0
  56. homesec/telemetry/db_log_handler.py +246 -0
  57. homesec/telemetry/postgres_settings.py +42 -0
  58. homesec-0.1.0.dist-info/METADATA +446 -0
  59. homesec-0.1.0.dist-info/RECORD +62 -0
  60. homesec-0.1.0.dist-info/WHEEL +4 -0
  61. homesec-0.1.0.dist-info/entry_points.txt +2 -0
  62. homesec-0.1.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,552 @@
1
+ """ClipRepository for coordinating state + event persistence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ from datetime import datetime
8
+ from typing import TYPE_CHECKING, Awaitable, Callable, TypeVar
9
+
10
+ from homesec.models.clip import Clip, ClipStateData
11
+ from homesec.models.config import RetryConfig
12
+ from homesec.models.events import (
13
+ AlertDecisionMadeEvent,
14
+ ClipDeletedEvent,
15
+ ClipRecheckedEvent,
16
+ ClipLifecycleEvent,
17
+ ClipRecordedEvent,
18
+ FilterCompletedEvent,
19
+ FilterFailedEvent,
20
+ FilterStartedEvent,
21
+ NotificationFailedEvent,
22
+ NotificationSentEvent,
23
+ UploadCompletedEvent,
24
+ UploadFailedEvent,
25
+ UploadStartedEvent,
26
+ VLMCompletedEvent,
27
+ VLMFailedEvent,
28
+ VLMStartedEvent,
29
+ VLMSkippedEvent,
30
+ )
31
+ from homesec.state.postgres import is_retryable_pg_error
32
+
33
+ if TYPE_CHECKING:
34
+ from homesec.models.alert import AlertDecision
35
+ from homesec.models.filter import FilterResult
36
+ from homesec.models.vlm import AnalysisResult
37
+ from homesec.interfaces import EventStore, StateStore
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+ TResult = TypeVar("TResult")
42
+
43
+
44
+ class ClipRepository:
45
+ """Coordinates state + event writes with best-effort retries."""
46
+
47
+ def __init__(
48
+ self,
49
+ state_store: StateStore,
50
+ event_store: EventStore,
51
+ retry: RetryConfig | None = None,
52
+ should_retry: Callable[[Exception], bool] | None = None,
53
+ ) -> None:
54
+ self._state = state_store
55
+ self._events = event_store
56
+ self._retry = retry or RetryConfig()
57
+ self._should_retry = should_retry or is_retryable_pg_error
58
+ self._max_attempts = max(1, int(self._retry.max_attempts))
59
+ self._backoff_s = max(0.0, float(self._retry.backoff_s))
60
+
61
+ async def initialize_clip(self, clip: Clip) -> ClipStateData:
62
+ """Create initial state + record clip received event."""
63
+ state = ClipStateData(
64
+ camera_name=clip.camera_name,
65
+ status="queued_local",
66
+ local_path=str(clip.local_path),
67
+ )
68
+
69
+ event = ClipRecordedEvent(
70
+ clip_id=clip.clip_id,
71
+ timestamp=datetime.now(),
72
+ camera_name=clip.camera_name,
73
+ duration_s=clip.duration_s,
74
+ source_type=clip.source_type,
75
+ )
76
+
77
+ await self._safe_upsert(clip.clip_id, state)
78
+ await self._safe_append(event)
79
+ return state
80
+
81
+ async def record_upload_started(self, clip_id: str, dest_key: str, attempt: int) -> None:
82
+ """Record upload start event."""
83
+ await self._safe_append(
84
+ UploadStartedEvent(
85
+ clip_id=clip_id,
86
+ timestamp=datetime.now(),
87
+ dest_key=dest_key,
88
+ attempt=attempt,
89
+ )
90
+ )
91
+
92
+ async def record_upload_completed(
93
+ self,
94
+ clip_id: str,
95
+ storage_uri: str,
96
+ view_url: str | None,
97
+ duration_ms: int,
98
+ attempt: int = 1,
99
+ ) -> ClipStateData | None:
100
+ """Record upload completion + update state."""
101
+ state = await self._load_state(clip_id, action="upload")
102
+ if state is None:
103
+ return None
104
+
105
+ state.storage_uri = storage_uri
106
+ state.view_url = view_url
107
+ if state.status not in ("analyzed", "done", "error", "deleted"):
108
+ state.status = "uploaded"
109
+
110
+ event = UploadCompletedEvent(
111
+ clip_id=clip_id,
112
+ timestamp=datetime.now(),
113
+ storage_uri=storage_uri,
114
+ view_url=view_url,
115
+ attempt=attempt,
116
+ duration_ms=duration_ms,
117
+ )
118
+
119
+ await self._safe_upsert(clip_id, state)
120
+ await self._safe_append(event)
121
+ return state
122
+
123
+ async def record_upload_failed(
124
+ self,
125
+ clip_id: str,
126
+ error_message: str,
127
+ error_type: str,
128
+ *,
129
+ attempt: int = 1,
130
+ will_retry: bool = False,
131
+ ) -> None:
132
+ """Record upload failure event."""
133
+ await self._safe_append(
134
+ UploadFailedEvent(
135
+ clip_id=clip_id,
136
+ timestamp=datetime.now(),
137
+ attempt=attempt,
138
+ error_message=error_message,
139
+ error_type=error_type,
140
+ will_retry=will_retry,
141
+ )
142
+ )
143
+
144
+ async def record_filter_started(self, clip_id: str, attempt: int) -> None:
145
+ """Record filter start event."""
146
+ await self._safe_append(
147
+ FilterStartedEvent(
148
+ clip_id=clip_id,
149
+ timestamp=datetime.now(),
150
+ attempt=attempt,
151
+ )
152
+ )
153
+
154
+ async def record_filter_completed(
155
+ self,
156
+ clip_id: str,
157
+ result: FilterResult,
158
+ duration_ms: int,
159
+ attempt: int = 1,
160
+ ) -> ClipStateData | None:
161
+ """Record filter completion + update state."""
162
+ state = await self._load_state(clip_id, action="filter")
163
+ if state is None:
164
+ return None
165
+
166
+ state.filter_result = result
167
+
168
+ event = FilterCompletedEvent(
169
+ clip_id=clip_id,
170
+ timestamp=datetime.now(),
171
+ detected_classes=result.detected_classes,
172
+ confidence=result.confidence,
173
+ model=result.model,
174
+ sampled_frames=result.sampled_frames,
175
+ attempt=attempt,
176
+ duration_ms=duration_ms,
177
+ )
178
+
179
+ await self._safe_upsert(clip_id, state)
180
+ await self._safe_append(event)
181
+ return state
182
+
183
+ async def record_filter_failed(
184
+ self,
185
+ clip_id: str,
186
+ error_message: str,
187
+ error_type: str,
188
+ *,
189
+ attempt: int = 1,
190
+ will_retry: bool = False,
191
+ ) -> ClipStateData | None:
192
+ """Record filter failure + mark state as error."""
193
+ event = FilterFailedEvent(
194
+ clip_id=clip_id,
195
+ timestamp=datetime.now(),
196
+ attempt=attempt,
197
+ error_message=error_message,
198
+ error_type=error_type,
199
+ will_retry=will_retry,
200
+ )
201
+
202
+ await self._safe_append(event)
203
+ if will_retry:
204
+ return None
205
+
206
+ state = await self._load_state(clip_id, action="filter failure")
207
+ if state is None:
208
+ return None
209
+
210
+ if state.status != "deleted":
211
+ state.status = "error"
212
+ await self._safe_upsert(clip_id, state)
213
+ return state
214
+
215
+ async def record_vlm_started(self, clip_id: str, attempt: int) -> None:
216
+ """Record VLM start event."""
217
+ await self._safe_append(
218
+ VLMStartedEvent(
219
+ clip_id=clip_id,
220
+ timestamp=datetime.now(),
221
+ attempt=attempt,
222
+ )
223
+ )
224
+
225
+ async def record_vlm_completed(
226
+ self,
227
+ clip_id: str,
228
+ result: AnalysisResult,
229
+ prompt_tokens: int | None,
230
+ completion_tokens: int | None,
231
+ duration_ms: int,
232
+ attempt: int = 1,
233
+ ) -> ClipStateData | None:
234
+ """Record VLM completion + update state."""
235
+ state = await self._load_state(clip_id, action="VLM")
236
+ if state is None:
237
+ return None
238
+
239
+ state.analysis_result = result
240
+ if state.status != "deleted":
241
+ state.status = "analyzed"
242
+
243
+ event = VLMCompletedEvent(
244
+ clip_id=clip_id,
245
+ timestamp=datetime.now(),
246
+ risk_level=result.risk_level,
247
+ activity_type=result.activity_type,
248
+ summary=result.summary,
249
+ analysis=result.analysis.model_dump(mode="json") if result.analysis else {},
250
+ prompt_tokens=prompt_tokens,
251
+ completion_tokens=completion_tokens,
252
+ attempt=attempt,
253
+ duration_ms=duration_ms,
254
+ )
255
+
256
+ await self._safe_upsert(clip_id, state)
257
+ await self._safe_append(event)
258
+ return state
259
+
260
+ async def record_vlm_failed(
261
+ self,
262
+ clip_id: str,
263
+ error_message: str,
264
+ error_type: str,
265
+ *,
266
+ attempt: int = 1,
267
+ will_retry: bool = False,
268
+ ) -> None:
269
+ """Record VLM failure event."""
270
+ await self._safe_append(
271
+ VLMFailedEvent(
272
+ clip_id=clip_id,
273
+ timestamp=datetime.now(),
274
+ attempt=attempt,
275
+ error_message=error_message,
276
+ error_type=error_type,
277
+ will_retry=will_retry,
278
+ )
279
+ )
280
+
281
+ async def record_vlm_skipped(self, clip_id: str, reason: str) -> None:
282
+ """Record VLM skipped event."""
283
+ await self._safe_append(
284
+ VLMSkippedEvent(
285
+ clip_id=clip_id,
286
+ timestamp=datetime.now(),
287
+ reason=reason,
288
+ )
289
+ )
290
+
291
+ async def record_alert_decision(
292
+ self,
293
+ clip_id: str,
294
+ decision: AlertDecision,
295
+ detected_classes: list[str] | None,
296
+ vlm_risk: str | None,
297
+ ) -> ClipStateData | None:
298
+ """Record alert decision + update state."""
299
+ state = await self._load_state(clip_id, action="alert decision")
300
+ if state is None:
301
+ return None
302
+
303
+ state.alert_decision = decision
304
+
305
+ event = AlertDecisionMadeEvent(
306
+ clip_id=clip_id,
307
+ timestamp=datetime.now(),
308
+ should_notify=decision.notify,
309
+ reason=decision.notify_reason,
310
+ detected_classes=detected_classes,
311
+ vlm_risk=vlm_risk,
312
+ )
313
+
314
+ await self._safe_upsert(clip_id, state)
315
+ await self._safe_append(event)
316
+ return state
317
+
318
+ async def record_notification_sent(
319
+ self,
320
+ clip_id: str,
321
+ notifier_name: str,
322
+ dedupe_key: str,
323
+ attempt: int = 1,
324
+ ) -> ClipStateData | None:
325
+ """Record notification sent + mark state as done."""
326
+ state = await self._load_state(clip_id, action="notification")
327
+ if state is None:
328
+ return None
329
+
330
+ if state.status != "deleted":
331
+ state.status = "done"
332
+
333
+ event = NotificationSentEvent(
334
+ clip_id=clip_id,
335
+ timestamp=datetime.now(),
336
+ notifier_name=notifier_name,
337
+ dedupe_key=dedupe_key,
338
+ attempt=attempt,
339
+ )
340
+
341
+ await self._safe_upsert(clip_id, state)
342
+ await self._safe_append(event)
343
+ return state
344
+
345
+ async def record_notification_failed(
346
+ self,
347
+ clip_id: str,
348
+ notifier_name: str,
349
+ error_message: str,
350
+ error_type: str,
351
+ *,
352
+ attempt: int = 1,
353
+ will_retry: bool = False,
354
+ ) -> None:
355
+ """Record notification failure event."""
356
+ await self._safe_append(
357
+ NotificationFailedEvent(
358
+ clip_id=clip_id,
359
+ timestamp=datetime.now(),
360
+ notifier_name=notifier_name,
361
+ error_message=error_message,
362
+ error_type=error_type,
363
+ attempt=attempt,
364
+ will_retry=will_retry,
365
+ )
366
+ )
367
+
368
+ async def record_clip_deleted(
369
+ self,
370
+ clip_id: str,
371
+ *,
372
+ reason: str,
373
+ run_id: str,
374
+ deleted_local: bool,
375
+ deleted_storage: bool,
376
+ ) -> ClipStateData | None:
377
+ """Mark clip as deleted and append a clip_deleted event."""
378
+ state = await self._load_state(clip_id, action="clip delete")
379
+ if state is None:
380
+ return None
381
+
382
+ state.status = "deleted"
383
+
384
+ event = ClipDeletedEvent(
385
+ clip_id=clip_id,
386
+ timestamp=datetime.now(),
387
+ camera_name=state.camera_name,
388
+ reason=reason,
389
+ run_id=run_id,
390
+ local_path=state.local_path,
391
+ storage_uri=state.storage_uri,
392
+ deleted_local=deleted_local,
393
+ deleted_storage=deleted_storage,
394
+ )
395
+
396
+ await self._safe_upsert(clip_id, state)
397
+ await self._safe_append(event)
398
+ return state
399
+
400
+ async def record_clip_rechecked(
401
+ self,
402
+ clip_id: str,
403
+ *,
404
+ result: FilterResult,
405
+ prior_filter: FilterResult | None,
406
+ reason: str,
407
+ run_id: str,
408
+ ) -> ClipStateData | None:
409
+ """Record a recheck result and update the clip state."""
410
+ state = await self._load_state(clip_id, action="clip recheck")
411
+ if state is None:
412
+ return None
413
+ if state.status == "deleted":
414
+ return state
415
+
416
+ state.filter_result = result
417
+
418
+ event = ClipRecheckedEvent(
419
+ clip_id=clip_id,
420
+ timestamp=datetime.now(),
421
+ camera_name=state.camera_name,
422
+ reason=reason,
423
+ run_id=run_id,
424
+ prior_filter=prior_filter,
425
+ recheck_filter=result,
426
+ )
427
+
428
+ await self._safe_upsert(clip_id, state)
429
+ await self._safe_append(event)
430
+ return state
431
+
432
+ async def list_candidate_clips_for_cleanup(
433
+ self,
434
+ *,
435
+ older_than_days: int | None,
436
+ camera_name: str | None,
437
+ batch_size: int,
438
+ cursor: tuple[datetime, str] | None = None,
439
+ ) -> list[tuple[str, ClipStateData, datetime]]:
440
+ """List clip states eligible for cleanup."""
441
+ try:
442
+ return await self._run_with_retries(
443
+ label="State store cleanup list",
444
+ clip_id="cleanup",
445
+ op=lambda: self._state.list_candidate_clips_for_cleanup(
446
+ older_than_days=older_than_days,
447
+ camera_name=camera_name,
448
+ batch_size=batch_size,
449
+ cursor=cursor,
450
+ ),
451
+ )
452
+ except Exception as exc:
453
+ logger.error(
454
+ "State store cleanup list failed after retries: %s",
455
+ exc,
456
+ exc_info=exc,
457
+ )
458
+ return []
459
+
460
+ async def mark_done(self, clip_id: str) -> ClipStateData | None:
461
+ """Mark processing as done (no event)."""
462
+ state = await self._load_state(clip_id, action="completion")
463
+ if state is None:
464
+ return None
465
+
466
+ if state.status in ("done", "deleted"):
467
+ return state
468
+
469
+ state.status = "done"
470
+ await self._safe_upsert(clip_id, state)
471
+ return state
472
+
473
+ async def _load_state(self, clip_id: str, *, action: str) -> ClipStateData | None:
474
+ state = await self._safe_get(clip_id)
475
+ if state is None:
476
+ logger.error("Cannot update %s: clip %s not found", action, clip_id)
477
+ return state
478
+
479
+ async def _safe_get(self, clip_id: str) -> ClipStateData | None:
480
+ try:
481
+ return await self._run_with_retries(
482
+ label="State store get",
483
+ clip_id=clip_id,
484
+ op=lambda: self._state.get(clip_id),
485
+ )
486
+ except Exception as exc:
487
+ logger.error(
488
+ "State store get failed for %s after retries: %s",
489
+ clip_id,
490
+ exc,
491
+ exc_info=exc,
492
+ )
493
+ return None
494
+
495
+ async def _safe_upsert(self, clip_id: str, state: ClipStateData) -> None:
496
+ try:
497
+ await self._run_with_retries(
498
+ label="State store upsert",
499
+ clip_id=clip_id,
500
+ op=lambda: self._state.upsert(clip_id, state),
501
+ )
502
+ except Exception as exc:
503
+ logger.error(
504
+ "State store upsert failed for %s after retries: %s",
505
+ clip_id,
506
+ exc,
507
+ exc_info=exc,
508
+ )
509
+
510
+ async def _safe_append(self, event: ClipLifecycleEvent) -> None:
511
+ clip_id = event.clip_id
512
+ try:
513
+ await self._run_with_retries(
514
+ label="Event store append",
515
+ clip_id=clip_id,
516
+ op=lambda: self._events.append(event),
517
+ )
518
+ except Exception as exc:
519
+ logger.error(
520
+ "Event store append failed for %s after retries: %s",
521
+ clip_id,
522
+ exc,
523
+ exc_info=exc,
524
+ )
525
+
526
+ async def _run_with_retries(
527
+ self,
528
+ *,
529
+ label: str,
530
+ clip_id: str,
531
+ op: Callable[[], Awaitable[TResult]],
532
+ ) -> TResult:
533
+ attempt = 1
534
+
535
+ while True:
536
+ try:
537
+ return await op()
538
+ except Exception as exc:
539
+ if not self._should_retry(exc) or attempt >= self._max_attempts:
540
+ raise
541
+ logger.warning(
542
+ "%s failed for %s (attempt %d/%d): %s",
543
+ label,
544
+ clip_id,
545
+ attempt,
546
+ self._max_attempts,
547
+ exc,
548
+ )
549
+ delay = self._backoff_s * (2 ** (attempt - 1))
550
+ if delay > 0:
551
+ await asyncio.sleep(delay)
552
+ attempt += 1
@@ -0,0 +1,17 @@
1
+ """Clip source implementations."""
2
+
3
+ from homesec.models.source import FtpSourceConfig, LocalFolderSourceConfig, RTSPSourceConfig
4
+ from homesec.sources.base import ThreadedClipSource
5
+ from homesec.sources.ftp import FtpSource
6
+ from homesec.sources.local_folder import LocalFolderSource
7
+ from homesec.sources.rtsp import RTSPSource
8
+
9
+ __all__ = [
10
+ "FtpSource",
11
+ "FtpSourceConfig",
12
+ "LocalFolderSource",
13
+ "LocalFolderSourceConfig",
14
+ "RTSPSource",
15
+ "RTSPSourceConfig",
16
+ "ThreadedClipSource",
17
+ ]