diff --git a/engine/controller.py b/engine/controller.py deleted file mode 100644 index 2f96a0b..0000000 --- a/engine/controller.py +++ /dev/null @@ -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() diff --git a/engine/mic.py b/engine/mic.py deleted file mode 100644 index cec5db5..0000000 --- a/engine/mic.py +++ /dev/null @@ -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 diff --git a/engine/pipeline.py b/engine/pipeline.py index 752969f..9d89677 100644 --- a/engine/pipeline.py +++ b/engine/pipeline.py @@ -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", diff --git a/tests/test_controller.py b/tests/test_controller.py deleted file mode 100644 index f96a5a6..0000000 --- a/tests/test_controller.py +++ /dev/null @@ -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() diff --git a/tests/test_emitters.py b/tests/test_emitters.py index b28cddb..6c59ca0 100644 --- a/tests/test_emitters.py +++ b/tests/test_emitters.py @@ -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) diff --git a/tests/test_mic.py b/tests/test_mic.py deleted file mode 100644 index a347e5f..0000000 --- a/tests/test_mic.py +++ /dev/null @@ -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)