Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ See [`config/example.yaml`](config/example.yaml) for a complete reference of all
### Tips

- **Secrets**: Never put secrets in YAML. Use env vars (`*_env`) and set them in your shell or `.env`.
- **Notifiers**: At least one notifier (mqtt/email) must be enabled unless `alert_policy.enabled` is false.
- **Notifiers**: Notifiers are optional. With no enabled notifiers, alert decisions are still evaluated and recorded, but no external notifications are sent.
- **YOLO Classes**: Built-in classes include `person`, `car`, `truck`, `motorcycle`, `bicycle`, `dog`, `cat`, `bird`, `backpack`, `handbag`, `suitcase`.

After installation, the `homesec` command is available:
Expand Down
1 change: 1 addition & 0 deletions config/example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ state_store:
# =============================================================================
# Notifiers - How to send alerts
# =============================================================================
# Optional: set to [] (or disable all entries) to run analysis-only with no external alerts.
notifiers:
# MQTT (for Home Assistant, Node-RED, etc.)
- backend: mqtt
Expand Down
8 changes: 1 addition & 7 deletions src/homesec/models/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,17 +181,11 @@ class Config(BaseModel):
cameras: list[CameraConfig]
storage: StorageConfig
state_store: StateStoreConfig = Field(default_factory=StateStoreConfig)
notifiers: list[NotifierConfig]
notifiers: list[NotifierConfig] = Field(default_factory=list)
retention: RetentionConfig = Field(default_factory=RetentionConfig)
concurrency: ConcurrencyConfig = Field(default_factory=ConcurrencyConfig)
retry: RetryConfig = Field(default_factory=RetryConfig)
server: FastAPIServerConfig = Field(default_factory=FastAPIServerConfig)
filter: FilterConfig
vlm: VLMConfig
alert_policy: AlertPolicyConfig

@model_validator(mode="after")
def _validate_notifiers(self) -> Config:
if not self.notifiers:
raise ValueError("notifiers must include at least one notifier")
return self
2 changes: 0 additions & 2 deletions src/homesec/notifiers/multiplex.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,6 @@ class MultiplexNotifier(Notifier):
"""Send notifications to multiple notifiers in parallel."""

def __init__(self, entries: list[NotifierEntry]) -> None:
if not entries:
raise ValueError("MultiplexNotifier requires at least one notifier")
self._entries = list(entries)
self._shutdown_called = False

Expand Down
57 changes: 33 additions & 24 deletions src/homesec/pipeline/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ def _resolve_notifier_entries(
notifier: Notifier,
notifier_entries: list[NotifierEntry] | None,
) -> list[NotifierEntry]:
if notifier_entries:
if notifier_entries is not None:
return list(notifier_entries)
name = getattr(notifier, "name", type(notifier).__name__)
return [NotifierEntry(name=name, notifier=notifier)]
Expand Down Expand Up @@ -315,29 +315,35 @@ async def _process_clip(self, clip: Clip) -> None:

# Stage 5: Notify (conditional)
if alert_decision.notify:
notify_result = await self._notify_stage(
clip,
alert_decision,
analysis_result,
storage_uri,
view_url,
upload_failed,
vlm_failed,
)
match notify_result:
case NotifyError() as notify_err:
logger.error(
"Notify failed for %s: %s",
clip.clip_id,
notify_err.cause,
exc_info=notify_err.cause,
)
case None:
logger.info("Notification sent for %s", clip.clip_id)
case _:
raise TypeError(
f"Unexpected notify result type: {type(notify_result).__name__}"
)
if not self._notifier_entries:
logger.info(
"Notification skipped for %s: no enabled notifiers configured",
clip.clip_id,
)
else:
notify_result = await self._notify_stage(
clip,
alert_decision,
analysis_result,
storage_uri,
view_url,
upload_failed,
vlm_failed,
)
match notify_result:
case NotifyError() as notify_err:
logger.error(
"Notify failed for %s: %s",
clip.clip_id,
notify_err.cause,
exc_info=notify_err.cause,
)
case None:
logger.info("Notification sent for %s", clip.clip_id)
case _:
raise TypeError(
f"Unexpected notify result type: {type(notify_result).__name__}"
)

await self._repository.mark_done(clip.clip_id)
logger.info("Clip processing complete: %s", clip.clip_id)
Expand Down Expand Up @@ -546,6 +552,9 @@ async def _notify_stage(
vlm_failed: bool,
) -> None | NotifyError:
"""Send notification. Returns None on success or NotifyError."""
if not self._notifier_entries:
return None

