homesec 0.1.1__py3-none-any.whl → 1.0.1__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 (44) hide show
  1. homesec/app.py +34 -36
  2. homesec/cli.py +14 -11
  3. homesec/config/loader.py +11 -11
  4. homesec/config/validation.py +2 -5
  5. homesec/errors.py +2 -4
  6. homesec/health/server.py +29 -27
  7. homesec/interfaces.py +11 -6
  8. homesec/logging_setup.py +9 -5
  9. homesec/maintenance/cleanup_clips.py +2 -3
  10. homesec/models/__init__.py +1 -1
  11. homesec/models/alert.py +2 -0
  12. homesec/models/clip.py +8 -1
  13. homesec/models/config.py +9 -13
  14. homesec/models/events.py +14 -0
  15. homesec/models/filter.py +1 -3
  16. homesec/models/vlm.py +1 -2
  17. homesec/pipeline/core.py +15 -32
  18. homesec/plugins/alert_policies/__init__.py +3 -4
  19. homesec/plugins/alert_policies/default.py +3 -2
  20. homesec/plugins/alert_policies/noop.py +1 -2
  21. homesec/plugins/analyzers/__init__.py +3 -4
  22. homesec/plugins/analyzers/openai.py +34 -43
  23. homesec/plugins/filters/__init__.py +3 -4
  24. homesec/plugins/filters/yolo.py +27 -29
  25. homesec/plugins/notifiers/__init__.py +2 -1
  26. homesec/plugins/notifiers/mqtt.py +16 -17
  27. homesec/plugins/notifiers/multiplex.py +3 -2
  28. homesec/plugins/notifiers/sendgrid_email.py +6 -8
  29. homesec/plugins/storage/__init__.py +3 -4
  30. homesec/plugins/storage/dropbox.py +20 -17
  31. homesec/plugins/storage/local.py +3 -1
  32. homesec/plugins/utils.py +2 -1
  33. homesec/repository/clip_repository.py +5 -4
  34. homesec/sources/base.py +2 -2
  35. homesec/sources/local_folder.py +9 -7
  36. homesec/sources/rtsp.py +22 -10
  37. homesec/state/postgres.py +34 -35
  38. homesec/telemetry/db_log_handler.py +3 -2
  39. {homesec-0.1.1.dist-info → homesec-1.0.1.dist-info}/METADATA +39 -31
  40. homesec-1.0.1.dist-info/RECORD +62 -0
  41. homesec-0.1.1.dist-info/RECORD +0 -62
  42. {homesec-0.1.1.dist-info → homesec-1.0.1.dist-info}/WHEEL +0 -0
  43. {homesec-0.1.1.dist-info → homesec-1.0.1.dist-info}/entry_points.txt +0 -0
  44. {homesec-0.1.1.dist-info → homesec-1.0.1.dist-info}/licenses/LICENSE +0 -0
@@ -7,11 +7,12 @@ import logging
7
7
  import os
8
8
  from pathlib import Path, PurePosixPath
9
9
  from typing import BinaryIO
10
+
10
11
  import dropbox # type: ignore
11
12
 
13
+ from homesec.interfaces import StorageBackend
12
14
  from homesec.models.config import DropboxStorageConfig
13
15
  from homesec.models.storage import StorageUploadResult
14
- from homesec.interfaces import StorageBackend
15
16
 
16
17
  logger = logging.getLogger(__name__)
17
18
 
@@ -20,10 +21,10 @@ CHUNK_SIZE = 4 * 1024 * 1024
20
21
 
21
22
  class DropboxStorage(StorageBackend):
22
23
  """Dropbox storage backend.
23
-
24
+
24
25
  Uses dropbox SDK for file operations.
25
26
  Implements idempotent uploads with overwrite mode.
26
-
27
+
27
28
  Supports two auth modes:
28
29
  1. Simple token: Set DROPBOX_TOKEN env var
29
30
  2. Refresh token flow: Set DROPBOX_APP_KEY, DROPBOX_APP_SECRET, DROPBOX_REFRESH_TOKEN
@@ -31,10 +32,10 @@ class DropboxStorage(StorageBackend):
31
32
 
32
33
  def __init__(self, config: DropboxStorageConfig) -> None:
33
34
  """Initialize Dropbox storage with config validation.
