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:
98
AGENTS.md
98
AGENTS.md
@@ -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
|
||||||
|
|
||||||
|
|||||||
54
README.md
54
README.md
@@ -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
|
||||||
|
|||||||
45
cmdline.py
45
cmdline.py
@@ -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__":
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
102
engine/display.py
Normal 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
|
||||||
@@ -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}"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
|
||||||
|
|||||||
20
mise.toml
20
mise.toml
@@ -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"
|
||||||
|
|
||||||
# =====================
|
# =====================
|
||||||
|
|||||||
@@ -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
79
tests/test_display.py
Normal 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()
|
||||||
117
tests/test_effects_controller.py
Normal file
117
tests/test_effects_controller.py
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user