From bfd3a25758894731c313ee0a748ca0223059249b Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Mon, 2 Mar 2026 14:44:22 -0500 Subject: [PATCH 1/3] Make notifiers optional in Config model - Remove model validator requiring at least one notifier - Default notifiers to empty list in Config - Add NoopNotifier for when no notifiers are configured - Remove hard requirement in MultiplexNotifier constructor - Default setup finalize to empty notifiers list instead of MQTT --- src/homesec/models/config.py | 8 +------- src/homesec/notifiers/multiplex.py | 2 -- src/homesec/runtime/worker.py | 16 +++++++++++++++- src/homesec/services/setup.py | 2 +- 4 files changed, 17 insertions(+), 11 deletions(-) diff --git a/src/homesec/models/config.py b/src/homesec/models/config.py index 5cc5a105..e069abb7 100644 --- a/src/homesec/models/config.py +++ b/src/homesec/models/config.py @@ -181,7 +181,7 @@ 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) @@ -189,9 +189,3 @@ class Config(BaseModel): 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 diff --git a/src/homesec/notifiers/multiplex.py b/src/homesec/notifiers/multiplex.py index f0bab8a0..a02bc72e 100644 --- a/src/homesec/notifiers/multiplex.py +++ b/src/homesec/notifiers/multiplex.py @@ -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 diff --git a/src/homesec/runtime/worker.py b/src/homesec/runtime/worker.py index ff692af2..a6a3dbdf 100644 --- a/src/homesec/runtime/worker.py +++ b/src/homesec/runtime/worker.py @@ -44,6 +44,19 @@ logger = logging.getLogger(__name__) +class _NoopNotifier: + """No-op notifier used when no notifiers are configured.""" + + async def send(self, alert: object) -> None: + 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.""" @@ -192,7 +205,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 diff --git a/src/homesec/services/setup.py b/src/homesec/services/setup.py index 1f5ec1d0..8da9afa8 100644 --- a/src/homesec/services/setup.py +++ b/src/homesec/services/setup.py @@ -860,7 +860,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, ) From fc8f94deee719f1163abddb59fc28ee4e4c0776f Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Mon, 2 Mar 2026 11:55:12 -0800 Subject: [PATCH 2/3] fix: honor empty notifier entries and add regression test --- src/homesec/pipeline/core.py | 2 +- src/homesec/runtime/worker.py | 8 ++++--- tests/homesec/test_pipeline.py | 40 ++++++++++++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 4 deletions(-) diff --git a/src/homesec/pipeline/core.py b/src/homesec/pipeline/core.py index 4b903f83..011ade7d 100644 --- a/src/homesec/pipeline/core.py +++ b/src/homesec/pipeline/core.py @@ -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)] diff --git a/src/homesec/runtime/worker.py b/src/homesec/runtime/worker.py index a6a3dbdf..7c071ba3 100644 --- a/src/homesec/runtime/worker.py +++ b/src/homesec/runtime/worker.py @@ -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 @@ -35,7 +37,6 @@ AlertPolicy, ClipSource, EventStore, - Notifier, StateStore, StorageBackend, ) @@ -44,10 +45,11 @@ logger = logging.getLogger(__name__) -class _NoopNotifier: +class _NoopNotifier(Notifier): """No-op notifier used when no notifiers are configured.""" - async def send(self, alert: object) -> None: + async def send(self, alert: Alert) -> None: + _ = alert logger.debug("NoopNotifier: alert suppressed (no notifiers configured)") async def ping(self) -> bool: diff --git a/tests/homesec/test_pipeline.py b/tests/homesec/test_pipeline.py index 5e7bee1b..8397cc05 100644 --- a/tests/homesec/test_pipeline.py +++ b/tests/homesec/test_pipeline.py @@ -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 From 2686277b748e5964877ddadcbe1cbe0855a7984d Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Mon, 2 Mar 2026 12:19:56 -0800 Subject: [PATCH 3/3] fix: align optional-notifier no-sink behavior --- README.md | 2 +- config/example.yaml | 1 + src/homesec/pipeline/core.py | 55 +++++++++-------- src/homesec/services/setup.py | 5 -- tests/homesec/test_pipeline_events.py | 45 ++++++++++++++ tests/homesec/test_runtime_worker.py | 85 +++++++++++++++++++++++++++ 6 files changed, 164 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index cb338cf2..8361eed6 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/config/example.yaml b/config/example.yaml index 1e8e0a93..e3f3c20b 100644 --- a/config/example.yaml +++ b/config/example.yaml @@ -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 diff --git a/src/homesec/pipeline/core.py b/src/homesec/pipeline/core.py index 011ade7d..ec99fda6 100644 --- a/src/homesec/pipeline/core.py +++ b/src/homesec/pipeline/core.py @@ -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) @@ -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, diff --git a/src/homesec/services/setup.py b/src/homesec/services/setup.py index 8da9afa8..7f09b32b 100644 --- a/src/homesec/services/setup.py +++ b/src/homesec/services/setup.py @@ -19,7 +19,6 @@ AlertPolicyConfig, Config, FastAPIServerConfig, - NotifierConfig, StateStoreConfig, StorageConfig, ) @@ -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", diff --git a/tests/homesec/test_pipeline_events.py b/tests/homesec/test_pipeline_events.py index 6e6a6c95..571e99fd 100644 --- a/tests/homesec/test_pipeline_events.py +++ b/tests/homesec/test_pipeline_events.py @@ -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 diff --git a/tests/homesec/test_runtime_worker.py b/tests/homesec/test_runtime_worker.py index d177a117..de2011b8 100644 --- a/tests/homesec/test_runtime_worker.py +++ b/tests/homesec/test_runtime_worker.py @@ -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: @@ -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 == []