3 Commits

Author SHA1 Message Date
adlee-was-taken
09b59e3bb5 feat: wire MQTT → SSE bridge so the event timeline updates live
Closes #1.

The Flask event-timeline was dead: `broadcast_sse_event` existed in
`vigilar/web/blueprints/events.py` but had zero call sites. Clients
subscribed to `/events/stream`, received the initial "connected"
message, and then only keepalives — a page refresh was required to
see new events. (Web Push via VAPID was independent and already worked.)

The root cause was a process-boundary gap: the events subsystem runs
in its own OS process and emits to MQTT, while the Flask app runs in a
separate process with no MQTT client of its own.

This change adds a thin bridge:

- EventProcessor._handle_event now publishes a classified summary
  (id, ts, type, severity, source_id, payload) to a new topic
  `Topics.EVENTS_PUBLISHED = "vigilar/events/published"` right after
  `insert_event()`. Classification logic stays in one place.

- A new module `vigilar/web/sse_bridge.py` provides `forward_event`
  (MQTT handler) and `start_sse_bridge(cfg)` (creates a MessageBus,
  subscribes forward_event to EVENTS_PUBLISHED, connects, returns the
  bus).

- `vigilar/main.py:_run_web` starts the bridge after `create_app(cfg)`
  and disconnects it on shutdown. Bridge failure is logged but does
  not kill the web process — the UI still works without live updates.

- `create_app` is deliberately NOT changed. Keeping the bridge out of
  the app factory means no existing test triggers a real MQTT
  connection, and the bridge stays a production-only concern wired by
  the supervisor.

Tests (all added with TDD, RED verified before GREEN):

- tests/unit/test_events.py::TestEventsPublishedBroadcast — asserts
  `_handle_event` publishes the classified payload for a motion event
  and does NOT publish for unclassified topics (heartbeats).
- tests/unit/test_sse_bridge.py — asserts `forward_event` reaches SSE
  subscribers, and `start_sse_bridge` wires the handler to
  `Topics.EVENTS_PUBLISHED` on a connected bus (fake bus, no real
  MQTT in tests).

Also refreshes the docs that previously flagged the dead SSE as a
known limitation (operator guide, web architecture doc).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 16:55:27 +00:00
adlee-was-taken
9f959f8c78 test: isolate VIGILAR_CONFIG via autouse session fixture
Some web endpoint handlers call _save_and_reload(), which resolves the
target path via VIGILAR_CONFIG env var with a fallback to the relative
"config/vigilar.toml". Any test exercising such an endpoint without
setting the env var rewrites the repo's committed config file via a
Pydantic model_dump round-trip, stripping comments and non-default
fields. The culprit discovered was test_reset_pin_correct_passphrase
in tests/unit/test_system_pin.py.

Add an autouse session-scoped fixture in tests/conftest.py that points
VIGILAR_CONFIG at a path inside pytest's session tmp dir so no test
can touch the real file. Restore the previous env var value on teardown.

Fixes #3.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 16:54:12 +00:00
adlee-was-taken
17bd403217 fix: correct set-password docstring (scrypt, not bcrypt)
The set_password_cmd docstring and inline comment claimed bcrypt /
SHA-256, but the implementation actually uses scrypt via
cryptography.hazmat.primitives.kdf.scrypt. Correct the docstring,
drop the misleading comment, and remove the now-unused hashlib import.

No behavior change.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 10:41:10 -04:00

View File

@@ -60,12 +60,9 @@ def show_cmd(config_path: str | None) -> None:
@config_cmd.command("set-password") @config_cmd.command("set-password")
@click.option("--config", "-c", "config_path", default=None, help="Path to vigilar.toml.") @click.option("--config", "-c", "config_path", default=None, help="Path to vigilar.toml.")
def set_password_cmd(config_path: str | None) -> None: def set_password_cmd(config_path: str | None) -> None:
"""Generate a bcrypt hash for the web UI password.""" """Generate a scrypt hash for the web UI password."""
try: try:
import hashlib
password = click.prompt("Enter web UI password", hide_input=True, confirmation_prompt=True) password = click.prompt("Enter web UI password", hide_input=True, confirmation_prompt=True)
# Use SHA-256 hash (bcrypt requires external dep, but cryptography is available)
from cryptography.hazmat.primitives.kdf.scrypt import Scrypt from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
import os import os