34
-
35
+
35
36
  Required config:
36
37
  root: Root path in Dropbox (e.g., /homecam)
37
-
38
+
38
39
  Optional config:
39
40
  token_env: Env var name for simple token auth (default: DROPBOX_TOKEN)
40
41
  app_key_env: Env var name for app key (default: DROPBOX_APP_KEY)
@@ -44,16 +45,16 @@ class DropboxStorage(StorageBackend):
44
45
  """
45
46
  self.root = str(config.root).rstrip("/")
46
47
  self.web_url_prefix = str(config.web_url_prefix)
47
-
48
+
48
49
  # Initialize Dropbox client using env vars
49
50
  self.client = self._create_client(config)
50
51
  self._shutdown_called = False
51
-
52
+
52
53
  logger.info("DropboxStorage initialized: root=%s", self.root)
53
54
 
54
55
  def _create_client(self, config: DropboxStorageConfig) -> dropbox.Dropbox:
55
56
  """Create Dropbox client from env vars.
56
-
57
+
57
58
  Tries simple token first, then falls back to refresh token flow.
58
59
  """
59
60
  # Try simple token auth first
@@ -62,16 +63,16 @@ class DropboxStorage(StorageBackend):
62
63
  if token:
63
64
  logger.info("Using Dropbox simple token auth")
64
65
  return dropbox.Dropbox(token)
65
-
66
+
66
67
  # Try refresh token flow
67
68
  app_key_var = str(config.app_key_env)
68
69
  app_secret_var = str(config.app_secret_env)
69
70
  refresh_token_var = str(config.refresh_token_env)
70
-
71
+
71
72
  app_key = os.getenv(app_key_var)
72
73
  app_secret = os.getenv(app_secret_var)
73
74
  refresh_token = os.getenv(refresh_token_var)
74
-
75
+
75
76
  if app_key and app_secret and refresh_token:
76
77
  logger.info("Using Dropbox refresh token auth")
77
78
  return dropbox.Dropbox(
@@ -79,7 +80,7 @@ class DropboxStorage(StorageBackend):
79
80
  app_secret=app_secret,
80
81
  oauth2_refresh_token=refresh_token,
81
82
  )
82
-
83
+
83
84
  raise ValueError(
84
85
  f"Missing Dropbox credentials. Set {token_var} or "
85
86
  f"({app_key_var}, {app_secret_var}, {refresh_token_var})."
@@ -150,13 +151,13 @@ class DropboxStorage(StorageBackend):
150
151
  async def get(self, storage_uri: str, local_path: Path) -> None:
151
152
  """Download file from Dropbox."""
152
153
  self._ensure_open()
153
-
154
+
154
155
  # Parse storage_uri
155
156
  if not storage_uri.startswith("dropbox:"):
156
157
  raise ValueError(f"Invalid storage_uri: {storage_uri}")
157
-
158
+
158
159
  remote_path = storage_uri[8:] # Strip "dropbox:"
159
-
160
+
160
161
  # Run blocking download in executor
161
162
  await asyncio.to_thread(self._download_file, remote_path, local_path)
162
163
 
@@ -227,7 +228,7 @@ class DropboxStorage(StorageBackend):
227
228
  _ = timeout
228
229
  if self._shutdown_called:
229
230
  return
230
-
231
+
231
232
  self._shutdown_called = True
232
233
  logger.info("DropboxStorage closed")
233
234
 
@@ -247,9 +248,11 @@ class DropboxStorage(StorageBackend):
247
248
 
248
249
  # Plugin registration
249
250
  from typing import cast
251
+
250
252
  from pydantic import BaseModel
251
- from homesec.plugins.storage import StoragePlugin, storage_plugin
253
+
252
254
  from homesec.interfaces import StorageBackend
255
+ from homesec.plugins.storage import StoragePlugin, storage_plugin
253
256
 
254
257
 
255
258
  @storage_plugin(name="dropbox")
@@ -83,9 +83,11 @@ class LocalStorage(StorageBackend):
83
83
 
84
84
  # Plugin registration
85
85
  from typing import cast
86
+
86
87
  from pydantic import BaseModel
87
- from homesec.plugins.storage import StoragePlugin, storage_plugin
88
+
88
89
  from homesec.interfaces import StorageBackend
90
+ from homesec.plugins.storage import StoragePlugin, storage_plugin
89
91
 
90
92
 
91
93
  @storage_plugin(name="local")
homesec/plugins/utils.py CHANGED
@@ -2,8 +2,9 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from collections.abc import Iterable
5
6
  from importlib import metadata
6
- from typing import Iterable, TypeVar, cast
7
+ from typing import TypeVar, cast
7
8
 
8
9
  PluginT = TypeVar("PluginT")
9
10
 
@@ -4,16 +4,17 @@ from __future__ import annotations
4
4
 
5
5
  import asyncio
6
6
  import logging
7
+ from collections.abc import Awaitable, Callable
7
8
  from datetime import datetime
8
- from typing import TYPE_CHECKING, Awaitable, Callable, TypeVar
9
+ from typing import TYPE_CHECKING, TypeVar
9
10
 
10
11
  from homesec.models.clip import Clip, ClipStateData
11
12
  from homesec.models.config import RetryConfig
12
13
  from homesec.models.events import (
13
14
  AlertDecisionMadeEvent,
14
15
  ClipDeletedEvent,
15
- ClipRecheckedEvent,
16
16
  ClipLifecycleEvent,
17
+ ClipRecheckedEvent,
17
18
  ClipRecordedEvent,
18
19
  FilterCompletedEvent,
19
20
  FilterFailedEvent,
@@ -25,16 +26,16 @@ from homesec.models.events import (
25
26
  UploadStartedEvent,
26
27
  VLMCompletedEvent,
27
28
  VLMFailedEvent,
28
- VLMStartedEvent,
29
29
  VLMSkippedEvent,
30
+ VLMStartedEvent,
30
31
  )
31
32
  from homesec.state.postgres import is_retryable_pg_error
32
33
 
33
34
  if TYPE_CHECKING:
35
+ from homesec.interfaces import EventStore, StateStore
34
36
  from homesec.models.alert import AlertDecision
35
37
  from homesec.models.filter import FilterResult
36
38
  from homesec.models.vlm import AnalysisResult
37
- from homesec.interfaces import EventStore, StateStore
38
39
 
39
40
  logger = logging.getLogger(__name__)
40
41
 
homesec/sources/base.py CHANGED
@@ -6,11 +6,11 @@ import asyncio
6
6
  import logging
7
7
  import time
8
8
  from abc import ABC, abstractmethod
9
+ from collections.abc import Callable
9
10
  from threading import Event, Thread
10
- from typing import Callable
11
11
 
12
- from homesec.models.clip import Clip
13
12
  from homesec.interfaces import ClipSource
13
+ from homesec.models.clip import Clip
14
14
 
15
15
  logger = logging.getLogger(__name__)
16
16
 
@@ -6,9 +6,10 @@ import asyncio
6
6
  import logging
7
7
  import time
8
8
  from collections import OrderedDict
9
+ from collections.abc import Callable
9
10
  from datetime import datetime, timedelta
10
11
  from pathlib import Path
11
- from typing import Callable, TYPE_CHECKING
12
+ from typing import TYPE_CHECKING
12
13
 
13
14
  from anyio import Path as AsyncPath
14
15
 
@@ -34,7 +35,7 @@ class LocalFolderSource(AsyncClipSource):
34
35
  self,
35
36
  config: LocalFolderSourceConfig,
36
37
  camera_name: str = "local",
37
- state_store: "StateStore | None" = None,
38
+ state_store: StateStore | None = None,
38
39
  ) -> None:
39
40
  """Initialize folder watcher.
