refactor: remove legacy controller.py and MicMonitor

- Delete engine/controller.py (StreamController - deprecated)
- Delete engine/mic.py (MicMonitor - deprecated)
- Delete tests/test_controller.py (was testing removed legacy code)
- Delete tests/test_mic.py (was testing removed legacy code)
- Update tests/test_emitters.py to test MicSensor instead of MicMonitor
- Clean up pipeline.py introspector to remove StreamController reference
- Update AGENTS.md to reflect architecture changes
This commit is contained in:
2026-03-16 16:54:51 -07:00
parent f43920e2f0
commit 2a41a90d79
6 changed files with 9 additions and 625 deletions

View File

@@ -1,181 +0,0 @@
"""
Stream controller - manages input sources and orchestrates the render stream.
"""
from engine.config import Config, get_config
from engine.display import (
DisplayRegistry,
KittyDisplay,
MultiDisplay,
NullDisplay,
PygameDisplay,
SixelDisplay,
TerminalDisplay,
WebSocketDisplay,
)
from engine.effects.controller import handle_effects_command
from engine.eventbus import EventBus
from engine.events import EventType, StreamEvent
from engine.mic import MicMonitor
from engine.ntfy import NtfyPoller
from engine.scroll import stream
def _get_display(config: Config):
"""Get the appropriate display based on config."""
DisplayRegistry.initialize()
display_mode = config.display.lower()
displays = []
if display_mode in ("terminal", "both"):
displays.append(TerminalDisplay())
if display_mode in ("websocket", "both"):
ws = WebSocketDisplay(host="0.0.0.0", port=config.websocket_port)
ws.start_server()
ws.start_http_server()
displays.append(ws)
if display_mode == "sixel":
displays.append(SixelDisplay())
if display_mode == "kitty":
displays.append(KittyDisplay())
if display_mode == "pygame":
displays.append(PygameDisplay())
if not displays:
return NullDisplay()
if len(displays) == 1:
return displays[0]
return MultiDisplay(displays)
class StreamController:
"""Controls the stream lifecycle - initializes sources and runs the stream."""
_topics_warmed = False
def __init__(self, config: Config | None = None, event_bus: EventBus | None = None):
self.config = config or get_config()
self.event_bus = event_bus
self.mic: MicMonitor | None = None
self.ntfy: NtfyPoller | None = None
self.ntfy_cc: NtfyPoller | None = None
@classmethod
def warmup_topics(cls) -> None:
"""Warm up ntfy topics lazily (creates them if they don't exist)."""
if cls._topics_warmed:
return
import urllib.request
topics = [
"https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd",
"https://ntfy.sh/klubhaus_terminal_mainline_cc_resp",
"https://ntfy.sh/klubhaus_terminal_mainline",
]
for topic in topics:
try:
req = urllib.request.Request(
topic,
data=b"init",
headers={
"User-Agent": "mainline/0.1",
"Content-Type": "text/plain",
},
method="POST",
)
urllib.request.urlopen(req, timeout=5)
except Exception:
pass
cls._topics_warmed = True
def initialize_sources(self) -> tuple[bool, bool]:
"""Initialize microphone and ntfy sources.
Returns:
(mic_ok, ntfy_ok) - success status for each source
"""
self.mic = MicMonitor(threshold_db=self.config.mic_threshold_db)
mic_ok = self.mic.start() if self.mic.available else False
self.ntfy = NtfyPoller(
self.config.ntfy_topic,
reconnect_delay=self.config.ntfy_reconnect_delay,
display_secs=self.config.message_display_secs,
)
ntfy_ok = self.ntfy.start()
self.ntfy_cc = NtfyPoller(
self.config.ntfy_cc_cmd_topic,
reconnect_delay=self.config.ntfy_reconnect_delay,
display_secs=5,
)
self.ntfy_cc.subscribe(self._handle_cc_message)
ntfy_cc_ok = self.ntfy_cc.start()
return bool(mic_ok), ntfy_ok and ntfy_cc_ok
def _handle_cc_message(self, event) -> None:
"""Handle incoming C&C message - like a serial port control interface."""
import urllib.request
cmd = event.body.strip() if hasattr(event, "body") else str(event).strip()
if not cmd.startswith("/"):
return
response = handle_effects_command(cmd)
topic_url = self.config.ntfy_cc_resp_topic.replace("/json", "")
data = response.encode("utf-8")
req = urllib.request.Request(
topic_url,
data=data,
headers={"User-Agent": "mainline/0.1", "Content-Type": "text/plain"},
method="POST",
)
try:
urllib.request.urlopen(req, timeout=5)
except Exception:
pass
def run(self, items: list) -> None:
"""Run the stream with initialized sources."""
if self.mic is None or self.ntfy is None:
self.initialize_sources()
if self.event_bus:
self.event_bus.publish(
EventType.STREAM_START,
StreamEvent(
event_type=EventType.STREAM_START,
headline_count=len(items),
),
)
display = _get_display(self.config)
stream(items, self.ntfy, self.mic, display)
if display:
display.cleanup()
if self.event_bus:
self.event_bus.publish(
EventType.STREAM_END,
StreamEvent(
event_type=EventType.STREAM_END,
headline_count=len(items),
),
)
def cleanup(self) -> None:
"""Clean up resources."""
if self.mic:
self.mic.stop()

