homesec 0.1.1__py3-none-any.whl → 1.0.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.
- homesec/app.py +34 -36
- homesec/cli.py +14 -11
- homesec/config/loader.py +11 -11
- homesec/config/validation.py +2 -5
- homesec/errors.py +2 -4
- homesec/health/server.py +29 -27
- homesec/interfaces.py +11 -6
- homesec/logging_setup.py +9 -5
- homesec/maintenance/cleanup_clips.py +2 -3
- homesec/models/__init__.py +1 -1
- homesec/models/alert.py +2 -0
- homesec/models/clip.py +8 -1
- homesec/models/config.py +9 -13
- homesec/models/events.py +14 -0
- homesec/models/filter.py +1 -3
- homesec/models/vlm.py +1 -2
- homesec/pipeline/core.py +15 -32
- homesec/plugins/alert_policies/__init__.py +3 -4
- homesec/plugins/alert_policies/default.py +3 -2
- homesec/plugins/alert_policies/noop.py +1 -2
- homesec/plugins/analyzers/__init__.py +3 -4
- homesec/plugins/analyzers/openai.py +34 -43
- homesec/plugins/filters/__init__.py +3 -4
- homesec/plugins/filters/yolo.py +27 -29
- homesec/plugins/notifiers/__init__.py +2 -1
- homesec/plugins/notifiers/mqtt.py +16 -17
- homesec/plugins/notifiers/multiplex.py +3 -2
- homesec/plugins/notifiers/sendgrid_email.py +6 -8
- homesec/plugins/storage/__init__.py +3 -4
- homesec/plugins/storage/dropbox.py +20 -17
- homesec/plugins/storage/local.py +3 -1
- homesec/plugins/utils.py +2 -1
- homesec/repository/clip_repository.py +5 -4
- homesec/sources/base.py +2 -2
- homesec/sources/local_folder.py +9 -7
- homesec/sources/rtsp.py +22 -10
- homesec/state/postgres.py +34 -35
- homesec/telemetry/db_log_handler.py +3 -2
- {homesec-0.1.1.dist-info → homesec-1.0.0.dist-info}/METADATA +39 -31
- homesec-1.0.0.dist-info/RECORD +62 -0
- homesec-0.1.1.dist-info/RECORD +0 -62
- {homesec-0.1.1.dist-info → homesec-1.0.0.dist-info}/WHEEL +0 -0
- {homesec-0.1.1.dist-info → homesec-1.0.0.dist-info}/entry_points.txt +0 -0
- {homesec-0.1.1.dist-info → homesec-1.0.0.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
|
-
|
|
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")
|
homesec/plugins/storage/local.py
CHANGED
|
@@ -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
|
-
|
|
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
|
@@ -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,
|
|
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
|
|
homesec/sources/local_folder.py
CHANGED
|
@@ -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
|
|
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:
|
|
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(
|
|
65
|
-
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
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 =
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
38
|
+
FilterStartedEvent,
|
|
39
|
+
NotificationFailedEvent,
|
|
40
|
+
NotificationSentEvent,
|
|
41
|
+
UploadCompletedEvent,
|
|
42
|
+
UploadFailedEvent,
|
|
43
|
+
UploadStartedEvent,
|
|
41
44
|
VLMCompletedEvent,
|
|
42
45
|
VLMFailedEvent,
|
|
43
46
|
VLMSkippedEvent,
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
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
|
-
|
|
166
|
-
|
|
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) ->
|
|
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
|
-
|
|
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(
|
|
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.
|
|
3
|
+
Version: 1.0.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
|
|
@@ -247,11 +247,9 @@ Description-Content-Type: text/markdown
|
|
|
247
247
|
[](https://www.python.org/)
|
|
248
248
|
[](https://peps.python.org/pep-0561/)
|
|
249
249
|
|
|
250
|
-
HomeSec is a
|
|
251
|
-
|
|
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
|
-
-
|
|
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.
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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.
|
|
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`, `
|
|
367
|
-
`
|
|
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/
|
|
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/
|
|
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/
|
|
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
|
|
468
|
+
- Start local DB: `make db`
|
|
461
469
|
- Run migrations: `make db-migrate`
|
|
462
470
|
|
|
463
471
|
## Development
|