40
41
 
@@ -61,8 +62,11 @@ class LocalFolderSource(AsyncClipSource):
61
62
  self._seen_files: OrderedDict[str, None] = OrderedDict()
62
63
  self._max_seen_files = 10000
63
64
 
64
- logger.info("LocalFolderSource initialized: watch_dir=%s, has_state_store=%s",
65
- self.watch_dir, state_store is not None)
65
+ logger.info(
66
+ "LocalFolderSource initialized: watch_dir=%s, has_state_store=%s",
67
+ self.watch_dir,
68
+ state_store is not None,
69
+ )
66
70
 
67
71
  def register_callback(self, callback: Callable[[Clip], None]) -> None:
68
72
  """Register callback to be invoked when new clip is ready."""
@@ -163,9 +167,7 @@ class LocalFolderSource(AsyncClipSource):
163
167
 
164
168
  # Sleep before next poll
165
169
  try:
166
- await asyncio.wait_for(
167
- self._stop_event.wait(), timeout=self.poll_interval
168
- )
170
+ await asyncio.wait_for(self._stop_event.wait(), timeout=self.poll_interval)
169
171
  except asyncio.TimeoutError:
170
172
  pass # Normal - just means poll_interval elapsed
171
173
 
homesec/sources/rtsp.py CHANGED
@@ -23,6 +23,7 @@ from typing import Any, cast
23
23
  import cv2
