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,79 @@
1
+ """HomeSec data models."""
2
+
3
+ from homesec.models.alert import Alert, AlertDecision
4
+ from homesec.models.clip import Clip, ClipStateData, _resolve_forward_refs
5
+ from homesec.models.config import (
6
+ AlertPolicyConfig,
7
+ AlertPolicyOverrides,
8
+ DefaultAlertPolicySettings,
9
+ CameraConfig,
10
+ CameraSourceConfig,
11
+ ConcurrencyConfig,
12
+ Config,
13
+ DropboxStorageConfig,
14
+ HealthConfig,
15
+ LocalStorageConfig,
16
+ MQTTAuthConfig,
17
+ MQTTConfig,
18
+ NotifierConfig,
19
+ RetentionConfig,
20
+ RetryConfig,
21
+ SendGridEmailConfig,
22
+ StateStoreConfig,
23
+ StorageConfig,
24
+ StoragePathsConfig,
25
+ )
26
+ from homesec.models.filter import FilterConfig, FilterOverrides, FilterResult, YoloFilterSettings
27
+ from homesec.models.source import FtpSourceConfig, LocalFolderSourceConfig, RTSPSourceConfig
28
+ from homesec.models.vlm import (
29
+ AnalysisResult,
30
+ EntityTimeline,
31
+ OpenAILLMConfig,
32
+ RiskLevel,
33
+ SequenceAnalysis,
34
+ VLMConfig,
35
+ VLMPreprocessConfig,
36
+ )
37
+
38
+ # Resolve forward references in ClipStateData
39
+ _resolve_forward_refs()
40
+
41
+ __all__ = [
42
+ "Alert",
43
+ "AlertDecision",
44
+ "AlertPolicyConfig",
45
+ "AlertPolicyOverrides",
46
+ "DefaultAlertPolicySettings",
47
+ "AnalysisResult",
48
+ "CameraConfig",
49
+ "CameraSourceConfig",
50
+ "Clip",
51
+ "ClipStateData",
52
+ "ConcurrencyConfig",
53
+ "Config",
54
+ "DropboxStorageConfig",
55
+ "EntityTimeline",
56
+ "FilterConfig",
57
+ "FilterOverrides",
58
+ "FtpSourceConfig",
59
+ "FilterResult",
60
+ "HealthConfig",
61
+ "LocalStorageConfig",
62
+ "LocalFolderSourceConfig",
63
+ "MQTTAuthConfig",
64
+ "MQTTConfig",
65
+ "NotifierConfig",
66
+ "OpenAILLMConfig",
67
+ "RTSPSourceConfig",
68
+ "SendGridEmailConfig",
69
+ "RetentionConfig",
70
+ "RetryConfig",
71
+ "RiskLevel",
72
+ "SequenceAnalysis",
73
+ "StateStoreConfig",
74
+ "StorageConfig",
75
+ "StoragePathsConfig",
76
+ "VLMConfig",
77
+ "VLMPreprocessConfig",
78
+ "YoloFilterSettings",
79
+ ]
@@ -0,0 +1,32 @@
1
+ """Alert decision and notification payload models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from pydantic import BaseModel
7
+
8
+ from homesec.models.vlm import RiskLevel, SequenceAnalysis
9
+
10
+ class AlertDecision(BaseModel):
11
+ """Decision whether to send an alert for a clip."""
12
+
13
+ notify: bool
14
+ notify_reason: str # e.g., "risk_level=high" or "activity_type=delivery (per-camera)"
15
+
16
+
17
+ class Alert(BaseModel):
18
+ """MQTT notification payload."""
19
+
20
+ clip_id: str
21
+ camera_name: str
22
+ storage_uri: str | None
23
+ view_url: str | None
24
+ risk_level: RiskLevel | None # None if VLM skipped
25
+ activity_type: str | None
26
+ notify_reason: str
27
+ summary: str | None
28
+ analysis: SequenceAnalysis | None = None
29
+ ts: datetime
30
+ dedupe_key: str # Same as clip_id for MVP
31
+ upload_failed: bool # True if storage_uri is None due to upload failure
32
+ vlm_failed: bool = False # True if VLM analysis failed but alert sent anyway
homesec/models/clip.py ADDED
@@ -0,0 +1,71 @@
1
+ """Core clip-centric data models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING, Literal
8
+
9
+ from pydantic import BaseModel
10
+
11
+ if TYPE_CHECKING:
12
+ from homesec.models.alert import AlertDecision
13
+ from homesec.models.filter import FilterResult
14
+ from homesec.models.vlm import AnalysisResult
15
+
16
+
17
+ class Clip(BaseModel):
18
+ """Represents a finalized video clip ready for processing."""
19
+
20
+ clip_id: str
21
+ camera_name: str
22
+ local_path: Path
23
+ start_ts: datetime
24
+ end_ts: datetime
25
+ duration_s: float
26
+ source_type: str # "rtsp", "ftp", etc.
27
+
28
+
29
+ class ClipStateData(BaseModel):
30
+ """Lightweight snapshot of current clip state (stored in clip_states.data JSONB)."""
31
+
32
+ schema_version: int = 1
33
+ camera_name: str
34
+
35
+ # High-level status for queries
36
+ status: Literal["queued_local", "uploaded", "analyzed", "done", "error", "deleted"]
37
+
38
+ # Pointers
39
+ local_path: str
40
+ storage_uri: str | None = None
41
+ view_url: str | None = None
42
+
43
+ # Latest results (denormalized for fast access)
44
+ filter_result: FilterResult | None = None
45
+ analysis_result: AnalysisResult | None = None
46
+ alert_decision: AlertDecision | None = None
47
+
48
+ @property
49
+ def upload_completed(self) -> bool:
50
+ """Check if upload stage completed."""
51
+ return self.storage_uri is not None
52
+
53
+ @property
54
+ def filter_completed(self) -> bool:
55
+ """Check if filter stage completed."""
56
+ return self.filter_result is not None
57
+
58
+ @property
59
+ def vlm_completed(self) -> bool:
60
+ """Check if VLM stage completed."""
61
+ return self.analysis_result is not None
62
+
63
+
64
+ # Resolve forward references after imports are available
65
+ def _resolve_forward_refs() -> None:
66
+ """Resolve forward references in ClipStateData."""
67
+ from homesec.models.alert import AlertDecision
68
+ from homesec.models.filter import FilterResult
69
+ from homesec.models.vlm import AnalysisResult
70
+
71
+ ClipStateData.model_rebuild()
@@ -0,0 +1,362 @@
1
+ """Configuration models with per-camera override support."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Literal, TypeVar
6
+
7
+ from pydantic import BaseModel, Field, model_validator
8
+
9
+ from homesec.models.filter import FilterConfig
10
+ from homesec.models.source import FtpSourceConfig, LocalFolderSourceConfig, RTSPSourceConfig
11
+ from homesec.models.vlm import (
12
+ OpenAILLMConfig,
13
+ RiskLevel,
14
+ VLMConfig,
15
+ VLMPreprocessConfig,
16
+ )
17
+
18
+
19
+ class AlertPolicyOverrides(BaseModel):
20
+ """Per-camera alert policy overrides (only non-None fields override base)."""
21
+
22
+ min_risk_level: RiskLevel | None = None
23
+ notify_on_activity_types: list[str] | None = None
24
+ notify_on_motion: bool | None = None
25
+
26
+
27
+ class DefaultAlertPolicySettings(BaseModel):
28
+ """Default alert policy settings."""
29
+
30
+ min_risk_level: RiskLevel = "medium"
31
+ notify_on_activity_types: list[str] = Field(default_factory=list)
32
+ notify_on_motion: bool = False
33
+
34
+
35
+ class AlertPolicyConfig(BaseModel):
36
+ """Alert policy plugin configuration."""
37
+
38
+ backend: str = "default"
39
+ enabled: bool = True
40
+ config: dict[str, object] = Field(default_factory=dict)
41
+
42
+ @model_validator(mode="after")
43
+ def _validate_alert_policy(self) -> "AlertPolicyConfig":
44
+ if self.backend == "default":
45
+ DefaultAlertPolicySettings.model_validate(self.config)
46
+ return self
47
+
48
+
49
+ class DropboxStorageConfig(BaseModel):
50
+ """Dropbox storage configuration."""
51
+
52
+ root: str
53
+ token_env: str = "DROPBOX_TOKEN"
54
+ app_key_env: str = "DROPBOX_APP_KEY"
55
+ app_secret_env: str = "DROPBOX_APP_SECRET"
56
+ refresh_token_env: str = "DROPBOX_REFRESH_TOKEN"
57
+ web_url_prefix: str = "https://www.dropbox.com/home"
58
+
59
+
60
+ class LocalStorageConfig(BaseModel):
61
+ """Local storage configuration."""
62
+
63
+ root: str = "./storage"
64
+
65
+
66
+ class StoragePathsConfig(BaseModel):
67
+ """Logical storage paths for different artifact types."""
68
+
69
+ clips_dir: str = "clips"
70
+ backups_dir: str = "backups"
71
+ artifacts_dir: str = "artifacts"
72
+
73
+
74
+ class StorageConfig(BaseModel):
75
+ """Storage backend configuration.
76
+
77
+ Note: Backend names are validated against the registry at runtime via
78
+ validate_plugin_names(). This allows third-party storage plugins via entry points.
79
+ """
80
+
81
+ model_config = {"extra": "allow"} # Allow third-party backend configs
82
+
83
+ backend: str = "dropbox"
84
+ dropbox: DropboxStorageConfig | None = None
85
+ local: LocalStorageConfig | None = None
86
+ paths: StoragePathsConfig = Field(default_factory=StoragePathsConfig)
87
+
88
+ @model_validator(mode="after")
89
+ def _validate_builtin_backends(self) -> "StorageConfig":
90
+ """Validate that built-in backends have their required config.
91
+
92
+ Third-party backends are validated later in create_storage().
93
+ """
94
+ match self.backend:
95
+ case "dropbox":
96
+ if self.dropbox is None:
97
+ raise ValueError(
98
+ "storage.dropbox is required when backend=dropbox. "
99
+ "Add 'storage.dropbox' section to your config."
100
+ )
101
+ case "local":
102
+ if self.local is None:
103
+ raise ValueError(
104
+ "storage.local is required when backend=local. "
105
+ "Add 'storage.local' section to your config."
106
+ )
107
+ case _:
108
+ # Third-party backend - validation happens in create_storage()
109
+ pass
110
+ return self
111
+
112
+
113
+ class StateStoreConfig(BaseModel):
114
+ """State store configuration."""
115
+
116
+ dsn_env: str | None = None
117
+ dsn: str | None = None
118
+
119
+ @model_validator(mode="after")
120
+ def _validate_backend(self) -> "StateStoreConfig":
121
+ if not (self.dsn_env or self.dsn):
122
+ raise ValueError("state_store.dsn_env or state_store.dsn required for postgres")
123
+ return self
124
+
125
+
126
+ class MQTTAuthConfig(BaseModel):
127
+ """MQTT auth configuration using env var names."""
128
+
129
+ username_env: str | None = None
130
+ password_env: str | None = None
131
+
132
+
133
+ class MQTTConfig(BaseModel):
134
+ """MQTT notifier configuration."""
135
+
136
+ host: str
137
+ port: int = 1883
138
+ auth: MQTTAuthConfig | None = None
139
+ topic_template: str = "homecam/alerts/{camera_name}"
140
+ qos: int = 1
141
+ retain: bool = False
142
+ connection_timeout: float = 10.0
143
+
144
+
145
+ class SendGridEmailConfig(BaseModel):
146
+ """SendGrid email notifier configuration."""
147
+
148
+ api_key_env: str = "SENDGRID_API_KEY"
149
+ from_email: str
150
+ from_name: str | None = None
151
+ to_emails: list[str] = Field(min_length=1)
152
+ cc_emails: list[str] = Field(default_factory=list)
153
+ bcc_emails: list[str] = Field(default_factory=list)
154
+ subject_template: str = "[HomeSec] {camera_name}: {activity_type} ({risk_level})"
155
+ text_template: str = (
156
+ "HomeSec alert\n"
157
+ "Camera: {camera_name}\n"
158
+ "Clip: {clip_id}\n"
159
+ "Risk: {risk_level}\n"
160
+ "Activity: {activity_type}\n"
161
+ "Reason: {notify_reason}\n"
162
+ "Summary: {summary}\n"
163
+ "View: {view_url}\n"
164
+ "Storage: {storage_uri}\n"
165
+ "Time: {ts}\n"
166
+ "Upload failed: {upload_failed}\n"
167
+ )
168
+ html_template: str = (
169
+ "<html><body>"
170
+ "<h2>HomeSec alert</h2>"
171
+ "<p><strong>Camera:</strong> {camera_name}</p>"
172
+ "<p><strong>Clip:</strong> {clip_id}</p>"
173
+ "<p><strong>Risk:</strong> {risk_level}</p>"
174
+ "<p><strong>Activity:</strong> {activity_type}</p>"
175
+ "<p><strong>Reason:</strong> {notify_reason}</p>"
176
+ "<p><strong>Summary:</strong> {summary}</p>"
177
+ "<p><strong>View:</strong> <a href=\"{view_url}\">{view_url}</a></p>"
178
+ "<p><strong>Storage:</strong> {storage_uri}</p>"
179
+ "<p><strong>Time:</strong> {ts}</p>"
180
+ "<p><strong>Upload failed:</strong> {upload_failed}</p>"
181
+ "<h3>Structured analysis</h3>"
182
+ "{analysis_html}"
183
+ "</body></html>"
184
+ )
185
+ request_timeout_s: float = 10.0
186
+ api_base: str = "https://api.sendgrid.com/v3"
187
+
188
+ @model_validator(mode="after")
189
+ def _validate_templates(self) -> "SendGridEmailConfig":
190
+ if not self.text_template and not self.html_template:
191
+ raise ValueError("sendgrid_email requires at least one of text_template/html_template")
192
+ return self
193
+
194
+
195
+ class NotifierConfig(BaseModel):
196
+ """Notifier configuration entry."""
197
+
198
+ backend: str
199
+ enabled: bool = True
200
+ config: dict[str, object] = Field(default_factory=dict)
201
+
202
+
203
+ class RetentionConfig(BaseModel):
204
+ """Retention configuration for local storage."""
205
+
206
+ max_local_size: str | None = None
207
+
208
+
209
+ class ConcurrencyConfig(BaseModel):
210
+ """Concurrency limits for pipeline stages."""
211
+
212
+ max_clips_in_flight: int = Field(default=4, ge=1)
213
+ upload_workers: int = Field(default=4, ge=1)
214
+ filter_workers: int = Field(default=4, ge=1)
215
+ vlm_workers: int = Field(default=2, ge=1)
216
+
217
+
218
+ class RetryConfig(BaseModel):
219
+ """Retry configuration for transient failures."""
220
+
221
+ max_attempts: int = Field(default=3, ge=1)
222
+ backoff_s: float = Field(default=1.0, ge=0.0)
223
+
224
+
225
+ class HealthConfig(BaseModel):
226
+ """Health endpoint configuration."""
227
+
228
+ host: str = "0.0.0.0"
229
+ port: int = 8080
230
+ mqtt_is_critical: bool = False
231
+
232
+
233
+ class CameraSourceConfig(BaseModel):
234
+ """Camera source configuration wrapper."""
235
+
236
+ model_config = {"extra": "forbid"}
237
+
238
+ type: Literal["rtsp", "local_folder", "ftp"]
239
+ config: RTSPSourceConfig | LocalFolderSourceConfig | FtpSourceConfig
240
+
241
+ @model_validator(mode="before")
242
+ @classmethod
243
+ def _parse_source_config(cls, data: object) -> object:
244
+ if not isinstance(data, dict):
245
+ return data
246
+ source_type = data.get("type")
247
+ raw_config = data.get("config")
248
+ if isinstance(
249
+ raw_config, (RTSPSourceConfig, LocalFolderSourceConfig, FtpSourceConfig)
250
+ ):
251
+ return data
252
+
253
+ config_data = raw_config or {}
254
+ updated = dict(data)
255
+ match source_type:
256
+ case "rtsp":
257
+ updated["config"] = RTSPSourceConfig.model_validate(config_data)
258
+ case "local_folder":
259
+ updated["config"] = LocalFolderSourceConfig.model_validate(config_data)
260
+ case "ftp":
261
+ updated["config"] = FtpSourceConfig.model_validate(config_data)
262
+ return updated
263
+
264
+ @model_validator(mode="after")
265
+ def _validate_source_config(self) -> "CameraSourceConfig":
266
+ match self.type:
267
+ case "rtsp":
268
+ if not isinstance(self.config, RTSPSourceConfig):
269
+ raise ValueError("camera.source.config must be RTSPSourceConfig for type=rtsp")
270
+ case "local_folder":
271
+ if not isinstance(self.config, LocalFolderSourceConfig):
272
+ raise ValueError(
273
+ "camera.source.config must be LocalFolderSourceConfig for type=local_folder"
274
+ )
275
+ case "ftp":
276
+ if not isinstance(self.config, FtpSourceConfig):
277
+ raise ValueError("camera.source.config must be FtpSourceConfig for type=ftp")
278
+ return self
279
+
280
+
281
+ class CameraConfig(BaseModel):
282
+ """Camera configuration and clip source selection."""
283
+
284
+ name: str
285
+ source: CameraSourceConfig
286
+
287
+
288
+ TConfig = TypeVar("TConfig", bound=BaseModel)
289
+
290
+
291
+ class Config(BaseModel):
292
+ """Main configuration with per-camera override support."""
293
+
294
+ version: int = 1
295
+ cameras: list[CameraConfig]
296
+ storage: StorageConfig
297
+ state_store: StateStoreConfig = Field(default_factory=StateStoreConfig)
298
+ notifiers: list[NotifierConfig]
299
+ retention: RetentionConfig = Field(default_factory=RetentionConfig)
300
+ concurrency: ConcurrencyConfig = Field(default_factory=ConcurrencyConfig)
301
+ retry: RetryConfig = Field(default_factory=RetryConfig)
302
+ health: HealthConfig = Field(default_factory=HealthConfig)
303
+ filter: FilterConfig
304
+ vlm: VLMConfig
305
+ alert_policy: AlertPolicyConfig
306
+
307
+ # Per-camera overrides (alert policy only)
308
+ per_camera_alert: dict[str, AlertPolicyOverrides] = Field(default_factory=dict)
309
+
310
+ @model_validator(mode="after")
311
+ def _validate_notifiers(self) -> "Config":
312
+ if not self.notifiers:
313
+ raise ValueError("notifiers must include at least one notifier")
314
+ return self
315
+
316
+ @model_validator(mode="after")
317
+ def _validate_builtin_plugin_configs(self) -> "Config":
318
+ """Validate built-in plugin configs for early error detection.
319
+
320
+ Third-party plugin configs are validated later during plugin loading.
321
+ """
322
+ # Validate built-in filter configs
323
+ if self.filter.plugin == "yolo":
324
+ from homesec.models.filter import YoloFilterSettings
325
+
326
+ if isinstance(self.filter.config, dict):
327
+ validated_filter_config = YoloFilterSettings.model_validate(self.filter.config)
328
+ # Replace dict with validated object for built-in plugins
329
+ object.__setattr__(self.filter, "config", validated_filter_config)
330
+
331
+ # Validate built-in VLM configs
332
+ if self.vlm.backend == "openai":
333
+ from homesec.models.vlm import OpenAILLMConfig
334
+
335
+ if isinstance(self.vlm.llm, dict):
336
+ validated_llm_config = OpenAILLMConfig.model_validate(self.vlm.llm)
337
+ # Replace dict with validated object for built-in plugins
338
+ object.__setattr__(self.vlm, "llm", validated_llm_config)
339
+
340
+ return self
341
+
342
+ def _merge_overrides(
343
+ self, base: TConfig, overrides: BaseModel, model_type: type[TConfig]
344
+ ) -> TConfig:
345
+ merged = {
346
+ **base.model_dump(),
347
+ **overrides.model_dump(exclude_none=True),
348
+ }
349
+ return model_type.model_validate(merged)
350
+
351
+ def get_default_alert_policy(self, camera_name: str) -> DefaultAlertPolicySettings:
352
+ """Get merged default alert policy settings for a specific camera."""
353
+ if self.alert_policy.backend != "default":
354
+ raise ValueError(
355
+ f"default alert policy requested but backend is {self.alert_policy.backend}"
356
+ )
357
+ base = DefaultAlertPolicySettings.model_validate(self.alert_policy.config)
358
+ if camera_name not in self.per_camera_alert:
359
+ return base
360
+
361
+ overrides = self.per_camera_alert[camera_name]
362
+ return self._merge_overrides(base, overrides, DefaultAlertPolicySettings)