View File

@@ -1,104 +0,0 @@
"""
Microphone input monitor — standalone, no internal dependencies.
Gracefully degrades if sounddevice/numpy are unavailable.
.. deprecated::
For pipeline integration, use :class:`engine.sensors.mic.MicSensor` instead.
MicSensor is a self-contained implementation and does not use MicMonitor.
"""
import atexit
from collections.abc import Callable
from datetime import datetime
try:
import numpy as _np
import sounddevice as _sd
_HAS_MIC = True
except Exception:
_HAS_MIC = False
from engine.events import MicLevelEvent
class MicMonitor:
"""Background mic stream that exposes current RMS dB level.
.. deprecated::
For pipeline integration, use :class:`engine.sensors.mic.MicSensor` instead.
"""
def __init__(self, threshold_db=50):
self.threshold_db = threshold_db
self._db = -99.0
self._stream = None
self._subscribers: list[Callable[[MicLevelEvent], None]] = []
@property
def available(self):
"""True if sounddevice is importable."""
return _HAS_MIC
@property
def db(self):
"""Current RMS dB level."""
return self._db
@property
def excess(self):
"""dB above threshold (clamped to 0)."""
return max(0.0, self._db - self.threshold_db)
def subscribe(self, callback: Callable[[MicLevelEvent], None]) -> None:
"""Register a callback to be called when mic level changes."""
self._subscribers.append(callback)
def unsubscribe(self, callback: Callable[[MicLevelEvent], None]) -> None:
"""Remove a registered callback."""
if callback in self._subscribers:
self._subscribers.remove(callback)
def _emit(self, event: MicLevelEvent) -> None:
"""Emit an event to all subscribers."""
for cb in self._subscribers:
try:
cb(event)
except Exception:
pass
def start(self):
"""Start background mic stream. Returns True on success, False/None otherwise."""
if not _HAS_MIC:
return None
def _cb(indata, frames, t, status):
rms = float(_np.sqrt(_np.mean(indata**2)))
self._db = 20 * _np.log10(rms) if rms > 0 else -99.0
if self._subscribers:
event = MicLevelEvent(
db_level=self._db,
excess_above_threshold=max(0.0, self._db - self.threshold_db),
timestamp=datetime.now(),
)
self._emit(event)
try:
self._stream = _sd.InputStream(
callback=_cb, channels=1, samplerate=44100, blocksize=2048
)
self._stream.start()
atexit.register(self.stop)
return True
except Exception:
return False
def stop(self):
"""Stop the mic stream if running."""
if self._stream:
try:
self._stream.stop()
except Exception:
pass
self._stream = None

View File