24
24
  import numpy as np
25
25
  import numpy.typing as npt
26
+
26
27
  from homesec.models.clip import Clip
27
28
  from homesec.models.source import RTSPSourceConfig
28
29
  from homesec.sources.base import ThreadedClipSource
@@ -386,9 +387,7 @@ class RTSPSource(ThreadedClipSource):
386
387
  try:
387
388
  return shlex.join([str(x) for x in cmd])
388
389
  except Exception as exc:
389
- logger.warning(
390
- "Failed to format command with shlex.join: %s", exc, exc_info=True
391
- )
390
+ logger.warning("Failed to format command with shlex.join: %s", exc, exc_info=True)
392
391
  return " ".join([str(x) for x in cmd])
393
392
 
394
393
  def detect_motion(self, frame: npt.NDArray[np.uint8]) -> bool:
@@ -469,7 +468,7 @@ class RTSPSource(ThreadedClipSource):
469
468
 
470
469
  if log_file:
471
470
  try:
472
- with open(log_file, "r") as f:
471
+ with open(log_file) as f:
473
472
  error_lines = f.read()
474
473
  if error_lines:
475
474
  logger.warning("Recording error log:\n%s", error_lines[-1000:])
@@ -572,7 +571,7 @@ class RTSPSource(ThreadedClipSource):
572
571
  logger.error("Recording process died immediately (exit code: %s)", proc.returncode)
573
572
  logger.error("Check logs at: %s", stderr_log)
574
573
  try:
575
- with open(stderr_log, "r") as f:
574
+ with open(stderr_log) as f:
576
575
  error_lines = f.read()
577
576
  if error_lines:
578
577
  logger.error("Error output: %s", error_lines[:500])
@@ -832,7 +831,9 @@ class RTSPSource(ThreadedClipSource):
832
831
  except Exception as exc:
833
832
  logger.warning("Failed to close stderr log: %s", exc, exc_info=True)
834
833
  stderr_tail = _read_tail(stderr_log)
835
- logger.error("Frame pipeline died immediately (%s, exit code: %s)", label, process.returncode)
834
+ logger.error(
835
+ "Frame pipeline died immediately (%s, exit code: %s)", label, process.returncode
836
+ )
836
837
  if stderr_tail:
837
838
  logger.error("Frame pipeline stderr tail (%s):\n%s", label, stderr_tail)
838
839
  process = None
@@ -841,7 +842,9 @@ class RTSPSource(ThreadedClipSource):
841
842
 
842
843
  raise RuntimeError("Frame pipeline failed to start")
843
844
 
844
- def _stop_process(self, proc: subprocess.Popen[bytes], name: str, terminate_timeout_s: float) -> None:
845
+ def _stop_process(
846
+ self, proc: subprocess.Popen[bytes], name: str, terminate_timeout_s: float
847
+ ) -> None:
845
848
  if proc.poll() is not None:
