feat(daemon): add daemon mode with C&C and display abstraction

- Add display abstraction with swappable backends (TerminalDisplay, NullDisplay)
- Separate C&C into RX/TX topics for serial-like communication
- Add StreamController with automatic ntfy topic warmup
- Add performance monitoring for render + display stages
- Update AGENTS.md and README.md with daemon/cmd operating procedures
- Add clean/clobber tasks to mise.toml
- Add tests for display, effects controller, and controller warmup
- Fix bug where /effects reorder command was unreachable
This commit is contained in:
2026-03-15 18:37:36 -07:00
parent 40ad935dda
commit bc2e086f2f
14 changed files with 633 additions and 82 deletions

View File

@@ -22,13 +22,37 @@ uv sync
### Available Commands ### Available Commands
```bash ```bash
# Development
mise run test # Run tests mise run test # Run tests
mise run test-v # Run tests verbose mise run test-v # Run tests verbose
mise run test-cov # Run tests with coverage report mise run test-cov # Run tests with coverage report
mise run lint # Run ruff linter mise run lint # Run ruff linter
mise run lint-fix # Run ruff with auto-fix mise run lint-fix # Run ruff with auto-fix
mise run format # Run ruff formatter mise run format # Run ruff formatter
mise run ci # Full CI pipeline (sync + test + coverage) mise run ci # Full CI pipeline
# Runtime
mise run run # Interactive terminal mode (news)
mise run run-poetry # Interactive terminal mode (poetry)
mise run run-firehose # Dense headline mode
# Daemon mode (recommended for long-running)
mise run daemon # Start mainline in background
mise run daemon-stop # Stop daemon
mise run daemon-restart # Restart daemon
# Command & Control
mise run cmd # Interactive CLI
mise run cmd "/cmd" # Send single command
mise run cmd-stats # Watch performance stats
mise run topics-init # Initialize ntfy topics
# Environment
mise run install # Install dependencies
mise run sync # Sync dependencies
mise run sync-all # Sync with all extras
mise run clean # Clean cache files
mise run clobber # Aggressive cleanup (git clean -fdx + caches)
``` ```
## Git Hooks ## Git Hooks
@@ -108,6 +132,59 @@ The project uses pytest with strict marker enforcement. Test configuration is in
- **eventbus.py** provides thread-safe event publishing for decoupled communication - **eventbus.py** provides thread-safe event publishing for decoupled communication
- **controller.py** coordinates ntfy/mic monitoring - **controller.py** coordinates ntfy/mic monitoring
- The render pipeline: fetch → render → effects → scroll → terminal output - The render pipeline: fetch → render → effects → scroll → terminal output
- **display.py** provides swappable display backends (TerminalDisplay, NullDisplay)
## Operating Modes
Mainline can run in two modes:
### 1. Standalone Mode (Original)
Run directly as a terminal application with interactive pickers:
```bash
mise run run # news stream
mise run run-poetry # poetry mode
mise run run-firehose # dense headline mode
```
This runs the full interactive experience with font picker and effects picker at startup.
### 2. Daemon + Command Mode (Recommended for Long-Running)
The recommended approach for persistent displays:
```bash
# Start the daemon (headless rendering)
mise run daemon
# Send commands via ntfy
mise run cmd "/effects list"
mise run cmd "/effects noise off"
mise run cmd "/effects stats"
# Watch mode (continuous stats polling)
mise run cmd-stats
# Stop the daemon
mise run daemon-stop
```
#### How It Works
- **Daemon**: Runs `mainline.py` in the background, renders to terminal
- **C&C Topics**: Uses separate ntfy topics (like UART serial):
- `klubhaus_terminal_mainline_cc_cmd` - commands TO mainline
- `klubhaus_terminal_mainline_cc_resp` - responses FROM mainline
- **Topics are auto-warmed** on first daemon start
#### Available Commands
```
/effects list - List all effects and status
/effects <name> on - Enable an effect
/effects <name> off - Disable an effect
/effects <name> intensity 0.5 - Set effect intensity (0.0-1.0)
/effects reorder noise,fade,glitch,firehose - Reorder pipeline
/effects stats - Show performance statistics
```
## Effects Plugin System ## Effects Plugin System
@@ -143,15 +220,20 @@ class MyEffect:
### NTFY Commands ### NTFY Commands
Send messages to the ntfy topic to control effects: Send commands via `cmdline.py` or directly to the C&C topic:
```bash
# Using cmdline tool (recommended)
mise run cmd "/effects list"
mise run cmd "/effects noise on"
mise run cmd "/effects noise intensity 0.5"
mise run cmd "/effects reorder noise,glitch,fade,firehose"
# Or directly via curl
curl -d "/effects list" https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd
``` ```
/effects list
/effects noise on The cmdline tool polls the response topic for the daemon's reply.
/effects noise off
/effects noise intensity 0.5
/effects reorder noise,glitch,fade,firehose
```
## Conventional Commits ## Conventional Commits

View File

