forked from genewildish/Mainline
feat(cmdline): C&C with separate topics and rich output
This commit is contained in:
153
engine/app.py
153
engine/app.py
@@ -11,10 +11,8 @@ import time
|
||||
import tty
|
||||
|
||||
from engine import config, render
|
||||
from engine.controller import StreamController
|
||||
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 (
|
||||
CLR,
|
||||
CURSOR_OFF,
|
||||
@@ -29,30 +27,6 @@ from engine.terminal import (
|
||||
slow_print,
|
||||
tw,
|
||||
)
|
||||
from engine.websocket_display import WebSocketDisplay
|
||||
|
||||
|
||||
def _get_display():
|
||||
"""Get the appropriate display(s) based on config."""
|
||||
from engine.display import MultiDisplay, TerminalDisplay
|
||||
|
||||
displays = []
|
||||
|
||||
if config.DISPLAY in ("terminal", "both"):
|
||||
displays.append(TerminalDisplay())
|
||||
|
||||
if config.DISPLAY in ("websocket", "both") or config.WEBSOCKET:
|
||||
ws = WebSocketDisplay(host="0.0.0.0", port=config.WEBSOCKET_PORT)
|
||||
ws.start_server()
|
||||
ws.start_http_server()
|
||||
displays.append(ws)
|
||||
|
||||
if not displays:
|
||||
return None
|
||||
if len(displays) == 1:
|
||||
return displays[0]
|
||||
return MultiDisplay(displays)
|
||||
|
||||
|
||||
TITLE = [
|
||||
" ███╗ ███╗ █████╗ ██╗███╗ ██╗██╗ ██╗███╗ ██╗███████╗",
|
||||
@@ -273,6 +247,110 @@ def pick_font_face():
|
||||
print()
|
||||
|
||||
|
||||
def pick_effects_config():
|
||||
"""Interactive picker for configuring effects pipeline."""
|
||||
import effects_plugins
|
||||
from engine.effects import get_effect_chain, get_registry
|
||||
|
||||
effects_plugins.discover_plugins()
|
||||
|
||||
registry = get_registry()
|
||||
chain = get_effect_chain()
|
||||
chain.set_order(["noise", "fade", "glitch", "firehose"])
|
||||
|
||||
effects = list(registry.list_all().values())
|
||||
if not effects:
|
||||
return
|
||||
|
||||
selected = 0
|
||||
editing_intensity = False
|
||||
intensity_value = 1.0
|
||||
|
||||
def _draw_effects_picker():
|
||||
w = tw()
|
||||
print(CLR, end="")
|
||||
print("\033[1;1H", end="")
|
||||
print(" \033[1;38;5;231mEFFECTS CONFIG\033[0m")
|
||||
print(f" \033[2;38;5;37m{'─' * (w - 4)}\033[0m")
|
||||
print()
|
||||
|
||||
for i, effect in enumerate(effects):
|
||||
prefix = " > " if i == selected else " "
|
||||
marker = "[*]" if effect.config.enabled else "[ ]"
|
||||
if editing_intensity and i == selected:
|
||||
print(
|
||||
f"{prefix}{marker} \033[1;38;5;82m{effect.name}\033[0m: intensity={intensity_value:.2f} (use +/- to adjust, Enter to confirm)"
|
||||
)
|
||||
else:
|
||||
print(
|
||||
f"{prefix}{marker} {effect.name}: intensity={effect.config.intensity:.2f}"
|
||||
)
|
||||
|
||||
print()
|
||||
print(f" \033[2;38;5;37m{'─' * (w - 4)}\033[0m")
|
||||
print(
|
||||
" \033[38;5;245mControls: space=toggle on/off | +/-=adjust intensity | arrows=move | Enter=next effect | q=done\033[0m"
|
||||
)
|
||||
|
||||
def _read_effects_key():
|
||||
ch = sys.stdin.read(1)
|
||||
if ch == "\x03":
|
||||
return "interrupt"
|
||||
if ch in ("\r", "\n"):
|
||||
return "enter"
|
||||
if ch == " ":
|
||||
return "toggle"
|
||||
if ch == "q":
|
||||
return "quit"
|
||||
if ch == "+" or ch == "=":
|
||||
return "up"
|
||||
if ch == "-" or ch == "_":
|
||||
return "down"
|
||||
if ch == "\x1b":
|
||||
c1 = sys.stdin.read(1)
|
||||
if c1 != "[":
|
||||
return None
|
||||
c2 = sys.stdin.read(1)
|
||||
if c2 == "A":
|
||||
return "up"
|
||||
if c2 == "B":
|
||||
return "down"
|
||||
return None
|
||||
return None
|
||||
|
||||
if not sys.stdin.isatty():
|
||||
return
|
||||
|
||||
fd = sys.stdin.fileno()
|
||||
old_settings = termios.tcgetattr(fd)
|
||||
try:
|
||||
tty.setcbreak(fd)
|
||||
while True:
|
||||
_draw_effects_picker()
|
||||
key = _read_effects_key()
|
||||
|
||||
if key == "quit" or key == "enter":
|
||||
break
|
||||
elif key == "up" and editing_intensity:
|
||||
intensity_value = min(1.0, intensity_value + 0.1)
|
||||
effects[selected].config.intensity = intensity_value
|
||||
elif key == "down" and editing_intensity:
|
||||
intensity_value = max(0.0, intensity_value - 0.1)
|
||||
effects[selected].config.intensity = intensity_value
|
||||
elif key == "up":
|
||||
selected = max(0, selected - 1)
|
||||
intensity_value = effects[selected].config.intensity
|
||||
elif key == "down":
|
||||
selected = min(len(effects) - 1, selected + 1)
|
||||
intensity_value = effects[selected].config.intensity
|
||||
elif key == "toggle":
|
||||
effects[selected].config.enabled = not effects[selected].config.enabled
|
||||
elif key == "interrupt":
|
||||
raise KeyboardInterrupt
|
||||
finally:
|
||||
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
||||
|
||||
|
||||
def main():
|
||||
atexit.register(lambda: print(CURSOR_ON, end="", flush=True))
|
||||
|
||||
@@ -283,10 +361,13 @@ def main():
|
||||
|
||||
signal.signal(signal.SIGINT, handle_sigint)
|
||||
|
||||
StreamController.warmup_topics()
|
||||
|
||||
w = tw()
|
||||
print(CLR, end="")
|
||||
print(CURSOR_OFF, end="")
|
||||
pick_font_face()
|
||||
pick_effects_config()
|
||||
w = tw()
|
||||
print()
|
||||
time.sleep(0.4)
|
||||
@@ -338,9 +419,10 @@ def main():
|
||||
sys.exit(1)
|
||||
|
||||
print()
|
||||
mic = MicMonitor(threshold_db=config.MIC_THRESHOLD_DB)
|
||||
mic_ok = mic.start()
|
||||
if mic.available:
|
||||
controller = StreamController()
|
||||
mic_ok, ntfy_ok = controller.initialize_sources()
|
||||
|
||||
if controller.mic and controller.mic.available:
|
||||
boot_ln(
|
||||
"Microphone",
|
||||
"ACTIVE"
|
||||
@@ -349,12 +431,6 @@ def main():
|
||||
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)
|
||||
|
||||
if config.FIREHOSE:
|
||||
@@ -367,10 +443,7 @@ def main():
|
||||
print()
|
||||
time.sleep(0.4)
|
||||
|
||||
display = _get_display()
|
||||
stream(items, ntfy, mic, display)
|
||||
if display:
|
||||
display.cleanup()
|
||||
controller.run(items)
|
||||
|
||||
print()
|
||||
print(f" {W_GHOST}{'─' * (tw() - 4)}{RST}")
|
||||
|
||||
@@ -105,6 +105,8 @@ class Config:
|
||||
firehose: bool = False
|
||||
|
||||
ntfy_topic: str = "https://ntfy.sh/klubhaus_terminal_mainline/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
|
||||
message_display_secs: int = 30
|
||||
|
||||
@@ -152,6 +154,8 @@ class Config:
|
||||
mode="poetry" if "--poetry" in argv or "-p" in argv else "news",
|
||||
firehose="--firehose" in argv,
|
||||
ntfy_topic="https://ntfy.sh/klubhaus_terminal_mainline/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,
|
||||
message_display_secs=30,
|
||||
font_dir=font_dir,
|
||||
@@ -200,6 +204,8 @@ FIREHOSE = "--firehose" in sys.argv
|
||||
|
||||
# ─── NTFY MESSAGE QUEUE ──────────────────────────────────
|
||||
NTFY_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline/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
|
||||
MESSAGE_DISPLAY_SECS = 30 # how long a message holds the screen
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ Stream controller - manages input sources and orchestrates the render stream.
|
||||
"""
|
||||
|
||||
from engine.config import Config, get_config
|
||||
from engine.effects.controller import handle_effects_command
|
||||
from engine.eventbus import EventBus
|
||||
from engine.events import EventType, StreamEvent
|
||||
from engine.mic import MicMonitor
|
||||
@@ -24,11 +25,45 @@ def _get_display(config: Config):
|
||||
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.
|
||||
@@ -46,7 +81,38 @@ class StreamController:
|
||||
)
|
||||
ntfy_ok = self.ntfy.start()
|
||||
|
||||
return bool(mic_ok), ntfy_ok
|
||||
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."""
|
||||
|
||||
Reference in New Issue
Block a user