846
849
  return
847
850
  try:
@@ -890,7 +893,9 @@ class RTSPSource(ThreadedClipSource):
890
893
  def _start_frame_pipeline(self) -> None:
891
894
  self._stop_frame_pipeline()
892
895
 
893
- self.frame_pipe, self._frame_pipe_stderr, self._frame_width, self._frame_height = self.get_frame_pipe()
896
+ self.frame_pipe, self._frame_pipe_stderr, self._frame_width, self._frame_height = (
897
+ self.get_frame_pipe()
898
+ )
894
899
  self._frame_size = int(self._frame_width) * int(self._frame_height)
895
900
  self._frame_queue = Queue(maxsize=self.frame_queue_size)
896
901
  self._frame_reader_stop = Event()
@@ -1168,13 +1173,20 @@ class RTSPSource(ThreadedClipSource):
1168
1173
  self.recording_process is not None,
1169
1174
  )
1170
1175
  if self.frame_pipe and self.frame_pipe.poll() is not None:
1171
- logger.error("Frame pipeline died! Exit code: %s", self.frame_pipe.returncode)
1176
+ logger.error(
1177
+ "Frame pipeline died! Exit code: %s", self.frame_pipe.returncode
1178
+ )
1172
1179
  break
1173
1180
  last_heartbeat = time.monotonic()
1174
1181
 
1175
1182
  frame_count += 1
1176
1183
 
1177
- if not self._frame_queue or not self._frame_width or not self._frame_height or not self._frame_size:
1184
+ if (
1185
+ not self._frame_queue
1186
+ or not self._frame_width
1187
+ or not self._frame_height
1188
+ or not self._frame_size
1189
+ ):
1178
1190
  break
1179
1191
 
1180
1192
  try:
homesec/state/postgres.py CHANGED
@@ -19,33 +19,36 @@ from sqlalchemy import (
19
19
  or_,
20
20
  select,
21
21
  )
22
- from sqlalchemy.dialects.postgresql import JSONB, insert as pg_insert
22
+ from sqlalchemy.dialects.postgresql import JSONB
23
+ from sqlalchemy.dialects.postgresql import insert as pg_insert
23
24
  from sqlalchemy.exc import DBAPIError, OperationalError
24
- from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, create_async_engine
25
+ from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
25
26
  from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
26
27
 
28
+ from homesec.interfaces import EventStore, StateStore
27
29
  from homesec.models.clip import ClipStateData
28
30
  from homesec.models.events import (
31
+ AlertDecisionMadeEvent,
29
32
  ClipDeletedEvent,
30
- ClipRecheckedEvent,
31
- ClipEvent as ClipEventModel,
32
33
  ClipLifecycleEvent,
34
+ ClipRecheckedEvent,
33
35
  ClipRecordedEvent,
34
- UploadStartedEvent,
35
- UploadCompletedEvent,
36
- UploadFailedEvent,
37
- FilterStartedEvent,
38
36
  FilterCompletedEvent,
39
37
  FilterFailedEvent,
40
- VLMStartedEvent,
38
+ FilterStartedEvent,
39
+ NotificationFailedEvent,
40
+ NotificationSentEvent,
41
+ UploadCompletedEvent,
42
+ UploadFailedEvent,
43
+ UploadStartedEvent,
41
44
  VLMCompletedEvent,
42
45
  VLMFailedEvent,
43
46
  VLMSkippedEvent,
44
- AlertDecisionMadeEvent,
45
- NotificationSentEvent,
46
- NotificationFailedEvent,
47
+ VLMStartedEvent,
48
+ )
49
+ from homesec.models.events import (
50
+ ClipEvent as ClipEventModel,
47
51
  )
48
- from homesec.interfaces import EventStore, StateStore
49
52
 
50
53
  logger = logging.getLogger(__name__)
51
54
 
@@ -75,6 +78,7 @@ class Base(DeclarativeBase):
75
78
 