@@ -319,18 +319,7 @@ class PipelineIntrospector:
)
def introspect_scroll(self) -> None:
"""Introspect scroll engine."""
self.add_node(
PipelineNode(
name="StreamController",
module="engine.controller",
class_name="StreamController",
description="Main render loop orchestrator",
inputs=["items", "ntfy_poller", "mic_monitor", "display"],
outputs=["buffer"],
)
)
"""Introspect scroll engine (legacy - replaced by pipeline architecture)."""
self.add_node(
PipelineNode(
name="render_ticker_zone",

View File

@@ -1,171 +0,0 @@
"""
Tests for engine.controller module.
"""
from unittest.mock import MagicMock, patch
from engine import config
from engine.controller import StreamController, _get_display
class TestGetDisplay:
"""Tests for _get_display function."""
@patch("engine.controller.WebSocketDisplay")
@patch("engine.controller.TerminalDisplay")
def test_get_display_terminal(self, mock_terminal, mock_ws):
"""returns TerminalDisplay for display=terminal."""
mock_terminal.return_value = MagicMock()
mock_ws.return_value = MagicMock()
cfg = config.Config(display="terminal")
display = _get_display(cfg)
mock_terminal.assert_called()
assert isinstance(display, MagicMock)
@patch("engine.controller.WebSocketDisplay")
@patch("engine.controller.TerminalDisplay")
def test_get_display_websocket(self, mock_terminal, mock_ws):
"""returns WebSocketDisplay for display=websocket."""
mock_ws_instance = MagicMock()
mock_ws.return_value = mock_ws_instance
mock_terminal.return_value = MagicMock()
cfg = config.Config(display="websocket")
_get_display(cfg)
mock_ws.assert_called()
mock_ws_instance.start_server.assert_called()
mock_ws_instance.start_http_server.assert_called()
@patch("engine.controller.SixelDisplay")
def test_get_display_sixel(self, mock_sixel):
"""returns SixelDisplay for display=sixel."""
mock_sixel.return_value = MagicMock()
cfg = config.Config(display="sixel")
_get_display(cfg)
mock_sixel.assert_called()
def test_get_display_unknown_returns_null(self):
"""returns NullDisplay for unknown display mode."""
cfg = config.Config(display="unknown")
display = _get_display(cfg)
from engine.display import NullDisplay
assert isinstance(display, NullDisplay)
@patch("engine.controller.WebSocketDisplay")
@patch("engine.controller.TerminalDisplay")
@patch("engine.controller.MultiDisplay")
def test_get_display_both(self, mock_multi, mock_terminal, mock_ws):
"""returns MultiDisplay for display=both."""
mock_terminal_instance = MagicMock()
mock_ws_instance = MagicMock()
mock_terminal.return_value = mock_terminal_instance
mock_ws.return_value = mock_ws_instance
cfg = config.Config(display="both")
_get_display(cfg)
mock_multi.assert_called()
call_args = mock_multi.call_args[0][0]
assert mock_terminal_instance in call_args
assert mock_ws_instance in call_args
class TestStreamController:
"""Tests for StreamController class."""
def test_init_default_config(self):
"""StreamController initializes with default config."""
controller = StreamController()
assert controller.config is not None
assert isinstance(controller.config, config.Config)
def test_init_custom_config(self):
"""StreamController accepts custom config."""
custom_config = config.Config(headline_limit=500)
controller = StreamController(config=custom_config)
assert controller.config.headline_limit == 500
def test_init_sources_none_by_default(self):
"""Sources are None until initialized."""
controller = StreamController()
assert controller.mic is None
assert controller.ntfy is None
@patch("engine.controller.MicMonitor")
@patch("engine.controller.NtfyPoller")
def test_initialize_sources(self, mock_ntfy, mock_mic):
"""initialize_sources creates mic and ntfy instances."""
mock_mic_instance = MagicMock()
mock_mic_instance.available = True
mock_mic_instance.start.return_value = True
mock_mic.return_value = mock_mic_instance
mock_ntfy_instance = MagicMock()
mock_ntfy_instance.start.return_value = True
mock_ntfy.return_value = mock_ntfy_instance
controller = StreamController()
mic_ok, ntfy_ok = controller.initialize_sources()
assert mic_ok is True
assert ntfy_ok is True
assert controller.mic is not None
assert controller.ntfy is not None
@patch("engine.controller.MicMonitor")
@patch("engine.controller.NtfyPoller")
def test_initialize_sources_mic_unavailable(self, mock_ntfy, mock_mic):
"""initialize_sources handles unavailable mic."""
mock_mic_instance = MagicMock()
mock_mic_instance.available = False
mock_mic.return_value = mock_mic_instance
mock_ntfy_instance = MagicMock()
mock_ntfy_instance.start.return_value = True
mock_ntfy.return_value = mock_ntfy_instance
controller = StreamController()
mic_ok, ntfy_ok = controller.initialize_sources()
assert mic_ok is False
assert ntfy_ok is True
@patch("engine.controller.MicMonitor")
def test_initialize_sources_cc_subscribed(self, mock_mic):
"""initialize_sources subscribes C&C handler."""
mock_mic_instance = MagicMock()
mock_mic_instance.available = False
mock_mic_instance.start.return_value = False
mock_mic.return_value = mock_mic_instance
with patch("engine.controller.NtfyPoller") as mock_ntfy:
mock_ntfy_instance = MagicMock()
mock_ntfy_instance.start.return_value = True
mock_ntfy.return_value = mock_ntfy_instance
controller = StreamController()
controller.initialize_sources()
mock_ntfy_instance.subscribe.assert_called()
class TestStreamControllerCleanup:
"""Tests for StreamController cleanup."""
@patch("engine.controller.MicMonitor")
def test_cleanup_stops_mic(self, mock_mic):
"""cleanup stops the microphone if running."""
mock_mic_instance = MagicMock()
mock_mic.return_value = mock_mic_instance
controller = StreamController()
controller.mic = mock_mic_instance
controller.cleanup()
mock_mic_instance.stop.assert_called_once()

View File

@@ -58,12 +58,12 @@ class TestProtocolCompliance:
assert callable(poller.subscribe)
assert callable(poller.unsubscribe)
def test_mic_monitor_complies_with_protocol(self):
"""MicMonitor implements EventEmitter and Startable protocols."""
from engine.mic import MicMonitor
def test_mic_sensor_complies_with_protocol(self):
"""MicSensor implements Startable and Stoppable protocols."""
from engine.sensors.mic import MicSensor
monitor = MicMonitor()
assert hasattr(monitor, "subscribe")
assert hasattr(monitor, "unsubscribe")
assert hasattr(monitor, "start")
assert hasattr(monitor, "stop")
sensor = MicSensor()
assert hasattr(sensor, "start")
assert hasattr(sensor, "stop")
assert callable(sensor.start)
assert callable(sensor.stop)

View File

@@ -1,149 +0,0 @@
"""
Tests for engine.mic module.
"""
from datetime import datetime
from unittest.mock import patch
from engine.events import MicLevelEvent
class TestMicMonitorImport:
"""Tests for module import behavior."""
def test_mic_monitor_imports_without_error(self):
"""MicMonitor can be imported even without sounddevice."""
from engine.mic import MicMonitor
assert MicMonitor is not None
class TestMicMonitorInit:
"""Tests for MicMonitor initialization."""
def test_init_sets_threshold(self):
"""Threshold is set correctly."""
from engine.mic import MicMonitor
monitor = MicMonitor(threshold_db=60)
assert monitor.threshold_db == 60
def test_init_defaults(self):
"""Default values are set correctly."""
from engine.mic import MicMonitor
monitor = MicMonitor()
assert monitor.threshold_db == 50
def test_init_db_starts_at_negative(self):
"""_db starts at negative value."""
from engine.mic import MicMonitor
monitor = MicMonitor()
assert monitor.db == -99.0
class TestMicMonitorProperties:
"""Tests for MicMonitor properties."""
def test_excess_returns_positive_when_above_threshold(self):
"""excess returns positive value when above threshold."""
from engine.mic import MicMonitor
monitor = MicMonitor(threshold_db=50)
with patch.object(monitor, "_db", 60.0):
assert monitor.excess == 10.0
def test_excess_returns_zero_when_below_threshold(self):
"""excess returns zero when below threshold."""
from engine.mic import MicMonitor
monitor = MicMonitor(threshold_db=50)
with patch.object(monitor, "_db", 40.0):
assert monitor.excess == 0.0
class TestMicMonitorAvailable:
"""Tests for MicMonitor.available property."""
def test_available_is_bool(self):
"""available returns a boolean."""
from engine.mic import MicMonitor
monitor = MicMonitor()
assert isinstance(monitor.available, bool)
class TestMicMonitorStop:
"""Tests for MicMonitor.stop method."""
def test_stop_does_nothing_when_no_stream(self):
"""stop() does nothing if no stream exists."""
from engine.mic import MicMonitor
monitor = MicMonitor()
monitor.stop()
assert monitor._stream is None
class TestMicMonitorEventEmission:
"""Tests for MicMonitor event emission."""
def test_subscribe_adds_callback(self):
"""subscribe() adds a callback."""
from engine.mic import MicMonitor
monitor = MicMonitor()
def callback(e):
return None
monitor.subscribe(callback)
assert callback in monitor._subscribers
def test_unsubscribe_removes_callback(self):
"""unsubscribe() removes a callback."""
from engine.mic import MicMonitor
monitor = MicMonitor()
def callback(e):
return None
monitor.subscribe(callback)
monitor.unsubscribe(callback)
assert callback not in monitor._subscribers
def test_emit_calls_subscribers(self):
"""_emit() calls all subscribers."""
from engine.mic import MicMonitor
monitor = MicMonitor()
received = []
def callback(event):
received.append(event)
monitor.subscribe(callback)
event = MicLevelEvent(
db_level=60.0, excess_above_threshold=10.0, timestamp=datetime.now()
)
monitor._emit(event)
assert len(received) == 1
assert received[0].db_level == 60.0
def test_emit_handles_subscriber_exception(self):
"""_emit() handles exceptions in subscribers gracefully."""
from engine.mic import MicMonitor
monitor = MicMonitor()
def bad_callback(event):
raise RuntimeError("test")
monitor.subscribe(bad_callback)
event = MicLevelEvent(
db_level=60.0, excess_above_threshold=10.0, timestamp=datetime.now()
)
monitor._emit(event)