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,184 @@
1
+ """Event models for clip lifecycle tracking."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from typing import Annotated, Any, Literal
7
+
8
+ from pydantic import BaseModel, Field
9
+
10
+ from homesec.models.filter import FilterResult
11
+
12
+
13
+ class ClipEvent(BaseModel):
14
+ """Base class for all clip lifecycle events."""
15
+ id: int | None = None
16
+ clip_id: str
17
+ timestamp: datetime
18
+ event_type: str
19
+
20
+
21
+ class ClipRecordedEvent(ClipEvent):
22
+ """Clip was recorded and queued for processing."""
23
+
24
+ event_type: Literal["clip_recorded"] = "clip_recorded"
25
+ camera_name: str
26
+ duration_s: float
27
+ source_type: str
28
+
29
+
30
+ class ClipDeletedEvent(ClipEvent):
31
+ """Clip was deleted by a maintenance workflow (e.g., cleanup CLI)."""
32
+
33
+ event_type: Literal["clip_deleted"] = "clip_deleted"
34
+ camera_name: str
35
+ reason: str
36
+ run_id: str
37
+ local_path: str
38
+ storage_uri: str | None
39
+ deleted_local: bool
40
+ deleted_storage: bool
41
+
42
+
43
+ class ClipRecheckedEvent(ClipEvent):
44
+ """Clip was re-analyzed by a maintenance workflow."""
45
+
46
+ event_type: Literal["clip_rechecked"] = "clip_rechecked"
47
+ camera_name: str
48
+ reason: str
49
+ run_id: str
50
+ prior_filter: FilterResult | None
51
+ recheck_filter: FilterResult
52
+
53
+
54
+ class UploadStartedEvent(ClipEvent):
55
+ """Upload to storage backend started."""
56
+ event_type: Literal["upload_started"] = "upload_started"
57
+ dest_key: str
58
+ attempt: int
59
+
60
+
61
+ class UploadCompletedEvent(ClipEvent):
62
+ """Upload to storage backend completed successfully."""
63
+ event_type: Literal["upload_completed"] = "upload_completed"
64
+ storage_uri: str
65
+ view_url: str | None
66
+ attempt: int
67
+ duration_ms: int
68
+
69
+
70
+ class UploadFailedEvent(ClipEvent):
71
+ """Upload to storage backend failed."""
72
+ event_type: Literal["upload_failed"] = "upload_failed"
73
+ attempt: int
74
+ error_message: str
75
+ error_type: str
76
+ will_retry: bool
77
+
78
+
79
+ class FilterStartedEvent(ClipEvent):
80
+ """Object detection filter started."""
81
+ event_type: Literal["filter_started"] = "filter_started"
82
+ attempt: int
83
+
84
+
85
+ class FilterCompletedEvent(ClipEvent):
86
+ """Object detection filter completed."""
87
+ event_type: Literal["filter_completed"] = "filter_completed"
88
+ detected_classes: list[str]
89
+ confidence: float
90
+ model: str
91
+ sampled_frames: int
92
+ attempt: int
93
+ duration_ms: int
94
+
95
+
96
+ class FilterFailedEvent(ClipEvent):
97
+ """Object detection filter failed."""
98
+ event_type: Literal["filter_failed"] = "filter_failed"
99
+ attempt: int
100
+ error_message: str
101
+ error_type: str
102
+ will_retry: bool
103
+
104
+
105
+ class VLMStartedEvent(ClipEvent):
106
+ """VLM analysis started."""
107
+ event_type: Literal["vlm_started"] = "vlm_started"
108
+ attempt: int
109
+
110
+
111
+ class VLMCompletedEvent(ClipEvent):
112
+ """VLM analysis completed."""
113
+ event_type: Literal["vlm_completed"] = "vlm_completed"
114
+ risk_level: str
115
+ activity_type: str
116
+ summary: str
117
+ analysis: dict[str, Any]
118
+ prompt_tokens: int | None
119
+ completion_tokens: int | None
120
+ attempt: int
121
+ duration_ms: int
122
+
123
+
124
+ class VLMFailedEvent(ClipEvent):
125
+ """VLM analysis failed."""
126
+ event_type: Literal["vlm_failed"] = "vlm_failed"
127
+ attempt: int
128
+ error_message: str
129
+ error_type: str
130
+ will_retry: bool
131
+
132
+
133
+ class VLMSkippedEvent(ClipEvent):
134
+ """VLM analysis skipped (no trigger classes detected)."""
135
+ event_type: Literal["vlm_skipped"] = "vlm_skipped"
136
+ reason: str
137
+
138
+
139
+ class AlertDecisionMadeEvent(ClipEvent):
140
+ """Alert policy decision made."""
141
+ event_type: Literal["alert_decision_made"] = "alert_decision_made"
142
+ should_notify: bool
143
+ reason: str
144
+ detected_classes: list[str] | None
145
+ vlm_risk: str | None
146
+
147
+
148
+ class NotificationSentEvent(ClipEvent):
149
+ """Notification sent successfully."""
150
+ event_type: Literal["notification_sent"] = "notification_sent"
151
+ notifier_name: str
152
+ dedupe_key: str
153
+ attempt: int = 1
154
+
155
+
156
+ class NotificationFailedEvent(ClipEvent):
157
+ """Notification send failed."""
158
+ event_type: Literal["notification_failed"] = "notification_failed"
159
+ notifier_name: str
160
+ error_message: str
161
+ error_type: str
162
+ attempt: int = 1
163
+ will_retry: bool = False
164
+
165
+
166
+ ClipLifecycleEvent = Annotated[
167
+ ClipRecordedEvent
168
+ | ClipDeletedEvent
169
+ | ClipRecheckedEvent
170
+ | UploadStartedEvent
171
+ | UploadCompletedEvent
172
+ | UploadFailedEvent
173
+ | FilterStartedEvent
174
+ | FilterCompletedEvent
175
+ | FilterFailedEvent
176
+ | VLMStartedEvent
177
+ | VLMCompletedEvent
178
+ | VLMFailedEvent
179
+ | VLMSkippedEvent
180
+ | AlertDecisionMadeEvent
181
+ | NotificationSentEvent
182
+ | NotificationFailedEvent,
183
+ Field(discriminator="event_type"),
184
+ ]
@@ -0,0 +1,62 @@
1
+ """Object detection filter data and config models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Literal
6
+
7
+ from pydantic import BaseModel, Field, model_validator
8
+
9
+
10
+ class FilterResult(BaseModel):
11
+ """Result from object detection filter on a video clip."""
12
+
13
+ detected_classes: list[str]
14
+ confidence: float
15
+ model: str
16
+ sampled_frames: int
17
+
18
+
19
+ class YoloFilterSettings(BaseModel):
20
+ """YOLO filter settings.
21
+
22
+ model_path accepts a filename; bare names resolve under ./yolo_cache.
23
+ """
24
+
25
+ model_config = {"extra": "forbid"}
26
+
27
+ model_path: str = "yolo11n.pt"
28
+ classes: list[str] = Field(default_factory=lambda: ["person"], min_length=1)
29
+ min_confidence: float = Field(default=0.5, ge=0.0, le=1.0)
30
+ sample_fps: int = Field(default=2, ge=1)
31
+ min_box_h_ratio: float = Field(default=0.1, ge=0.0, le=1.0)
32
+ min_hits: int = Field(default=1, ge=1)
33
+
34
+
35
+ class FilterOverrides(BaseModel):
36
+ """Runtime overrides for filter settings (model path not allowed)."""
37
+
38
+ model_config = {"extra": "forbid"}
39
+
40
+ classes: list[str] | None = Field(default=None, min_length=1)
41
+ min_confidence: float | None = Field(default=None, ge=0.0, le=1.0)
42
+ sample_fps: int | None = Field(default=None, ge=1)
43
+ min_box_h_ratio: float | None = Field(default=None, ge=0.0, le=1.0)
44
+ min_hits: int | None = Field(default=None, ge=1)
45
+
46
+
47
+ class FilterConfig(BaseModel):
48
+ """Base filter configuration (plugin-agnostic).
49
+
50
+ Plugin-specific config is stored in the 'config' field.
51
+ - During YAML parsing: dict[str, object] (preserves all third-party fields)
52
+ - After plugin discovery: BaseModel subclass (validated against plugin.config_model)
53
+
54
+ Note: Plugin names are validated against the registry at runtime via
55
+ validate_plugin_names(). This allows third-party plugins via entry points.
56
+ """
57
+
58
+ model_config = {"extra": "forbid"}
59
+
60
+ plugin: str
61
+ max_workers: int = Field(default=4, ge=1)
62
+ config: dict[str, object] | BaseModel # Dict before validation, BaseModel after
@@ -0,0 +1,77 @@
1
+ """Source configuration models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pydantic import BaseModel, Field, field_validator
6
+
7
+
8
+ class RTSPSourceConfig(BaseModel):
9
+ """RTSP source configuration."""
10
+
11
+ model_config = {"extra": "forbid"}
12
+
13
+ rtsp_url_env: str | None = None
14
+ rtsp_url: str | None = None
15
+ detect_rtsp_url_env: str | None = None
16
+ detect_rtsp_url: str | None = None
17
+ output_dir: str = "./recordings"
18
+ pixel_threshold: int = 45
19
+ min_changed_pct: float = 1.0
20
+ blur_kernel: int = 5
21
+ stop_delay: float = 10.0
22
+ max_recording_s: float = 60.0
23
+ max_reconnect_attempts: int = 20
24
+ disable_hwaccel: bool = False
25
+ frame_timeout_s: float = 2.0
26
+ frame_queue_size: int = 20
27
+ reconnect_backoff_s: float = 1.0
28
+ debug_motion: bool = False
29
+ heartbeat_s: float = 30.0
30
+ rtsp_connect_timeout_s: float = 2.0
31
+ rtsp_io_timeout_s: float = 2.0
32
+
33
+
34
+ class LocalFolderSourceConfig(BaseModel):
35
+ """Local folder source configuration."""
36
+
37
+ model_config = {"extra": "forbid"}
38
+
39
+ watch_dir: str = "recordings"
40
+ poll_interval: float = 1.0
41
+ stability_threshold_s: float = 3.0
42
+
43
+
44
+ class FtpSourceConfig(BaseModel):
45
+ """FTP source configuration."""
46
+
47
+ model_config = {"extra": "forbid"}
48
+
49
+ host: str = "0.0.0.0"
50
+ port: int = 2121
51
+ root_dir: str = "./ftp_incoming"
52
+ ftp_subdir: str | None = None
53
+ anonymous: bool = True
54
+ username_env: str | None = None
55
+ password_env: str | None = None
56
+ perms: str = "elw"
57
+ passive_ports: str | None = None
58
+ masquerade_address: str | None = None
59
+ heartbeat_s: float = 30.0
60
+ allowed_extensions: list[str] = Field(default_factory=lambda: [".mp4"])
61
+ delete_non_matching: bool = True
62
+ delete_incomplete: bool = True
63
+ default_duration_s: float = 10.0
64
+ log_level: str = "INFO"
65
+
66
+ @field_validator("allowed_extensions")
67
+ @classmethod
68
+ def _normalize_extensions(cls, value: list[str]) -> list[str]:
69
+ cleaned: list[str] = []
70
+ for item in value:
71
+ ext = str(item).strip().lower()
72
+ if not ext:
73
+ continue
74
+ if not ext.startswith("."):
75
+ ext = f".{ext}"
76
+ cleaned.append(ext)
77
+ return cleaned
@@ -0,0 +1,12 @@
1
+ """Storage-related data models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pydantic import BaseModel
6
+
7
+
8
+ class StorageUploadResult(BaseModel):
9
+ """Result of a storage upload."""
10
+
11
+ storage_uri: str
12
+ view_url: str | None = None
homesec/models/vlm.py ADDED
@@ -0,0 +1,99 @@
1
+ """VLM analysis data and config models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Literal
6
+
7
+ from pydantic import BaseModel, Field, model_validator
8
+
9
+
10
+ RiskLevel = Literal["low", "medium", "high", "critical"]
11
+
12
+
13
+ class AnalysisResult(BaseModel):
14
+ """Structured result from VLM analysis of a video clip."""
15
+
16
+ risk_level: RiskLevel
17
+ activity_type: str
18
+ summary: str
19
+ analysis: SequenceAnalysis | None = None
20
+ prompt_tokens: int | None = None
21
+ completion_tokens: int | None = None
22
+
23
+
24
+ class EntityTimeline(BaseModel):
25
+ """Timeline of an entity across multiple frames."""
26
+
27
+ model_config = {"extra": "forbid"}
28
+ type: Literal["person", "vehicle", "animal", "package", "object", "unknown"]
29
+ first_seen_timestamp: str
30
+ last_seen_timestamp: str
31
+ description: str
32
+ movement: str
33
+ location: str
34
+ interaction: str
35
+
36
+
37
+ class SequenceAnalysis(BaseModel):
38
+ """Structured analysis of a sequence of security camera frames."""
39
+
40
+ model_config = {"extra": "forbid"}
41
+ sequence_description: str
42
+ max_risk_level: RiskLevel
43
+ primary_activity: Literal[
44
+ "normal_delivery",
45
+ "normal_visitor",
46
+ "passerby",
47
+ "suspicious_behavior",
48
+ "dangerous_activity",
49
+ "no_activity",
50
+ "unknown",
51
+ ]
52
+ observations: list[str]
53
+ entities_timeline: list[EntityTimeline]
54
+ requires_review: bool
55
+ frame_count: int
56
+ video_start_time: str
57
+ video_end_time: str
58
+
59
+
60
+ class VLMPreprocessConfig(BaseModel):
61
+ """Preprocessing configuration for VLM frame extraction."""
62
+
63
+ model_config = {"extra": "forbid"}
64
+ max_frames: int = 10
65
+ max_size: int = 1024
66
+ quality: int = 85
67
+
68
+
69
+ class OpenAILLMConfig(BaseModel):
70
+ """OpenAI-compatible LLM configuration."""
71
+
72
+ model_config = {"extra": "forbid"}
73
+ api_key_env: str
74
+ model: str
75
+ base_url: str = "https://api.openai.com/v1"
76
+ token_param: Literal["max_tokens", "max_completion_tokens"] = "max_completion_tokens"
77
+ max_completion_tokens: int = 10_000
78
+ max_tokens: int | None = None
79
+ temperature: float | None = 0.0
80
+ request_timeout: float = 60.0
81
+
82
+
83
+ class VLMConfig(BaseModel):
84
+ """Base VLM configuration.
85
+
86
+ LLM-specific config is stored in the 'llm' field.
87
+ - During YAML parsing: dict[str, object] (preserves all third-party fields)
88
+ - After plugin discovery: BaseModel subclass (validated against plugin.config_model)
89
+
90
+ Note: Backend names are validated against the registry at runtime via
91
+ validate_plugin_names(). This allows third-party VLM plugins via entry points.
92
+ """
93
+
94
+ model_config = {"extra": "forbid"}
95
+ backend: str
96
+ trigger_classes: list[str] = Field(default_factory=lambda: ["person"])
97
+ max_workers: int = Field(default=2, ge=1)
98
+ llm: dict[str, object] | BaseModel # Dict before validation, BaseModel after
99
+ preprocessing: VLMPreprocessConfig = Field(default_factory=VLMPreprocessConfig)
@@ -0,0 +1,6 @@
1
+ """Pipeline module - clip processing orchestration."""
2
+
3
+ from homesec.pipeline.alert_policy import DefaultAlertPolicy
4
+ from homesec.pipeline.core import ClipPipeline
5
+
6
+ __all__ = ["ClipPipeline", "DefaultAlertPolicy"]
@@ -0,0 +1,5 @@
1
+ """Alert policy implementation (re-export)."""
2
+
3
+ from homesec.plugins.alert_policies.default import DefaultAlertPolicy
4
+
5
+ __all__ = ["DefaultAlertPolicy"]