homesec 1.1.1__py3-none-any.whl → 1.2.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 (46) hide show
  1. homesec/__init__.py +1 -1
  2. homesec/app.py +38 -84
  3. homesec/cli.py +6 -10
  4. homesec/config/validation.py +38 -12
  5. homesec/interfaces.py +50 -2
  6. homesec/maintenance/cleanup_clips.py +4 -4
  7. homesec/models/__init__.py +6 -5
  8. homesec/models/alert.py +3 -2
  9. homesec/models/clip.py +4 -2
  10. homesec/models/config.py +62 -17
  11. homesec/models/enums.py +114 -0
  12. homesec/models/events.py +19 -18
  13. homesec/models/filter.py +13 -3
  14. homesec/models/source.py +4 -0
  15. homesec/models/vlm.py +18 -7
  16. homesec/plugins/__init__.py +7 -33
  17. homesec/plugins/alert_policies/__init__.py +34 -59
  18. homesec/plugins/alert_policies/default.py +20 -45
  19. homesec/plugins/alert_policies/noop.py +14 -29
  20. homesec/plugins/analyzers/__init__.py +20 -105
  21. homesec/plugins/analyzers/openai.py +70 -53
  22. homesec/plugins/filters/__init__.py +18 -102
  23. homesec/plugins/filters/yolo.py +103 -66
  24. homesec/plugins/notifiers/__init__.py +20 -56
  25. homesec/plugins/notifiers/mqtt.py +22 -30
  26. homesec/plugins/notifiers/sendgrid_email.py +34 -32
  27. homesec/plugins/registry.py +160 -0
  28. homesec/plugins/sources/__init__.py +45 -0
  29. homesec/plugins/sources/ftp.py +25 -0
  30. homesec/plugins/sources/local_folder.py +30 -0
  31. homesec/plugins/sources/rtsp.py +27 -0
  32. homesec/plugins/storage/__init__.py +18 -88
  33. homesec/plugins/storage/dropbox.py +36 -37
  34. homesec/plugins/storage/local.py +8 -29
  35. homesec/plugins/utils.py +8 -4
  36. homesec/repository/clip_repository.py +20 -14
  37. homesec/sources/base.py +24 -2
  38. homesec/sources/local_folder.py +57 -78
  39. homesec/sources/rtsp.py +45 -4
  40. homesec/state/postgres.py +46 -17
  41. {homesec-1.1.1.dist-info → homesec-1.2.0.dist-info}/METADATA +1 -1
  42. homesec-1.2.0.dist-info/RECORD +68 -0
  43. homesec-1.1.1.dist-info/RECORD +0 -62
  44. {homesec-1.1.1.dist-info → homesec-1.2.0.dist-info}/WHEEL +0 -0
  45. {homesec-1.1.1.dist-info → homesec-1.2.0.dist-info}/entry_points.txt +0 -0
  46. {homesec-1.1.1.dist-info → homesec-1.2.0.dist-info}/licenses/LICENSE +0 -0
@@ -10,6 +10,7 @@ from typing import TYPE_CHECKING, TypeVar
10
10
 
11
11
  from homesec.models.clip import Clip, ClipStateData
12
12
  from homesec.models.config import RetryConfig