alert = Alert(
clip_id=clip.clip_id,
camera_name=clip.camera_name,
Expand Down
20 changes: 18 additions & 2 deletions src/homesec/runtime/worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@
from typing import TYPE_CHECKING

from homesec.config import resolve_env_var
from homesec.interfaces import Notifier
from homesec.logging_setup import configure_logging
from homesec.models.alert import Alert
from homesec.notifiers.multiplex import MultiplexNotifier, NotifierEntry
from homesec.plugins import discover_all_plugins
from homesec.plugins.alert_policies import load_alert_policy
Expand All @@ -35,7 +37,6 @@
AlertPolicy,
ClipSource,
EventStore,
Notifier,
StateStore,
StorageBackend,
)
Expand All @@ -44,6 +45,20 @@
logger = logging.getLogger(__name__)


class _NoopNotifier(Notifier):
"""No-op notifier used when no notifiers are configured."""

async def send(self, alert: Alert) -> None:
_ = alert
logger.debug("NoopNotifier: alert suppressed (no notifiers configured)")

async def ping(self) -> bool:
return True

async def shutdown(self, timeout: float | None = None) -> None:
pass


class _WorkerEventEmitter:
"""Sends typed worker events over unix datagram IPC."""

Expand Down Expand Up @@ -192,7 +207,8 @@ def _create_notifier(self, config: Config) -> tuple[Notifier, list[NotifierEntry
)

if not entries:
raise RuntimeError("No enabled notifiers configured")
logger.info("No notifiers configured; notifications disabled")
return _NoopNotifier(), entries
if len(entries) == 1:
return entries[0].notifier, entries
return MultiplexNotifier(entries), entries
Expand Down
7 changes: 1 addition & 6 deletions src/homesec/services/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
AlertPolicyConfig,
Config,
FastAPIServerConfig,
NotifierConfig,
StateStoreConfig,
StorageConfig,
)
Expand Down Expand Up @@ -860,7 +859,7 @@ async def _build_finalize_config(
notifiers = _pick_section(
requested=request.notifiers,
existing=existing.notifiers if existing is not None else None,
default=[_default_notifier()],
default=[],
key="notifiers",
defaults_applied=defaults_applied,
)
Expand Down Expand Up @@ -940,10 +939,6 @@ def _default_state_store() -> StateStoreConfig:
return StateStoreConfig(dsn_env="DB_DSN")


def _default_notifier() -> NotifierConfig:
return NotifierConfig(backend="mqtt", enabled=True, config={"host": "localhost", "port": 1883})


def _default_filter() -> FilterConfig:
return FilterConfig(
backend="yolo",
Expand Down
40 changes: 40 additions & 0 deletions tests/homesec/test_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -812,6 +812,46 @@ async def upsert(self, clip_id: str, data: ClipStateData) -> None:
class TestClipPipelineAlertPolicy:
"""Test alert policy integration."""

@pytest.mark.asyncio
async def test_no_notification_when_notifier_entries_explicitly_empty(
self, base_config: Config, sample_clip: Clip, mocks: PipelineMocks
) -> None:
"""No notification side effects when runtime provides no notifier entries."""
# Given: A config that would normally notify, but runtime passes no notifier entries
no_notifier_config = Config(
cameras=base_config.cameras,
storage=base_config.storage,
state_store=base_config.state_store,
notifiers=[],
filter=base_config.filter,
vlm=base_config.vlm,
alert_policy=base_config.alert_policy,
retry=base_config.retry,
)
notifier = MockNotifier()
pipeline = ClipPipeline(
config=no_notifier_config,
storage=mocks.storage,
repository=make_repository(no_notifier_config, mocks),
filter_plugin=mocks.filter,
vlm_plugin=mocks.vlm,
notifier=notifier,
notifier_entries=[],
alert_policy=make_alert_policy(no_notifier_config),
retention_pruner=MockRetentionPruner(),
)

# When: A clip is processed
pipeline.on_new_clip(sample_clip)
await pipeline.shutdown()

# Then: No notifier is called and no notification_sent event is recorded
assert notifier.sent_alerts == []
notification_sent_events = [
event for event in mocks.event_store.events if event.event_type == "notification_sent"
]
assert notification_sent_events == []

@pytest.mark.asyncio
async def test_no_notification_when_below_risk_threshold(
self, base_config: Config, sample_clip: Clip, mocks: PipelineMocks
Expand Down
45 changes: 45 additions & 0 deletions tests/homesec/test_pipeline_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,51 @@ async def test_pipeline_emits_notification_events_per_notifier(
await state_store.shutdown()


@pytest.mark.asyncio
async def test_pipeline_records_alert_decision_without_notification_events_when_no_notifiers(
postgres_dsn: str, tmp_path: Path, clean_test_db: None
) -> None:
# Given: A real Postgres event store and runtime-provided empty notifier entries
state_store = PostgresStateStore(postgres_dsn)
await state_store.initialize()
event_store = state_store.create_event_store()
assert isinstance(event_store, PostgresEventStore)
config = build_config(notifier_count=0)
repository = ClipRepository(state_store, event_store, retry=config.retry)

filter_result = FilterResult(
detected_classes=["person"],
confidence=0.9,
model="mock",
sampled_frames=30,
)
pipeline = ClipPipeline(
config=config,
storage=MockStorage(),
repository=repository,
filter_plugin=MockFilter(result=filter_result),
vlm_plugin=MockVLM(),
notifier=MockNotifier(),
notifier_entries=[],
alert_policy=make_alert_policy(config),
retention_pruner=MockRetentionPruner(),
)
clip = make_clip(tmp_path, "test-clip-events-005")

# When: A clip is processed
pipeline.on_new_clip(clip)
await pipeline.shutdown()

# Then: Alert decision is recorded, but no notification sent/failed events are emitted
events = await event_store.get_events(clip.clip_id)
event_types = {event.event_type for event in events}
assert "alert_decision_made" in event_types
assert "notification_sent" not in event_types
assert "notification_failed" not in event_types

await state_store.shutdown()


@pytest.mark.asyncio
async def test_pipeline_emits_vlm_skipped_event(
postgres_dsn: str, tmp_path: Path, clean_test_db: None
Expand Down
85 changes: 85 additions & 0 deletions tests/homesec/test_runtime_worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,55 @@
from __future__ import annotations

from argparse import Namespace
from typing import Any, cast

import homesec.runtime.worker as worker_module
from homesec.models.config import (
AlertPolicyConfig,
CameraConfig,
CameraSourceConfig,
Config,
NotifierConfig,
StateStoreConfig,
StorageConfig,
)
from homesec.models.filter import FilterConfig
from homesec.models.vlm import VLMConfig


class _StubEmitter:
def send(self, event: object) -> None:
_ = event

def close(self) -> None:
return None


def _make_config(*, notifiers: list[NotifierConfig]) -> Config:
return Config(
cameras=[
CameraConfig(
name="front",
source=CameraSourceConfig(backend="local_folder", config={}),
)
],
storage=StorageConfig(backend="local", config={}),
state_store=StateStoreConfig(dsn="postgresql://user:pass@localhost/homesec"),
notifiers=notifiers,
filter=FilterConfig(backend="yolo", config={}),
vlm=VLMConfig(backend="openai", config={}),
alert_policy=AlertPolicyConfig(backend="default", config={}),
)


def _make_service(config: Config) -> worker_module._RuntimeWorkerService:
return worker_module._RuntimeWorkerService(
config=config,
generation=1,
correlation_id="test-correlation-id",
heartbeat_interval_s=1.0,
emitter=cast(Any, _StubEmitter()),
)


def test_worker_main_uses_shared_logging_configuration(monkeypatch) -> None:
Expand Down Expand Up @@ -37,3 +84,41 @@ def _fake_asyncio_run(coro: object) -> None:
assert calls["log_level"] == "INFO"
assert calls["camera_name"] == "runtime-worker-g7"
assert calls["ran"] is True


def test_runtime_worker_create_notifier_returns_noop_when_notifier_list_empty() -> None:
# Given: Runtime worker config with no notifier entries
config = _make_config(notifiers=[])
service = _make_service(config)

# When: Building notifier stack for runtime bundle
notifier, entries = service._create_notifier(config)

# Then: Worker uses a noop notifier and exposes no notifier entries
assert isinstance(notifier, worker_module._NoopNotifier)
assert entries == []


def test_runtime_worker_create_notifier_skips_disabled_entries(
monkeypatch,
) -> None:
# Given: Runtime worker config with only disabled notifiers
config = _make_config(
notifiers=[
NotifierConfig(backend="mqtt", enabled=False, config={"host": "localhost"}),
NotifierConfig(backend="sendgrid", enabled=False, config={"api_key_env": "SENDGRID"}),
]
)
service = _make_service(config)

def _unexpected_plugin_load(*_: object) -> object:
raise AssertionError("Disabled notifiers should not be loaded")

monkeypatch.setattr(worker_module, "load_notifier_plugin", _unexpected_plugin_load)

# When: Building notifier stack for runtime bundle
notifier, entries = service._create_notifier(config)

# Then: Worker keeps notifications disabled and does not load plugins
assert isinstance(notifier, worker_module._NoopNotifier)
assert entries == []
Loading