@@ -24,6 +24,46 @@ First run bootstraps a local `.mainline_venv/` and installs deps (`feedparser`,
--- ---
## Daemon Mode (Recommended for Long-Running)
For persistent displays (e.g., always-on terminal), use daemon mode with command-and-control over ntfy:
```bash
# Start the daemon (runs in background, auto-warms ntfy topics)
mise run daemon
# Send commands via cmdline
mise run cmd "/effects list"
mise run cmd "/effects noise off"
mise run cmd "/effects noise intensity 0.5"
# Watch performance stats continuously
mise run cmd-stats
# Stop the daemon
mise run daemon-stop
```
### How It Works
- **Topics**: Uses separate ntfy topics for serial-like communication:
- `klubhaus_terminal_mainline_cc_cmd` - commands TO mainline
- `klubhaus_terminal_mainline_cc_resp` - responses FROM mainline
- Topics are automatically created on first daemon start
### Available Commands
```
/effects list - List all effects and status
/effects <name> on - Enable an effect
/effects <name> off - Disable an effect
/effects <name> intensity 0.5 - Set effect intensity (0.0-1.0)
/effects reorder noise,fade,glitch,firehose - Reorder pipeline
/effects stats - Show performance statistics
```
---
## Config ## Config
All constants live in `engine/config.py`: All constants live in `engine/config.py`:
@@ -85,9 +125,17 @@ engine/
sources.py FEEDS, POETRY_SOURCES, language/script maps sources.py FEEDS, POETRY_SOURCES, language/script maps
terminal.py ANSI codes, tw/th, type_out, boot_ln terminal.py ANSI codes, tw/th, type_out, boot_ln
filter.py HTML stripping, content filter filter.py HTML stripping, content filter
translate.py Google Translate wrapper + region detection translate.py Google Translate wrapper + region detection
render.py OTF → half-block pipeline (SSAA, gradient) render.py OTF → half-block pipeline (SSAA, gradient)
effects.py noise, glitch_bar, fade, firehose effects/ plugin-based effects system
types.py EffectConfig, EffectContext, EffectPlugin protocol
registry.py Plugin discovery and management
chain.py Ordered pipeline execution
performance.py Performance monitoring
controller.py NTFY command handler
legacy.py Original effects (noise, glitch, fade, firehose)
effects_plugins/ External effect plugins (noise, glitch, fade, firehose)
display.py Swappable display backends (TerminalDisplay, NullDisplay)
fetch.py RSS/Gutenberg fetching + cache load/save fetch.py RSS/Gutenberg fetching + cache load/save
ntfy.py NtfyPoller — standalone, zero internal deps ntfy.py NtfyPoller — standalone, zero internal deps
mic.py MicMonitor — standalone, graceful fallback mic.py MicMonitor — standalone, graceful fallback
@@ -96,7 +144,7 @@ engine/
frame.py scroll step calculation, timing frame.py scroll step calculation, timing
layers.py ticker zone, firehose, message overlay rendering layers.py ticker zone, firehose, message overlay rendering
eventbus.py thread-safe event publishing for decoupled communication eventbus.py thread-safe event publishing for decoupled communication
events.py event types and definitions events.py event types and definitions
controller.py coordinates ntfy/mic monitoring and event publishing controller.py coordinates ntfy/mic monitoring and event publishing
emitters.py background emitters for ntfy and mic emitters.py background emitters for ntfy and mic
types.py type definitions and dataclasses types.py type definitions and dataclasses

View File

@@ -32,18 +32,19 @@ from engine import config
from engine.terminal import CLR, CURSOR_OFF, CURSOR_ON, G_DIM, G_HI, RST, W_GHOST from engine.terminal import CLR, CURSOR_OFF, CURSOR_ON, G_DIM, G_HI, RST, W_GHOST
try: try:
CC_TOPIC = config.NTFY_CC_TOPIC CC_CMD_TOPIC = config.NTFY_CC_CMD_TOPIC
CC_RESP_TOPIC = config.NTFY_CC_RESP_TOPIC
except AttributeError: except AttributeError:
CC_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline_cc/json" CC_CMD_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd/json"
CC_RESP_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline_cc_resp/json"
TOPIC = CC_TOPIC
class NtfyResponsePoller: class NtfyResponsePoller:
"""Polls ntfy for command responses.""" """Polls ntfy for command responses."""
def __init__(self, topic_url: str, timeout: float = 10.0): def __init__(self, cmd_topic: str, resp_topic: str, timeout: float = 10.0):
self.topic_url = topic_url self.cmd_topic = cmd_topic
self.resp_topic = resp_topic
self.timeout = timeout self.timeout = timeout
self._last_id = None self._last_id = None
self._lock = threading.Lock() self._lock = threading.Lock()
@@ -51,7 +52,7 @@ class NtfyResponsePoller:
def _build_url(self) -> str: def _build_url(self) -> str:
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
parsed = urlparse(self.topic_url) parsed = urlparse(self.resp_topic)
params = parse_qs(parsed.query, keep_blank_values=True) params = parse_qs(parsed.query, keep_blank_values=True)
params["since"] = [self._last_id if self._last_id else "20s"] params["since"] = [self._last_id if self._last_id else "20s"]
new_query = urlencode({k: v[0] for k, v in params.items()}) new_query = urlencode({k: v[0] for k, v in params.items()})
@@ -59,7 +60,7 @@ class NtfyResponsePoller:
def send_and_wait(self, cmd: str) -> str: def send_and_wait(self, cmd: str) -> str:
"""Send command and wait for response.""" """Send command and wait for response."""
url = self.topic_url.replace("/json", "") url = self.cmd_topic.replace("/json", "")
data = cmd.encode("utf-8") data = cmd.encode("utf-8")
req = urllib.request.Request( req = urllib.request.Request(
@@ -77,9 +78,9 @@ class NtfyResponsePoller:
except Exception as e: except Exception as e:
return f"Error sending command: {e}" return f"Error sending command: {e}"
return self._wait_for_response() return self._wait_for_response(cmd)
def _wait_for_response(self) -> str: def _wait_for_response(self, expected_cmd: str = "") -> str:
"""Poll for response message.""" """Poll for response message."""
start = time.time() start = time.time()
while time.time() - start < self.timeout: while time.time() - start < self.timeout:
@@ -127,7 +128,8 @@ def print_header():
f" \033[1;38;5;231m║\033[0m \033[1;38;5;82mMAINLINE\033[0m \033[3;38;5;245mCommand Center\033[0m \033[1;38;5;231m ║\033[0m" f" \033[1;38;5;231m║\033[0m \033[1;38;5;82mMAINLINE\033[0m \033[3;38;5;245mCommand Center\033[0m \033[1;38;5;231m ║\033[0m"
) )
print(f" \033[1;38;5;231m╚{'' * (w - 6)}\033[0m") print(f" \033[1;38;5;231m╚{'' * (w - 6)}\033[0m")
print(f" \033[2;38;5;37mTopic: {TOPIC}\033[0m") print(f" \033[2;38;5;37mCMD: {CC_CMD_TOPIC.split('/')[-2]}\033[0m")
print(f" \033[2;38;5;37mRESP: {CC_RESP_TOPIC.split('/')[-2]}\033[0m")
print() print()
@@ -151,7 +153,7 @@ def interactive_mode():
import readline import readline
print_header() print_header()
poller = NtfyResponsePoller(TOPIC) poller = NtfyResponsePoller(CC_CMD_TOPIC, CC_RESP_TOPIC)
print(f" \033[38;5;245mType /help for commands, /quit to exit\033[0m") print(f" \033[38;5;245mType /help for commands, /quit to exit\033[0m")
print() print()
@@ -182,6 +184,7 @@ def interactive_mode():
print(f"\n \033[1;38;5;196m⚠ Commands must start with /{RST}\n") print(f"\n \033[1;38;5;196m⚠ Commands must start with /{RST}\n")
print(CURSOR_ON, end="") print(CURSOR_ON, end="")
return 0
def main(): def main():
@@ -206,12 +209,20 @@ def main():
args = parser.parse_args() args = parser.parse_args()
if args.command is None: if args.command is None:
interactive_mode() return interactive_mode()
return
poller = NtfyResponsePoller(TOPIC) poller = NtfyResponsePoller(CC_CMD_TOPIC, CC_RESP_TOPIC)
if args.watch and "/effects stats" in args.command: if args.watch and "/effects stats" in args.command:
import signal
def handle_sigterm(*_):
print(f"\n \033[1;38;5;245mStopped watching{RST}")
print(CURSOR_ON, end="")
sys.exit(0)
signal.signal(signal.SIGTERM, handle_sigterm)
print_header() print_header()
print(f" \033[38;5;245mWatching /effects stats (Ctrl+C to exit)...{RST}\n") print(f" \033[38;5;245mWatching /effects stats (Ctrl+C to exit)...{RST}\n")
try: try:
@@ -227,10 +238,12 @@ def main():
time.sleep(2) time.sleep(2)
except KeyboardInterrupt: except KeyboardInterrupt:
print(f"\n \033[1;38;5;245mStopped watching{RST}") print(f"\n \033[1;38;5;245mStopped watching{RST}")
return return 0
return 0
result = poller.send_and_wait(args.command) result = poller.send_and_wait(args.command)
print(result) print(result)
return 0
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -11,10 +11,8 @@ import time
import tty import tty
from engine import config, render from engine import config, render
from engine.controller import StreamController
from engine.fetch import fetch_all, fetch_poetry, load_cache, save_cache from engine.fetch import fetch_all, fetch_poetry, load_cache, save_cache
from engine.mic import MicMonitor
from engine.ntfy import NtfyPoller
from engine.scroll import stream
from engine.terminal import ( from engine.terminal import (
CLR, CLR,
CURSOR_OFF, CURSOR_OFF,
@@ -363,6 +361,8 @@ def main():
signal.signal(signal.SIGINT, handle_sigint) signal.signal(signal.SIGINT, handle_sigint)
StreamController.warmup_topics()
w = tw() w = tw()
print(CLR, end="") print(CLR, end="")
print(CURSOR_OFF, end="") print(CURSOR_OFF, end="")
@@ -419,9 +419,10 @@ def main():
sys.exit(1) sys.exit(1)
print() print()
mic = MicMonitor(threshold_db=config.MIC_THRESHOLD_DB) controller = StreamController()
mic_ok = mic.start() mic_ok, ntfy_ok = controller.initialize_sources()
if mic.available:
if controller.mic and controller.mic.available:
boot_ln( boot_ln(
"Microphone", "Microphone",
"ACTIVE" "ACTIVE"
@@ -430,12 +431,6 @@ def main():
bool(mic_ok), bool(mic_ok),
) )
ntfy = NtfyPoller(
config.NTFY_TOPIC,
reconnect_delay=config.NTFY_RECONNECT_DELAY,
display_secs=config.MESSAGE_DISPLAY_SECS,
)
ntfy_ok = ntfy.start()
boot_ln("ntfy", "LISTENING" if ntfy_ok else "OFFLINE", ntfy_ok) boot_ln("ntfy", "LISTENING" if ntfy_ok else "OFFLINE", ntfy_ok)
if config.FIREHOSE: if config.FIREHOSE:
@@ -448,7 +443,7 @@ def main():
print() print()
time.sleep(0.4) time.sleep(0.4)
stream(items, ntfy, mic) controller.run(items)
print() print()
print(f" {W_GHOST}{'' * (tw() - 4)}{RST}") print(f" {W_GHOST}{'' * (tw() - 4)}{RST}")

View File

@@ -105,7 +105,8 @@ class Config:
firehose: bool = False firehose: bool = False
ntfy_topic: str = "https://ntfy.sh/klubhaus_terminal_mainline/json" ntfy_topic: str = "https://ntfy.sh/klubhaus_terminal_mainline/json"
ntfy_cc_topic: str = "https://ntfy.sh/klubhaus_terminal_mainline_cc/json" ntfy_cc_cmd_topic: str = "https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd/json"
ntfy_cc_resp_topic: str = "https://ntfy.sh/klubhaus_terminal_mainline_cc_resp/json"
ntfy_reconnect_delay: int = 5 ntfy_reconnect_delay: int = 5
message_display_secs: int = 30 message_display_secs: int = 30
@@ -149,7 +150,8 @@ class Config:
mode="poetry" if "--poetry" in argv or "-p" in argv else "news", mode="poetry" if "--poetry" in argv or "-p" in argv else "news",
firehose="--firehose" in argv, firehose="--firehose" in argv,
ntfy_topic="https://ntfy.sh/klubhaus_terminal_mainline/json", ntfy_topic="https://ntfy.sh/klubhaus_terminal_mainline/json",
ntfy_cc_topic="https://ntfy.sh/klubhaus_terminal_mainline_cc/json", ntfy_cc_cmd_topic="https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd/json",
ntfy_cc_resp_topic="https://ntfy.sh/klubhaus_terminal_mainline_cc_resp/json",
ntfy_reconnect_delay=5, ntfy_reconnect_delay=5,
message_display_secs=30, message_display_secs=30,
font_dir=font_dir, font_dir=font_dir,
@@ -195,7 +197,8 @@ FIREHOSE = "--firehose" in sys.argv
# ─── NTFY MESSAGE QUEUE ────────────────────────────────── # ─── NTFY MESSAGE QUEUE ──────────────────────────────────
NTFY_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline/json" NTFY_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline/json"
NTFY_CC_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline_cc/json" NTFY_CC_CMD_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd/json"
NTFY_CC_RESP_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline_cc_resp/json"
NTFY_RECONNECT_DELAY = 5 # seconds before reconnecting after a dropped stream NTFY_RECONNECT_DELAY = 5 # seconds before reconnecting after a dropped stream
MESSAGE_DISPLAY_SECS = 30 # how long a message holds the screen MESSAGE_DISPLAY_SECS = 30 # how long a message holds the screen

View File

@@ -14,6 +14,8 @@ from engine.scroll import stream
class StreamController: class StreamController:
"""Controls the stream lifecycle - initializes sources and runs the stream.""" """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): def __init__(self, config: Config | None = None, event_bus: EventBus | None = None):
self.config = config or get_config() self.config = config or get_config()
self.event_bus = event_bus self.event_bus = event_bus
@@ -21,6 +23,37 @@ class StreamController:
self.ntfy: NtfyPoller | None = None self.ntfy: NtfyPoller | None = None
self.ntfy_cc: 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]: def initialize_sources(self) -> tuple[bool, bool]:
"""Initialize microphone and ntfy sources. """Initialize microphone and ntfy sources.
@@ -38,7 +71,7 @@ class StreamController:
ntfy_ok = self.ntfy.start() ntfy_ok = self.ntfy.start()
self.ntfy_cc = NtfyPoller( self.ntfy_cc = NtfyPoller(
self.config.ntfy_cc_topic, self.config.ntfy_cc_cmd_topic,
reconnect_delay=self.config.ntfy_reconnect_delay, reconnect_delay=self.config.ntfy_reconnect_delay,
display_secs=5, display_secs=5,
) )
@@ -57,7 +90,7 @@ class StreamController:
response = handle_effects_command(cmd) response = handle_effects_command(cmd)
topic_url = self.config.ntfy_cc_topic.replace("/json", "") topic_url = self.config.ntfy_cc_resp_topic.replace("/json", "")
data = response.encode("utf-8") data = response.encode("utf-8")
req = urllib.request.Request( req = urllib.request.Request(
topic_url, topic_url,

102
engine/display.py Normal file
View File

@@ -0,0 +1,102 @@
"""
Display output abstraction - allows swapping output backends.
Protocol:
- init(width, height): Initialize display with terminal dimensions
- show(buffer): Render buffer (list of strings) to display
- clear(): Clear the display
- cleanup(): Shutdown display
"""
import time
from typing import Protocol
class Display(Protocol):
"""Protocol for display backends."""
def init(self, width: int, height: int) -> None:
"""Initialize display with dimensions."""
...
def show(self, buffer: list[str]) -> None:
"""Show buffer on display."""
...
def clear(self) -> None:
"""Clear display."""
...
def cleanup(self) -> None:
"""Shutdown display."""
...
def get_monitor():
"""Get the performance monitor."""
try:
from engine.effects.performance import get_monitor as _get_monitor
return _get_monitor()
except Exception:
return None
class TerminalDisplay:
"""ANSI terminal display backend."""
def __init__(self):
self.width = 80
self.height = 24
def init(self, width: int, height: int) -> None:
from engine.terminal import CURSOR_OFF
self.width = width
self.height = height
print(CURSOR_OFF, end="", flush=True)
def show(self, buffer: list[str]) -> None:
import sys
t0 = time.perf_counter()
sys.stdout.buffer.write("".join(buffer).encode())
sys.stdout.flush()
elapsed_ms = (time.perf_counter() - t0) * 1000
monitor = get_monitor()
if monitor:
chars_in = sum(len(line) for line in buffer)
monitor.record_effect("terminal_display", elapsed_ms, chars_in, chars_in)
def clear(self) -> None:
from engine.terminal import CLR
print(CLR, end="", flush=True)
def cleanup(self) -> None:
from engine.terminal import CURSOR_ON
print(CURSOR_ON, end="", flush=True)
class NullDisplay:
"""Headless/null display - discards all output."""
def init(self, width: int, height: int) -> None:
self.width = width
self.height = height
def show(self, buffer: list[str]) -> None:
monitor = get_monitor()
if monitor:
t0 = time.perf_counter()
chars_in = sum(len(line) for line in buffer)
elapsed_ms = (time.perf_counter() - t0) * 1000
monitor.record_effect("null_display", elapsed_ms, chars_in, chars_in)
def clear(self) -> None:
pass
def cleanup(self) -> None:
pass

View File

@@ -52,6 +52,12 @@ def handle_effects_command(cmd: str) -> str:
if parts[1] == "stats": if parts[1] == "stats":
return _format_stats() return _format_stats()
if parts[1] == "reorder" and len(parts) >= 3:
new_order = parts[2].split(",")
if chain and chain.reorder(new_order):
return f"Reordered pipeline: {new_order}"
return "Failed to reorder pipeline"
if len(parts) < 3: if len(parts) < 3:
return "Usage: /effects <name> on|off|intensity <value>" return "Usage: /effects <name> on|off|intensity <value>"
@@ -81,12 +87,6 @@ def handle_effects_command(cmd: str) -> str:
except ValueError: except ValueError:
return "Invalid intensity value" return "Invalid intensity value"
if action == "reorder" and len(parts) >= 3:
new_order = parts[2].split(",")
if chain and chain.reorder(new_order):
return f"Reordered pipeline: {new_order}"
return "Failed to reorder pipeline"
return f"Unknown action: {action}" return f"Unknown action: {action}"

View File

@@ -4,10 +4,16 @@ Orchestrates viewport, frame timing, and layers.
""" """
import random import random
import sys
import time import time
from engine import config from engine import config
from engine.display import (
Display,
TerminalDisplay,
)
from engine.display import (
get_monitor as _get_display_monitor,
)
from engine.frame import calculate_scroll_step from engine.frame import calculate_scroll_step
from engine.layers import ( from engine.layers import (
apply_glitch, apply_glitch,
@@ -16,24 +22,24 @@ from engine.layers import (
render_message_overlay, render_message_overlay,
render_ticker_zone, render_ticker_zone,
) )
from engine.terminal import CLR
from engine.viewport import th, tw from engine.viewport import th, tw
USE_EFFECT_CHAIN = True USE_EFFECT_CHAIN = True
def stream(items, ntfy_poller, mic_monitor): def stream(items, ntfy_poller, mic_monitor, display: Display | None = None):
"""Main render loop with four layers: message, ticker, scroll motion, firehose.""" """Main render loop with four layers: message, ticker, scroll motion, firehose."""
if display is None:
display = TerminalDisplay()
random.shuffle(items) random.shuffle(items)
pool = list(items) pool = list(items)
seen = set() seen = set()
queued = 0 queued = 0
time.sleep(0.5) time.sleep(0.5)
sys.stdout.write(CLR)
sys.stdout.flush()
w, h = tw(), th() w, h = tw(), th()
display.init(w, h)
display.clear()
fh = config.FIREHOSE_H if config.FIREHOSE else 0 fh = config.FIREHOSE_H if config.FIREHOSE else 0
ticker_view_h = h - fh ticker_view_h = h - fh
GAP = 3 GAP = 3
@@ -97,6 +103,7 @@ def stream(items, ntfy_poller, mic_monitor):
buf.extend(ticker_buf) buf.extend(ticker_buf)
mic_excess = mic_monitor.excess mic_excess = mic_monitor.excess
render_start = time.perf_counter()
if USE_EFFECT_CHAIN: if USE_EFFECT_CHAIN:
buf = process_effects( buf = process_effects(
@@ -119,12 +126,16 @@ def stream(items, ntfy_poller, mic_monitor):
if msg_overlay: if msg_overlay:
buf.extend(msg_overlay) buf.extend(msg_overlay)
sys.stdout.buffer.write("".join(buf).encode()) render_elapsed = (time.perf_counter() - render_start) * 1000
sys.stdout.flush() monitor = _get_display_monitor()
if monitor:
chars = sum(len(line) for line in buf)
monitor.record_effect("render", render_elapsed, chars, chars)
display.show(buf)
elapsed = time.monotonic() - t0 elapsed = time.monotonic() - t0
time.sleep(max(0, config.FRAME_DT - elapsed)) time.sleep(max(0, config.FRAME_DT - elapsed))
frame_number += 1 frame_number += 1
sys.stdout.write(CLR) display.cleanup()
sys.stdout.flush()

View File

@@ -25,12 +25,19 @@ run = "uv run mainline.py"
run-poetry = "uv run mainline.py --poetry" run-poetry = "uv run mainline.py --poetry"
run-firehose = "uv run mainline.py --firehose" run-firehose = "uv run mainline.py --firehose"
daemon = "nohup uv run mainline.py > /dev/null 2>&1 &"
daemon-stop = "pkill -f 'uv run mainline.py' 2>/dev/null || true"
daemon-restart = "mise run daemon-stop && sleep 2 && mise run daemon"
# ===================== # =====================
# Command & Control # Command & Control
# ===================== # =====================
cmd = "uv run cmdline.py" cmd = "uv run cmdline.py"
cmd-stats = "uv run cmdline.py -w /effects stats" cmd-stats = "bash -c 'uv run cmdline.py -w \"/effects stats\"';:"
# Initialize ntfy topics (warm up before first use - also done automatically by mainline)
topics-init = "curl -s -d 'init' https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd > /dev/null && curl -s -d 'init' https://ntfy.sh/klubhaus_terminal_mainline_cc_resp > /dev/null && curl -s -d 'init' https://ntfy.sh/klubhaus_terminal_mainline > /dev/null"
# ===================== # =====================
# Environment # Environment
@@ -38,18 +45,21 @@ cmd-stats = "uv run cmdline.py -w /effects stats"
sync = "uv sync" sync = "uv sync"
sync-all = "uv sync --all-extras" sync-all = "uv sync --all-extras"
install = "uv sync" install = "mise run sync"
install-dev = "uv sync --group dev" install-dev = "mise run sync && uv sync --group dev"
bootstrap = "uv sync && uv run mainline.py --help" bootstrap = "uv sync && uv run mainline.py --help"
clean = "rm -rf .venv htmlcov .coverage tests/.pytest_cache" clean = "rm -rf .venv htmlcov .coverage tests/.pytest_cache .mainline_cache_*.json nohup.out"
# Aggressive cleanup - removes all generated files, caches, and venv
clobber = "git clean -fdx && rm -rf .venv htmlcov .coverage tests/.pytest_cache .mainline_cache_*.json nohup.out"
# ===================== # =====================
# CI/CD # CI/CD
# ===================== # =====================
ci = "uv sync --group dev && uv run pytest --cov=engine --cov-report=term-missing --cov-report=xml" ci = "mise run topics-init && uv sync --group dev && uv run pytest --cov=engine --cov-report=term-missing --cov-report=xml"
ci-lint = "uv run ruff check engine/ mainline.py" ci-lint = "uv run ruff check engine/ mainline.py"
# ===================== # =====================

View File

@@ -83,3 +83,35 @@ class TestStreamControllerCleanup:
controller.cleanup() controller.cleanup()
mock_mic_instance.stop.assert_called_once() mock_mic_instance.stop.assert_called_once()
class TestStreamControllerWarmup:
"""Tests for StreamController topic warmup."""
def test_warmup_topics_idempotent(self):
"""warmup_topics can be called multiple times."""
StreamController._topics_warmed = False
with patch("urllib.request.urlopen") as mock_urlopen:
StreamController.warmup_topics()
StreamController.warmup_topics()
assert mock_urlopen.call_count >= 3
def test_warmup_topics_sets_flag(self):
"""warmup_topics sets the warmed flag."""
StreamController._topics_warmed = False
with patch("urllib.request.urlopen"):
StreamController.warmup_topics()
assert StreamController._topics_warmed is True
def test_warmup_topics_skips_after_first(self):
"""warmup_topics skips after first call."""
StreamController._topics_warmed = True
with patch("urllib.request.urlopen") as mock_urlopen:
StreamController.warmup_topics()
mock_urlopen.assert_not_called()

79
tests/test_display.py Normal file
View File

@@ -0,0 +1,79 @@
"""
Tests for engine.display module.
"""
from engine.display import NullDisplay, TerminalDisplay
class TestDisplayProtocol:
"""Test that display backends satisfy the Display protocol."""
def test_terminal_display_is_display(self):
"""TerminalDisplay satisfies Display protocol."""
display = TerminalDisplay()
assert hasattr(display, "init")
assert hasattr(display, "show")
assert hasattr(display, "clear")
assert hasattr(display, "cleanup")
def test_null_display_is_display(self):
"""NullDisplay satisfies Display protocol."""
display = NullDisplay()
assert hasattr(display, "init")
assert hasattr(display, "show")
assert hasattr(display, "clear")
assert hasattr(display, "cleanup")
class TestTerminalDisplay:
"""Tests for TerminalDisplay class."""
def test_init_sets_dimensions(self):
"""init stores terminal dimensions."""
display = TerminalDisplay()
display.init(80, 24)
assert display.width == 80
assert display.height == 24
def test_show_returns_none(self):
"""show returns None after writing to stdout."""
display = TerminalDisplay()
display.width = 80
display.height = 24
display.show(["line1", "line2"])
def test_clear_does_not_error(self):
"""clear works without error."""
display = TerminalDisplay()
display.clear()
def test_cleanup_does_not_error(self):
"""cleanup works without error."""
display = TerminalDisplay()
display.cleanup()
class TestNullDisplay:
"""Tests for NullDisplay class."""
def test_init_stores_dimensions(self):
"""init stores dimensions."""
display = NullDisplay()
display.init(100, 50)
assert display.width == 100
assert display.height == 50
def test_show_does_nothing(self):
"""show discards buffer without error."""
display = NullDisplay()
display.show(["line1", "line2", "line3"])
def test_clear_does_nothing(self):
"""clear does nothing."""
display = NullDisplay()
display.clear()
def test_cleanup_does_nothing(self):
"""cleanup does nothing."""
display = NullDisplay()
display.cleanup()

View File

@@ -0,0 +1,117 @@
"""
Tests for engine.effects.controller module.
"""
from unittest.mock import MagicMock, patch
from engine.effects.controller import (
handle_effects_command,
set_effect_chain_ref,
)
class TestHandleEffectsCommand:
"""Tests for handle_effects_command function."""
def test_list_effects(self):
"""list command returns formatted effects list."""
with patch("engine.effects.controller.get_registry") as mock_registry:
mock_plugin = MagicMock()
mock_plugin.config.enabled = True
mock_plugin.config.intensity = 0.5
mock_registry.return_value.list_all.return_value = {"noise": mock_plugin}
with patch("engine.effects.controller._get_effect_chain") as mock_chain:
mock_chain.return_value.get_order.return_value = ["noise"]
result = handle_effects_command("/effects list")
assert "noise: ON" in result
assert "intensity=0.5" in result
def test_enable_effect(self):
"""enable command calls registry.enable."""
with patch("engine.effects.controller.get_registry") as mock_registry:
mock_plugin = MagicMock()
mock_registry.return_value.get.return_value = mock_plugin
mock_registry.return_value.list_all.return_value = {"noise": mock_plugin}
result = handle_effects_command("/effects noise on")
assert "Enabled: noise" in result
mock_registry.return_value.enable.assert_called_once_with("noise")
def test_disable_effect(self):
"""disable command calls registry.disable."""
with patch("engine.effects.controller.get_registry") as mock_registry:
mock_plugin = MagicMock()
mock_registry.return_value.get.return_value = mock_plugin
mock_registry.return_value.list_all.return_value = {"noise": mock_plugin}
result = handle_effects_command("/effects noise off")
assert "Disabled: noise" in result
mock_registry.return_value.disable.assert_called_once_with("noise")
def test_set_intensity(self):
"""intensity command sets plugin intensity."""
with patch("engine.effects.controller.get_registry") as mock_registry:
mock_plugin = MagicMock()
mock_plugin.config.intensity = 0.5
mock_registry.return_value.get.return_value = mock_plugin
mock_registry.return_value.list_all.return_value = {"noise": mock_plugin}
result = handle_effects_command("/effects noise intensity 0.8")
assert "intensity to 0.8" in result
assert mock_plugin.config.intensity == 0.8
def test_invalid_intensity_range(self):
"""intensity outside 0.0-1.0 returns error."""
with patch("engine.effects.controller.get_registry") as mock_registry:
mock_plugin = MagicMock()
mock_registry.return_value.get.return_value = mock_plugin
mock_registry.return_value.list_all.return_value = {"noise": mock_plugin}
result = handle_effects_command("/effects noise intensity 1.5")
assert "between 0.0 and 1.0" in result
def test_reorder_pipeline(self):
"""reorder command calls chain.reorder."""
with patch("engine.effects.controller.get_registry") as mock_registry:
mock_registry.return_value.list_all.return_value = {}
with patch("engine.effects.controller._get_effect_chain") as mock_chain:
mock_chain_instance = MagicMock()
mock_chain_instance.reorder.return_value = True
mock_chain.return_value = mock_chain_instance
result = handle_effects_command("/effects reorder noise,fade")
assert "Reordered pipeline" in result
mock_chain_instance.reorder.assert_called_once_with(["noise", "fade"])
def test_unknown_command(self):
"""unknown command returns error."""
result = handle_effects_command("/unknown")
assert "Unknown command" in result
def test_non_effects_command(self):
"""non-effects command returns error."""
result = handle_effects_command("not a command")
assert "Unknown command" in result
class TestSetEffectChainRef:
"""Tests for set_effect_chain_ref function."""
def test_sets_global_ref(self):
"""set_effect_chain_ref updates global reference."""
mock_chain = MagicMock()
set_effect_chain_ref(mock_chain)
from engine.effects.controller import _get_effect_chain
result = _get_effect_chain()
assert result == mock_chain

View File

@@ -8,11 +8,11 @@ import urllib.request
class TestNtfyTopics: class TestNtfyTopics:
def test_cc_topic_exists_and_writable(self): def test_cc_cmd_topic_exists_and_writable(self):
"""Verify C&C topic exists and accepts messages.""" """Verify C&C CMD topic exists and accepts messages."""
from engine.config import NTFY_CC_TOPIC from engine.config import NTFY_CC_CMD_TOPIC
topic_url = NTFY_CC_TOPIC.replace("/json", "") topic_url = NTFY_CC_CMD_TOPIC.replace("/json", "")
test_message = f"test_{int(time.time())}" test_message = f"test_{int(time.time())}"
req = urllib.request.Request( req = urllib.request.Request(
@@ -29,7 +29,30 @@ class TestNtfyTopics:
with urllib.request.urlopen(req, timeout=10) as resp: with urllib.request.urlopen(req, timeout=10) as resp:
assert resp.status == 200 assert resp.status == 200
except Exception as e: except Exception as e:
raise AssertionError(f"Failed to write to C&C topic: {e}") from e raise AssertionError(f"Failed to write to C&C CMD topic: {e}") from e
def test_cc_resp_topic_exists_and_writable(self):
"""Verify C&C RESP topic exists and accepts messages."""
from engine.config import NTFY_CC_RESP_TOPIC
topic_url = NTFY_CC_RESP_TOPIC.replace("/json", "")
test_message = f"test_{int(time.time())}"
req = urllib.request.Request(
topic_url,
data=test_message.encode("utf-8"),
headers={
"User-Agent": "mainline-test/0.1",
"Content-Type": "text/plain",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
assert resp.status == 200
except Exception as e:
raise AssertionError(f"Failed to write to C&C RESP topic: {e}") from e
def test_message_topic_exists_and_writable(self): def test_message_topic_exists_and_writable(self):
"""Verify message topic exists and accepts messages.""" """Verify message topic exists and accepts messages."""
@@ -54,12 +77,12 @@ class TestNtfyTopics:
except Exception as e: except Exception as e:
raise AssertionError(f"Failed to write to message topic: {e}") from e raise AssertionError(f"Failed to write to message topic: {e}") from e
def test_cc_topic_readable(self): def test_cc_cmd_topic_readable(self):
"""Verify we can read messages from C&C topic.""" """Verify we can read messages from C&C CMD topic."""
from engine.config import NTFY_CC_TOPIC from engine.config import NTFY_CC_CMD_TOPIC
test_message = f"integration_test_{int(time.time())}" test_message = f"integration_test_{int(time.time())}"
topic_url = NTFY_CC_TOPIC.replace("/json", "") topic_url = NTFY_CC_CMD_TOPIC.replace("/json", "")
req = urllib.request.Request( req = urllib.request.Request(
topic_url, topic_url,
@@ -74,11 +97,11 @@ class TestNtfyTopics:
try: try:
urllib.request.urlopen(req, timeout=10) urllib.request.urlopen(req, timeout=10)
except Exception as e: except Exception as e:
raise AssertionError(f"Failed to write to C&C topic: {e}") from e raise AssertionError(f"Failed to write to C&C CMD topic: {e}") from e
time.sleep(1) time.sleep(1)
poll_url = f"{NTFY_CC_TOPIC}?poll=1&limit=1" poll_url = f"{NTFY_CC_CMD_TOPIC}?poll=1&limit=1"
req = urllib.request.Request( req = urllib.request.Request(
poll_url, poll_url,
headers={"User-Agent": "mainline-test/0.1"}, headers={"User-Agent": "mainline-test/0.1"},
@@ -91,11 +114,14 @@ class TestNtfyTopics:
data = json.loads(body.split("\n")[0]) data = json.loads(body.split("\n")[0])
assert isinstance(data, dict) assert isinstance(data, dict)
except Exception as e: except Exception as e:
raise AssertionError(f"Failed to read from C&C topic: {e}") from e raise AssertionError(f"Failed to read from C&C CMD topic: {e}") from e
def test_topics_are_different(self): def test_topics_are_different(self):
"""Verify C&C and message topics are different.""" """Verify C&C CMD/RESP and message topics are different."""
from engine.config import NTFY_CC_TOPIC, NTFY_TOPIC from engine.config import NTFY_CC_CMD_TOPIC, NTFY_CC_RESP_TOPIC, NTFY_TOPIC
assert NTFY_CC_TOPIC != NTFY_TOPIC assert NTFY_CC_CMD_TOPIC != NTFY_TOPIC
assert "_cc" in NTFY_CC_TOPIC assert NTFY_CC_RESP_TOPIC != NTFY_TOPIC
assert NTFY_CC_CMD_TOPIC != NTFY_CC_RESP_TOPIC
assert "_cc_cmd" in NTFY_CC_CMD_TOPIC
assert "_cc_resp" in NTFY_CC_RESP_TOPIC