13
+ from homesec.models.enums import ClipStatus, RiskLevelField
13
14
  from homesec.models.events import (
14
15
  AlertDecisionMadeEvent,
15
16
  ClipDeletedEvent,
@@ -63,7 +64,7 @@ class ClipRepository:
63
64
  """Create initial state + record clip received event."""
64
65
  state = ClipStateData(
65
66
  camera_name=clip.camera_name,
66
- status="queued_local",
67
+ status=ClipStatus.QUEUED_LOCAL,
67
68
  local_path=str(clip.local_path),
68
69
  )
69
70
 
@@ -105,8 +106,13 @@ class ClipRepository:
105
106
 
106
107
  state.storage_uri = storage_uri
107
108
  state.view_url = view_url
108
- if state.status not in ("analyzed", "done", "error", "deleted"):
109
- state.status = "uploaded"
109
+ if state.status not in (
110
+ ClipStatus.ANALYZED,
111
+ ClipStatus.DONE,
112
+ ClipStatus.ERROR,
113
+ ClipStatus.DELETED,
114
+ ):
115
+ state.status = ClipStatus.UPLOADED
110
116
 
111
117
  event = UploadCompletedEvent(
112
118
  clip_id=clip_id,
@@ -208,8 +214,8 @@ class ClipRepository:
208
214
  if state is None:
209
215
  return None
210
216
 
211
- if state.status != "deleted":
212
- state.status = "error"
217
+ if state.status != ClipStatus.DELETED:
218
+ state.status = ClipStatus.ERROR
213
219
  await self._safe_upsert(clip_id, state)
214
220
  return state
215
221
 
@@ -238,8 +244,8 @@ class ClipRepository:
238
244
  return None
239
245
 
240
246
  state.analysis_result = result
241
- if state.status != "deleted":
242
- state.status = "analyzed"
247
+ if state.status != ClipStatus.DELETED:
248
+ state.status = ClipStatus.ANALYZED
243
249
 
244
250
  event = VLMCompletedEvent(
245
251
  clip_id=clip_id,
@@ -294,7 +300,7 @@ class ClipRepository:
294
300
  clip_id: str,
295
301
  decision: AlertDecision,
296
302
  detected_classes: list[str] | None,
297
- vlm_risk: str | None,
303
+ vlm_risk: RiskLevelField | None,
298
304
  ) -> ClipStateData | None:
299
305
  """Record alert decision + update state."""
300
306
  state = await self._load_state(clip_id, action="alert decision")
@@ -328,8 +334,8 @@ class ClipRepository:
328
334
  if state is None:
329
335
  return None
330
336
 
331
- if state.status != "deleted":
332
- state.status = "done"
337
+ if state.status != ClipStatus.DELETED:
338
+ state.status = ClipStatus.DONE
333
339
 
334
340
  event = NotificationSentEvent(
335
341
  clip_id=clip_id,
@@ -380,7 +386,7 @@ class ClipRepository:
380
386
  if state is None:
381
387
  return None
382
388
 
383
- state.status = "deleted"
389
+ state.status = ClipStatus.DELETED
384
390
 
385
391
  event = ClipDeletedEvent(
386
392
  clip_id=clip_id,
@@ -411,7 +417,7 @@ class ClipRepository:
411
417
  state = await self._load_state(clip_id, action="clip recheck")
412
418
  if state is None:
413
419
  return None
414
- if state.status == "deleted":
420
+ if state.status == ClipStatus.DELETED:
415
421
  return state
416
422
 
417
423
  state.filter_result = result
@@ -464,10 +470,10 @@ class ClipRepository:
464
470
  if state is None:
465
471
  return None
466
472
 
467
- if state.status in ("done", "deleted"):
473
+ if state.status in (ClipStatus.DONE, ClipStatus.DELETED):
468
474
  return state
469
475
 
470
- state.status = "done"
476
+ state.status = ClipStatus.DONE
471
477
  await self._safe_upsert(clip_id, state)
472
478
  return state
473
479
 
homesec/sources/base.py CHANGED
@@ -23,6 +23,7 @@ class ThreadedClipSource(ClipSource, ABC):
23
23
  self._thread: Thread | None = None
24
24
  self._stop_event = Event()
25
25
  self._last_heartbeat = time.monotonic()
26
+ self._started = False
26
27
 
27
28
  def register_callback(self, callback: Callable[[Clip], None]) -> None:
28
29
  """Register callback to be invoked when a new clip is ready."""
@@ -34,6 +35,7 @@ class ThreadedClipSource(ClipSource, ABC):
34
35
  logger.warning("%s already started", self.__class__.__name__)
35
36
  return
36
37
 
38
+ self._started = True
37
39
  self._stop_event.clear()
38
40
  self._on_start()
39
41
  self._thread = Thread(target=self._run_wrapper, daemon=True)
@@ -68,12 +70,21 @@ class ThreadedClipSource(ClipSource, ABC):
68
70
  """Return monotonic timestamp of last heartbeat update."""
69
71
  return self._last_heartbeat
70
72
 
73
+ async def ping(self) -> bool:
74
+ """Health check - verify source is operational.
75
+
76
+ Returns True if:
77
+ - Source not started yet (ready to start)
78
+ - Background thread is alive
79
+ """
80
+ return self._thread_is_healthy()
81
+
71
82
  def _touch_heartbeat(self) -> None:
72
83
  self._last_heartbeat = time.monotonic()
73
84
 
74
85
  def _thread_is_healthy(self) -> bool:
75
86
  if self._thread is None:
76
- return True
87
+ return not self._started
77
88
  return self._thread.is_alive()
78
89
 
79
90
  def _emit_clip(self, clip: Clip) -> None:
@@ -126,6 +137,7 @@ class AsyncClipSource(ClipSource, ABC):
126
137
  self._task: asyncio.Task[None] | None = None
127
138
  self._stop_event = asyncio.Event()
128
139
  self._last_heartbeat = time.monotonic()
140
+ self._started = False
129
141
 
130
142
  def register_callback(self, callback: Callable[[Clip], None]) -> None:
131
143
  """Register callback to be invoked when a new clip is ready."""
@@ -137,6 +149,7 @@ class AsyncClipSource(ClipSource, ABC):
137
149
  logger.warning("%s already started", self.__class__.__name__)
138
150
  return
139
151
 
152
+ self._started = True
140
153
  self._stop_event.clear()
141
154
  self._on_start()
142
155
  self._task = asyncio.create_task(self._run_wrapper())
@@ -174,12 +187,21 @@ class AsyncClipSource(ClipSource, ABC):
174
187
  """Return timestamp (monotonic) of last successful operation."""
175
188
  return self._last_heartbeat
176
189
 
190
+ async def ping(self) -> bool:
191
+ """Health check - verify source is operational.
192
+
193
+ Returns True if:
194
+ - Source not started yet (ready to start)
195
+ - Background task is running
196
+ """
197
+ return self._task_is_healthy()
198
+
177
199
  def _touch_heartbeat(self) -> None:
178
200
  self._last_heartbeat = time.monotonic()
179
201
 
180
202
  def _task_is_healthy(self) -> bool:
181
203
  if self._task is None:
182
- return True
204
+ return not self._started
183
205
  return not self._task.done()
184
206
 
185
207
  def _emit_clip(self, clip: Clip) -> None:
@@ -3,13 +3,13 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
+ import json
6
7
  import logging
7
8
  import time
8
9
  from collections import OrderedDict
9
10
  from collections.abc import Callable
10
11
  from datetime import datetime, timedelta
11
12
  from pathlib import Path
12
- from typing import TYPE_CHECKING
13
13
 
14
14
  from anyio import Path as AsyncPath
15
15
 
@@ -17,9 +17,6 @@ from homesec.models.clip import Clip
17
17
  from homesec.models.source import LocalFolderSourceConfig
18
18
  from homesec.sources.base import AsyncClipSource
19
19
 
20
- if TYPE_CHECKING:
21
- from homesec.interfaces import StateStore
22
-
23
20
  logger = logging.getLogger(__name__)
24
21
 
25
22
 
@@ -34,38 +31,38 @@ class LocalFolderSource(AsyncClipSource):
34
31
  def __init__(
35
32
  self,
36
33
  config: LocalFolderSourceConfig,
37
- camera_name: str = "local",
38
- state_store: StateStore | None = None,
34
+ camera_name: str | None = None,
39
35
  ) -> None:
40
36
  """Initialize folder watcher.
41
37
 
42
38
  Args:
43
39
  config: LocalFolder source configuration
44
- camera_name: Name of the camera (used in Clip objects)
45
- state_store: Optional state store for deduplication via clip_states table.
46
- If None, falls back to in-memory cache only (may reprocess files after restart).
40
+ camera_name: Name of the camera (overrides config.camera_name).
47
41
  """
48
42
  super().__init__()
49
43
  self.watch_dir = Path(config.watch_dir)
50
- self.camera_name = camera_name
44
+ # Use config's camera_name if not explicitly passed, else default to "local"
45
+ self.camera_name = camera_name or config.camera_name or "local"
51
46
  self.poll_interval = float(config.poll_interval)
52
47
  self.stability_threshold_s = float(config.stability_threshold_s)
53
- self._state_store = state_store
54
48
 
55
49
  # Ensure watch dir exists
56
50
  self.watch_dir.mkdir(parents=True, exist_ok=True)
57
51
 
58
- # Bounded in-memory cache for performance (avoid DB query on every scan)
59
- # 10,000 files 100 days for 1 camera @ 100 clips/day (≈500 KB memory)
60
- # When limit exceeded, oldest half are removed (FIFO eviction)
61
- # This is just an optimization - clip_states table is source of truth
52
+ # Local State Manifest (replaces StateStore dependency)
53
+ # This file tracks which clips have been processed to avoid re-emitting on restart.
54
+ self._state_file = self.watch_dir / ".homesec_state.json"
55
+ self._processed_files: set[str] = set()
56
+ self._load_local_state()
57
+
58
+ # Bounded in-memory cache for performance (avoid checking set on every scan)
62
59
  self._seen_files: OrderedDict[str, None] = OrderedDict()
63
60
  self._max_seen_files = 10000
64
61
 
65
62
  logger.info(
66
- "LocalFolderSource initialized: watch_dir=%s, has_state_store=%s",
63
+ "LocalFolderSource initialized: watch_dir=%s, camera_name=%s",
67
64
  self.watch_dir,
68
- state_store is not None,
65
+ self.camera_name,
69
66
  )
70
67
 
71
68
  def register_callback(self, callback: Callable[[Clip], None]) -> None:
@@ -74,12 +71,7 @@ class LocalFolderSource(AsyncClipSource):
74
71
  logger.debug("Callback registered for %s", self.camera_name)
75
72
 
76
73
  def is_healthy(self) -> bool:
77
- """Check if source is healthy.
78
-
79
- Returns True if:
80
- - Watch directory exists and is readable
81
- - Watch task is alive (if started)
82
- """
74
+ """Check if source is healthy."""
83
75
  if not self.watch_dir.exists():
84
76
  return False
85
77
 
@@ -101,12 +93,34 @@ class LocalFolderSource(AsyncClipSource):
101
93
  def _on_stopped(self) -> None:
102
94
  logger.info("LocalFolderSource stopped")
103
95
 
104
- async def _run(self) -> None:
105
- """Background task that polls for new files.
96
+ def _load_local_state(self) -> None:
97
+ """Load processed files from local JSON manifest."""
98
+ if not self._state_file.exists():
99
+ return
100
+ try:
101
+ with open(self._state_file) as f:
102
+ data = json.load(f)
103
+ if isinstance(data, list):
104
+ self._processed_files = set(data)
105
+ elif isinstance(data, dict) and "processed_files" in data:
106
+ self._processed_files = set(data["processed_files"])
107
+ logger.info("Loaded %d processed files from local state", len(self._processed_files))
108
+ except Exception as e:
109
+ logger.warning("Failed to load local state file: %s", e)
106
110
 
107
- Uses anyio.Path for non-blocking filesystem operations to avoid
108
- stalling the event loop on slow/network filesystems.
109
- """
111
+ def _save_local_state(self) -> None:
112
+ """Save processed files to local JSON manifest."""
113
+ # Simple atomic write
114
+ try:
115
+ temp_file = self._state_file.with_suffix(".tmp")
116
+ with open(temp_file, "w") as f:
117
+ json.dump({"processed_files": list(self._processed_files)}, f)
118
+ temp_file.replace(self._state_file)
119
+ except Exception as e:
120
+ logger.warning("Failed to save local state file: %s", e)
121
+
122
+ async def _run(self) -> None:
123
+ """Background task that polls for new files."""
110
124
  logger.info("Watch loop started")
111
125
 
112
126
  # Create async path wrapper for watch directory
@@ -117,13 +131,18 @@ class LocalFolderSource(AsyncClipSource):
117
131
  # Update heartbeat
118
132
  self._touch_heartbeat()
119
133
 
134
+ new_files_processed = False
135
+
120
136
  # Scan for new .mp4 files (async to avoid blocking event loop)
121
137
  async for async_file_path in async_watch_dir.glob("*.mp4"):
122
138
  file_path = Path(async_file_path) # Convert to regular Path for Clip
123
139
  file_id = str(file_path)
124
140
  clip_id = file_path.stem
125
141
 
126
- # Check in-memory cache first (fast path)
142
+ # Check if already processed (Session cache OR Persistent Manifest)
143
+ if clip_id in self._processed_files:
144
+ continue
145
+
127
146
  if file_id in self._seen_files:
128
147
  continue
129
148
 
@@ -144,16 +163,10 @@ class LocalFolderSource(AsyncClipSource):
144
163
  logger.warning("Failed to stat file %s: %s", file_path, e, exc_info=True)
145
164
  continue
146
165
 
147
- # Check clip_states table (source of truth for deduplication)
148
- # This prevents reprocessing files even after cache eviction or restart
149
- if await self._has_clip_state(clip_id):
150
- # File was already processed - add to cache and skip
151
- self._add_to_cache(file_id)
152
- logger.debug("Skipping already-processed file: %s", file_path.name)
153
- continue
154
-
155
- # Mark as seen in cache
166
+ # Mark as seen
167
+ self._processed_files.add(clip_id)
156
168
  self._add_to_cache(file_id)
169
+ new_files_processed = True
157
170
 
158
171
  # Create Clip object (reuse mtime from async stat to avoid blocking)
159
172
  clip = self._create_clip(file_path, mtime=mtime)
@@ -162,6 +175,11 @@ class LocalFolderSource(AsyncClipSource):
162
175
  logger.info("New clip detected: %s", file_path.name)
163
176
  self._emit_clip(clip)
164
177
 
178
+ # Check for removed files to clean up manifest (OPTIONAL, maybe too expensive?)
179
+ # For now, let's just save if we added anything.
180
+ if new_files_processed:
181
+ await asyncio.to_thread(self._save_local_state)
182
+
165
183
  except Exception as e:
166
184
  logger.error("Error in watch loop: %s", e, exc_info=True)
167
185
 
@@ -183,50 +201,11 @@ class LocalFolderSource(AsyncClipSource):
183
201
  self._seen_files.popitem(last=False)
184
202
  logger.debug("Evicted %d old entries from seen files cache", evict_count)
185
203
 
186
- async def _has_clip_state(self, clip_id: str) -> bool:
187
- """Check if clip_id exists in clip_states table.
188
-
189
- Returns:
190
- True if clip_state exists (any status including 'deleted'), False otherwise
191
- """
192
- if self._state_store is None:
193
- return False
194
-
195
- try:
196
- # Direct async DB access - no threading complexity!
197
- state = await asyncio.wait_for(
198
- self._state_store.get(clip_id),
199
- timeout=5.0,
200
- )
201
- return state is not None
202
- except asyncio.TimeoutError:
203
- logger.warning(
204
- "DB query timeout for clip_states check: %s (assuming not seen)",
205
- clip_id,
206
- )
207
- return False
208
- except Exception as e:
209
- logger.warning(
210
- "Error checking clip_states for %s: %s (assuming not seen)",
211
- clip_id,
212
- e,
213
- exc_info=True,
214
- )
215
- return False
216
-
217
204
  def _create_clip(self, file_path: Path, mtime: float) -> Clip:
218
- """Create Clip object from file path.
219
-
220
- Args:
221
- file_path: Path to the video file
222
- mtime: File modification timestamp (from async stat)
223
-
224
- Estimates timestamps based on file modification time.
225
- """
205
+ """Create Clip object from file path."""
226
206
  mtime_dt = datetime.fromtimestamp(mtime)
227
207
 
228
208
  # Estimate clip duration (assume 10s if we can't determine)
229
- # In production, would parse from filename or video metadata
230
209
  duration_s = 10.0
231
210
 
232
211
  return Clip(
homesec/sources/rtsp.py CHANGED
@@ -219,6 +219,7 @@ class RTSPSource(ThreadedClipSource):
219
219
  self.reconnect_backoff_s = float(config.reconnect_backoff_s)
220
220
  self.rtsp_connect_timeout_s = float(config.rtsp_connect_timeout_s)
221
221
  self.rtsp_io_timeout_s = float(config.rtsp_io_timeout_s)
222
+ self.ffmpeg_flags = list(config.ffmpeg_flags)
222
223
 
223
224
  if config.disable_hwaccel:
224
225
  logger.info("Hardware acceleration manually disabled")
@@ -551,9 +552,31 @@ class RTSPSource(ThreadedClipSource):
551
552
  "-f",
552
553
  "mp4",
553
554
  "-y",
554
- str(output_file),
555
555
  ]
556
556
 
557
+ user_flags = self.ffmpeg_flags
558
+
559
+ # Naive check to see if user overrode defaults
560
+ # If user supplies ANY -loglevel, we don't add ours.
561
+ # If user supplies ANY -fflags, we don't add ours (to avoid concatenation complexity).
562
+ # This allows full user control.
563
+ has_loglevel = any(x == "-loglevel" for x in user_flags)
564
+ if not has_loglevel:
565
+ cmd.extend(["-loglevel", "warning"])
566
+
567
+ has_fflags = any(x == "-fflags" for x in user_flags)
568
+ if not has_fflags:
569
+ cmd.extend(["-fflags", "+genpts+igndts"])
570
+
571
+ has_fps_mode = any(x == "-fps_mode" or x == "-vsync" for x in user_flags)
572
+ if not has_fps_mode:
573
+ cmd.extend(["-vsync", "0"])
574
+
575
+ # Add user flags last so they can potentially override or add to the above
576
+ cmd.extend(user_flags)
577
+
578
+ cmd.extend([str(output_file)])
579
+
557
580
  safe_cmd = list(cmd)
558
581
  try:
559
582
  idx = safe_cmd.index("-i")
@@ -753,6 +776,7 @@ class RTSPSource(ThreadedClipSource):
753
776
 
754
777
  cmd = ["ffmpeg"]
755
778
 
779
+ # 1. Global Flags (Hardware Acceleration)
756
780
  if self.hwaccel_config.is_available and not self._hwaccel_failed:
757
781
  hwaccel = self.hwaccel_config.hwaccel
758
782
  if hwaccel is not None:
@@ -762,6 +786,23 @@ class RTSPSource(ThreadedClipSource):
762
786
  elif self._hwaccel_failed:
763
787
  logger.info("Hardware acceleration disabled due to previous failures")
764
788
 
789
+ # 2. Global Flags (Robustness & Logging)
790
+ user_flags = self.ffmpeg_flags
791
+
792
+ has_loglevel = any(x == "-loglevel" for x in user_flags)
793
+ if not has_loglevel:
794
+ cmd.extend(["-loglevel", "warning"])
795
+
796
+ has_fflags = any(x == "-fflags" for x in user_flags)
797
+ if not has_fflags:
798
+ cmd.extend(["-fflags", "+genpts+igndts"])
799
+
800
+ # Add all user flags to global scope.
801
+ # Users who want input-specific flags (before -i) must rely on ffmpeg parsing them correctly
802
+ # or we would need a more complex config structure.
803
+ # For now, most robustness flags (-re, -rtsp_transport, etc) work as global or are handled below.
804
+ cmd.extend(user_flags)
805
+
765
806
  base_input_args = [
766
807
  "-rtsp_transport",
767
808
  "tcp",
@@ -778,11 +819,10 @@ class RTSPSource(ThreadedClipSource):
778
819
  ]
779
820
 
780
821
  timeout_us_connect = str(int(max(0.1, self.rtsp_connect_timeout_s) * 1_000_000))
781
- timeout_us_io = str(int(max(0.1, self.rtsp_io_timeout_s) * 1_000_000))
782
822
  attempts: list[tuple[str, list[str]]] = [
783
823
  (
784
- "stimeout+rw_timeout",
785
- ["-stimeout", timeout_us_connect, "-rw_timeout", timeout_us_io] + base_input_args,
824
+ "stimeout",
825
+ ["-stimeout", timeout_us_connect] + base_input_args,
786
826
  ),
787
827
  ("stimeout", ["-stimeout", timeout_us_connect] + base_input_args),
788
828
  ("no_timeouts", base_input_args),
@@ -790,6 +830,7 @@ class RTSPSource(ThreadedClipSource):
790
830
 
791
831
  process: subprocess.Popen[bytes] | None = None
792
832
  stderr_file: Any | None = None
833
+
793
834
  for label, extra_args in attempts:
794
835
  cmd_attempt = list(cmd) + extra_args
795
836
  logger.debug("Starting frame pipeline (%s), logging to: %s", label, stderr_log)
homesec/state/postgres.py CHANGED
@@ -18,6 +18,7 @@ from sqlalchemy import (
18
18
  func,
19
19
  or_,
20
20
  select,
21
+ text,
21
22
  )
22
23
  from sqlalchemy.dialects.postgresql import JSONB
23
24
  from sqlalchemy.dialects.postgresql import insert as pg_insert
@@ -27,6 +28,7 @@ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
27
28
 
28
29
  from homesec.interfaces import EventStore, StateStore
29
30
  from homesec.models.clip import ClipStateData
31
+ from homesec.models.enums import EventType
30
32
  from homesec.models.events import (
31
33
  AlertDecisionMadeEvent,
32
34
  ClipDeletedEvent,
@@ -52,23 +54,25 @@ from homesec.models.events import (
52
54
 
53
55
  logger = logging.getLogger(__name__)
54
56
 
57
+ # Map EventType enum to event model classes
58
+ # Using enum values ensures consistency with event models
55
59
  _EVENT_TYPE_MAP: dict[str, type[ClipEventModel]] = {
56
- "clip_recorded": ClipRecordedEvent,
57
- "clip_deleted": ClipDeletedEvent,
58
- "clip_rechecked": ClipRecheckedEvent,
59
- "upload_started": UploadStartedEvent,
60
- "upload_completed": UploadCompletedEvent,
61
- "upload_failed": UploadFailedEvent,
62
- "filter_started": FilterStartedEvent,
63
- "filter_completed": FilterCompletedEvent,
64
- "filter_failed": FilterFailedEvent,
65
- "vlm_started": VLMStartedEvent,
66
- "vlm_completed": VLMCompletedEvent,
67
- "vlm_failed": VLMFailedEvent,
68
- "vlm_skipped": VLMSkippedEvent,
69
- "alert_decision_made": AlertDecisionMadeEvent,
70
- "notification_sent": NotificationSentEvent,
71
- "notification_failed": NotificationFailedEvent,
60
+ EventType.CLIP_RECORDED: ClipRecordedEvent,
61
+ EventType.CLIP_DELETED: ClipDeletedEvent,
62
+ EventType.CLIP_RECHECKED: ClipRecheckedEvent,
63
+ EventType.UPLOAD_STARTED: UploadStartedEvent,
64
+ EventType.UPLOAD_COMPLETED: UploadCompletedEvent,
65
+ EventType.UPLOAD_FAILED: UploadFailedEvent,
66
+ EventType.FILTER_STARTED: FilterStartedEvent,
67
+ EventType.FILTER_COMPLETED: FilterCompletedEvent,
68
+ EventType.FILTER_FAILED: FilterFailedEvent,
69
+ EventType.VLM_STARTED: VLMStartedEvent,
70
+ EventType.VLM_COMPLETED: VLMCompletedEvent,
71
+ EventType.VLM_FAILED: VLMFailedEvent,
72
+ EventType.VLM_SKIPPED: VLMSkippedEvent,
73
+ EventType.ALERT_DECISION_MADE: AlertDecisionMadeEvent,
74
+ EventType.NOTIFICATION_SENT: NotificationSentEvent,
75
+ EventType.NOTIFICATION_FAILED: NotificationFailedEvent,
72
76
  }
73
77
 
74
78
 
@@ -338,7 +342,7 @@ class PostgresStateStore(StateStore):
338
342
  """Parse JSONB payload from SQLAlchemy into a dict."""
339
343
  return _parse_jsonb_payload(raw)
340
344
 
341
- def create_event_store(self) -> PostgresEventStore | NoopEventStore:
345
+ def create_event_store(self) -> EventStore:
342
346
  """Create a Postgres-backed event store or a no-op fallback."""
343
347
  if self._engine is None:
344
348
  return NoopEventStore()
@@ -455,6 +459,19 @@ class PostgresEventStore(EventStore):
455
459
  logger.error("Failed to get events for %s: %s", clip_id, e, exc_info=e)
456
460
  return []
457
461
 
462
+ async def shutdown(self, timeout: float | None = None) -> None:
463
+ """Shutdown is handled by PostgresStateStore which owns the engine."""
464
+ _ = timeout
465
+
466
+ async def ping(self) -> bool:
467
+ """Health check - verify database is reachable."""
468
+ try:
469
+ async with self._engine.connect() as conn:
470
+ await conn.execute(text("SELECT 1"))
471
+ return True
472
+ except Exception:
473
+ return False
474
+
458
475
 
459
476
  class NoopEventStore(EventStore):
460
477
  """Event store that drops events (used when Postgres is unavailable)."""
@@ -469,6 +486,14 @@ class NoopEventStore(EventStore):
469
486
  ) -> list[ClipLifecycleEvent]:
470
487
  return []
471
488
 
489
+ async def shutdown(self, timeout: float | None = None) -> None:
490
+ """No resources to clean up."""
491
+ _ = timeout
492
+
493
+ async def ping(self) -> bool:
494
+ """Noop store is always 'unhealthy' - indicates no real backend."""
495
+ return False
496
+
472
497
 
473
498
  class NoopStateStore(StateStore):
474
499
  """State store that drops writes and returns no data."""
@@ -498,3 +523,7 @@ class NoopStateStore(StateStore):
498
523
 
499
524
  async def ping(self) -> bool:
500
525
  return False
526
+
527
+ def create_event_store(self) -> EventStore:
528
+ """Return NoopEventStore."""
529
+ return NoopEventStore()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: homesec
3
- Version: 1.1.1
3
+ Version: 1.2.0
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