76
79
  class ClipState(Base):
77
80
  """Current state snapshot (lightweight, fast queries)."""
81
+
78
82
  __tablename__ = "clip_states"
79
83
 
80
84
  clip_id: Mapped[str] = mapped_column(Text, primary_key=True)
@@ -99,6 +103,7 @@ class ClipState(Base):
99
103
 
100
104
  class ClipEvent(Base):
101
105
  """Event history (append-only audit log)."""
106
+
102
107
  __tablename__ = "clip_events"
103
108
 
104
109
  id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
@@ -122,7 +127,6 @@ class ClipEvent(Base):
122
127
  )
123
128
 
124
129
 
125
-
126
130
  def _normalize_async_dsn(dsn: str) -> str:
127
131
  if "+asyncpg" in dsn:
128
132
  return dsn
@@ -135,14 +139,14 @@ def _normalize_async_dsn(dsn: str) -> str:
135
139
 
136
140
  class PostgresStateStore(StateStore):
137
141
  """Postgres implementation of StateStore interface.
138
-
142
+
139
143
  Implements graceful degradation: operations return None/False
140
144
  instead of raising when DB is unavailable.
141
145
  """
142
146
 
143
147
  def __init__(self, dsn: str) -> None:
144
148
  """Initialize state store.
145
-
149
+
146
150
  Args:
147
151
  dsn: Postgres connection string (e.g., "postgresql+asyncpg://user:pass@host/db")
148
152
  """
@@ -150,8 +154,10 @@ class PostgresStateStore(StateStore):
150
154
  self._engine: AsyncEngine | None = None
151
155
 
152
156
  async def initialize(self) -> bool:
153
- """Initialize connection pool and create table if not exists.
154
-
157
+ """Initialize connection pool.
158
+
159
+ Note: Tables are created via alembic migrations, not here.
160
+
155
161
  Returns:
156
162
  True if initialization succeeded, False otherwise
157
163
  """
@@ -162,26 +168,21 @@ class PostgresStateStore(StateStore):
162
168
  pool_size=5,
163
169
  max_overflow=0,
164
170
  )
165
- async with self._engine.begin() as conn:
166
- await self._create_tables(conn)
171
+ # Verify connection works
172
+ async with self._engine.connect() as conn:
173
+ await conn.execute(select(1))
167
174
  logger.info("PostgresStateStore initialized successfully")
168
175
  return True
169
176
  except Exception as e:
170
- logger.error(
171
- "Failed to initialize PostgresStateStore: %s", e, exc_info=True
172
- )
177
+ logger.error("Failed to initialize PostgresStateStore: %s", e, exc_info=True)
173
178
  if self._engine is not None:
174
179
  await self._engine.dispose()
175
180
  self._engine = None
176
181
  return False
177
182
 
178
- async def _create_tables(self, conn: AsyncConnection) -> None:
179
- """Create all tables (clip_states + clip_events)."""
180
- await conn.run_sync(Base.metadata.create_all)
181
-
182
183
  async def upsert(self, clip_id: str, data: ClipStateData) -> None:
183
184
  """Insert or update clip state.
184
-
185
+
185
186
  Raises on execution errors so callers can retry/log appropriately.
186
187
  """
187
188
  if self._engine is None:
@@ -264,8 +265,7 @@ class PostgresStateStore(StateStore):
264
265
  conditions.append(camera_expr == camera_name)
265
266
  if older_than_days is not None:
266
267
  conditions.append(
267
- ClipState.created_at
268
- < func.now() - func.make_interval(days=int(older_than_days))
268
+ ClipState.created_at < func.now() - func.make_interval(days=int(older_than_days))
269
269
  )
270
270
 
271
271
  if cursor is not None:
@@ -311,7 +311,7 @@ class PostgresStateStore(StateStore):
311
311
 
312
312
  async def ping(self) -> bool:
313
313
  """Health check.
314
-
314
+
315
315
  Returns True if database is reachable, False otherwise.
316
316
  """
317
317
  if self._engine is None:
@@ -338,7 +338,7 @@ class PostgresStateStore(StateStore):
338
338
  """Parse JSONB payload from SQLAlchemy into a dict."""
339
339
  return _parse_jsonb_payload(raw)
340
340
 
341
- def create_event_store(self) -> "PostgresEventStore | NoopEventStore":
341
+ def create_event_store(self) -> PostgresEventStore | NoopEventStore:
342
342
  """Create a Postgres-backed event store or a no-op fallback."""
343
343
  if self._engine is None:
344
344
  return NoopEventStore()
@@ -427,9 +427,8 @@ class PostgresEventStore(EventStore):
427
427
  ) -> list[ClipLifecycleEvent]:
428
428
  """Get all events for a clip, optionally after an event id."""
429
429
  try:
430
- query = (
431
- select(ClipEvent.id, ClipEvent.event_type, ClipEvent.event_data)
432
- .where(ClipEvent.clip_id == clip_id)
430
+ query = select(ClipEvent.id, ClipEvent.event_type, ClipEvent.event_data).where(
431
+ ClipEvent.clip_id == clip_id
433
432
  )
434
433
  if after_id is not None:
435
434
  query = query.where(ClipEvent.id > after_id)
@@ -19,7 +19,6 @@ from homesec.telemetry.db.log_table import logs
19
19
  from homesec.telemetry.db.log_table import metadata as db_metadata
20
20
  from homesec.telemetry.postgres_settings import PostgresConfig
21
21
 
22
-
23
22
  _STANDARD_LOGRECORD_ATTRS = {
24
23
  "name",
25
24
  "msg",
@@ -168,7 +167,9 @@ class AsyncPostgresJsonLogHandler(logging.Handler):
168
167
  pass
169
168
  self._drop_count += 1
170
169
  if self._drop_count % 100 == 1:
171
- sys.stderr.write(f"[db-log] queue full; dropping logs (dropped={self._drop_count})\n")
170
+ sys.stderr.write(
171
+ f"[db-log] queue full; dropping logs (dropped={self._drop_count})\n"
172
+ )
172
173
 
173
174
  def _drain_batch(self) -> list[_DbRow]:
174
175
  batch: list[_DbRow] = []
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: homesec
3
- Version: 0.1.1
3
+ Version: 1.0.1
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
@@ -247,11 +247,9 @@ Description-Content-Type: text/markdown
247
247
  [![Python: 3.10+](https://img.shields.io/badge/python-3.10%2B-blue)](https://www.python.org/)
248
248
  [![Typing: Typed](https://img.shields.io/badge/typing-typed-2b825b)](https://peps.python.org/pep-0561/)
249
249
 
250
- HomeSec is a pluggable, async pipeline for home security cameras. It records
251
- short clips, runs object detection, optionally calls a vision-language model
252
- (VLM) for a structured summary, and sends alerts via MQTT or email. The design
253
- leans toward reliability: clips land on disk first, state/event writes are
254
- best-effort, and non-critical stages can fail without losing the alert.
250
+ HomeSec is a self-hosted, extensible network video recorder that puts you in control. Store clips wherever you want, analyze them with AI, and get smart notifications—all while keeping your footage private and off third-party clouds.
251
+
252
+ Under the hood, it's a pluggable async pipeline for home security cameras. It records short clips, runs object detection, optionally calls a vision-language model (VLM) for a structured summary, and sends alerts via MQTT or email. The design leans toward reliability: clips land on disk first, state/event writes are best-effort, and non-critical stages can fail without losing the alert.
255
253
 
256
254
  ## Highlights
257
255
 
@@ -278,32 +276,42 @@ ClipSource -> (Upload + Filter) -> VLM (optional) -> Alert Policy -> Notifier(s)
278
276
 
279
277
  ### Requirements
280
278
 
281
- - Python 3.10+ (newest available is best; 3.14 is fine if your deps support it)
282
- - ffmpeg in PATH (required for RTSP source)
283
- - Postgres for state/events (`make db-up` starts a local instance). The pipeline
284
- continues if the DB is down, but a DSN is still required.
279
+ - Docker and Docker Compose
285
280
  - Optional: MQTT broker, Dropbox credentials, OpenAI-compatible API key
286
281
 
287
282
  ### Setup
288
283
 
289
- 1. Install dependencies:
290
- `uv sync`
291
- 2. Create a config file:
292
- - Start from `config/example.yaml` or `config/sample.yaml`
293
- 3. Set environment variables (use `.env.example` as a template):
294
- `cp .env.example .env`
295
- 4. Start Postgres:
296
- `make db-up`
297
- 5. Validate config:
298
- `uv run python -m homesec.cli validate --config config/example.yaml`
299
- 6. Run:
300
- `uv run python -m homesec.cli run --config config/example.yaml --log_level INFO`
301
-
302
- Tip: `make homesec` loads `.env` and runs `config/production.yaml`.
284
+ 1. Create a config file:
285
+ ```bash
286
+ cp config/example.yaml config/config.yaml
287
+ # Edit config/config.yaml with your settings
288
+ ```
289
+ 2. Set environment variables:
290
+ ```bash
291
+ cp .env.example .env
292
+ # Edit .env with your credentials
293
+ ```
294
+ 3. Start HomeSec + Postgres:
295
+ ```bash
296
+ make up
297
+ ```
298
+ 4. Stop:
299
+ ```bash
300
+ make down
301
+ ```
302
+
303
+ ### Running without Docker
304
+
305
+ If you prefer to run locally:
306
+
307
+ 1. Install Python 3.10+ and ffmpeg
308
+ 2. `uv sync`
309
+ 3. `make db` (starts Postgres)
310
+ 4. `make run`
303
311
 
304
312
  ## Configuration
305
313
 
306
- Configs are YAML and validated with Pydantic. Start with any file in `config/`.
314
+ Configs are YAML and validated with Pydantic. See `config/example.yaml` for all options.
307
315
 
308
316
  Minimal example (RTSP + Dropbox + MQTT):
309
317
 
@@ -363,8 +371,8 @@ per_camera_alert:
363
371
  A few things worth knowing:
364
372
  - Secrets never go in YAML. Use env var names (`*_env`) and set values in your shell or `.env`.
365
373
  - At least one notifier must be enabled (`mqtt` or `sendgrid_email`).
366
- - Built-in YOLO classes: `person`, `bird`, `cat`, `dog`, `horse`, `sheep`, `cow`,
367
- `elephant`, `bear`, `zebra`, `giraffe`.
374
+ - Built-in YOLO classes: `person`, `car`, `truck`, `motorcycle`, `bicycle`,
375
+ `dog`, `cat`, `bird`, `backpack`, `handbag`, `suitcase`.
368
376
  - Local storage for development:
369
377
 
370
378
  ```yaml
@@ -402,11 +410,11 @@ Extension points (all pluggable):
402
410
  ## CLI
403
411
 
404
412
  - Run the pipeline:
405
- `uv run python -m homesec.cli run --config config/example.yaml --log_level INFO`
413
+ `uv run python -m homesec.cli run --config config/config.yaml --log_level INFO`
406
414
  - Validate config:
407
- `uv run python -m homesec.cli validate --config config/example.yaml`
415
+ `uv run python -m homesec.cli validate --config config/config.yaml`
408
416
  - Cleanup (reanalyze and optionally delete empty clips):
409
- `uv run python -m homesec.cli cleanup --config config/example.yaml --older_than_days 7 --dry_run True`
417
+ `uv run python -m homesec.cli cleanup --config config/config.yaml --older_than_days 7 --dry_run True`
410
418
 
411
419
  ## Built-in plugins
412
420
 
@@ -457,7 +465,7 @@ my_filters = "my_package.filters.custom"
457
465
 
458
466
  - Health endpoint: `GET /health` (configurable in `health.host`/`health.port`)
459
467
  - Optional telemetry logs to Postgres when `DB_DSN` is set:
460
- - Start local DB: `make db-up`
468
+ - Start local DB: `make db`
461
469
  - Run migrations: `make db-migrate`
462
470
 
463
471
  ## Development