feat(cmdline): use C&C topic with response polling
- Rewrite cmdline to send commands via ntfy and wait for response - Add NtfyResponsePoller class for serial-port-like interface - Add integration tests for ntfy topics (test read/write) - Add NTFY_CC_TOPIC export to config
This commit is contained in:
156
cmdline.py
156
cmdline.py
@@ -5,24 +5,30 @@ Command-line utility for interacting with mainline via ntfy.
|
|||||||
Usage:
|
Usage:
|
||||||
python cmdline.py # Interactive TUI mode
|
python cmdline.py # Interactive TUI mode
|
||||||
python cmdline.py --help # Show help
|
python cmdline.py --help # Show help
|
||||||
python cmdline.py /effects list # Send single command
|
python cmdline.py /effects list # Send single command via ntfy
|
||||||
python cmdline.py /effects stats # Get performance stats
|
python cmdline.py /effects stats # Get performance stats via ntfy
|
||||||
|
python cmdline.py -w /effects stats # Watch mode (polls for stats)
|
||||||
|
|
||||||
The TUI mode provides:
|
The TUI mode provides:
|
||||||
- Arrow keys to navigate command history
|
- Arrow keys to navigate command history
|
||||||
- Tab completion for commands
|
- Tab completion for commands
|
||||||
- Auto-refresh for performance stats
|
- Auto-refresh for performance stats
|
||||||
|
|
||||||
|
C&C works like a serial port:
|
||||||
|
1. Send command to ntfy_cc_topic
|
||||||
|
2. Mainline receives, processes, responds to same topic
|
||||||
|
3. Cmdline polls for response
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
import threading
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from engine import config
|
from engine import config
|
||||||
from engine.effects.controller import handle_effects_command
|
|
||||||
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:
|
||||||
@@ -33,64 +39,70 @@ except AttributeError:
|
|||||||
TOPIC = CC_TOPIC
|
TOPIC = CC_TOPIC
|
||||||
|
|
||||||
|
|
||||||
def send_command(cmd: str) -> str:
|
class NtfyResponsePoller:
|
||||||
"""Send a command to the ntfy topic and return the response."""
|
"""Polls ntfy for command responses."""
|
||||||
if not cmd.startswith("/"):
|
|
||||||
return "Commands must start with /"
|
|
||||||
|
|
||||||
url = TOPIC.replace("/json", "")
|
def __init__(self, topic_url: str, timeout: float = 10.0):
|
||||||
data = cmd.encode("utf-8")
|
self.topic_url = topic_url
|
||||||
|
self.timeout = timeout
|
||||||
|
self._last_id = None
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
req = urllib.request.Request(
|
def _build_url(self) -> str:
|
||||||
url,
|
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
|
||||||
data=data,
|
|
||||||
headers={
|
|
||||||
"User-Agent": "mainline-cmdline/0.1",
|
|
||||||
"Content-Type": "text/plain",
|
|
||||||
},
|
|
||||||
method="POST",
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
parsed = urlparse(self.topic_url)
|
||||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
params = parse_qs(parsed.query, keep_blank_values=True)
|
||||||
return f"Command sent: {cmd}\n(Response would appear on mainline display)"
|
params["since"] = [self._last_id if self._last_id else "20s"]
|
||||||
except Exception as e:
|
new_query = urlencode({k: v[0] for k, v in params.items()})
|
||||||
return f"Error sending command: {e}"
|
return urlunparse(parsed._replace(query=new_query))
|
||||||
|
|
||||||
|
def send_and_wait(self, cmd: str) -> str:
|
||||||
|
"""Send command and wait for response."""
|
||||||
|
url = self.topic_url.replace("/json", "")
|
||||||
|
data = cmd.encode("utf-8")
|
||||||
|
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
data=data,
|
||||||
|
headers={
|
||||||
|
"User-Agent": "mainline-cmdline/0.1",
|
||||||
|
"Content-Type": "text/plain",
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
|
||||||
def local_command(cmd: str) -> str:
|
|
||||||
"""Handle command locally without sending to ntfy."""
|
|
||||||
if cmd.startswith("/effects"):
|
|
||||||
try:
|
try:
|
||||||
import effects_plugins
|
urllib.request.urlopen(req, timeout=5)
|
||||||
from engine.effects.registry import get_registry
|
|
||||||
from engine.effects.chain import EffectChain
|
|
||||||
from engine.effects.controller import set_effect_chain_ref
|
|
||||||
|
|
||||||
effects_plugins.discover_plugins()
|
|
||||||
registry = get_registry()
|
|
||||||
chain = EffectChain(registry)
|
|
||||||
chain.set_order(["noise", "fade", "glitch", "firehose"])
|
|
||||||
|
|
||||||
set_effect_chain_ref(chain)
|
|
||||||
|
|
||||||
from engine.effects.controller import handle_effects_command
|
|
||||||
|
|
||||||
return handle_effects_command(cmd)
|
|
||||||
except ImportError as e:
|
|
||||||
return f"Error: {e}\n(Try: pip install Pillow)"
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"Error: {type(e).__name__}: {e}"
|
return f"Error sending command: {e}"
|
||||||
if cmd == "/help":
|
|
||||||
return AVAILABLE_COMMANDS
|
return self._wait_for_response()
|
||||||
if cmd == "/quit" or cmd == "/exit":
|
|
||||||
return "GOODBYE"
|
def _wait_for_response(self) -> str:
|
||||||
return f"Unknown command: {cmd}"
|
"""Poll for response message."""
|
||||||
if cmd == "/help":
|
start = time.time()
|
||||||
return AVAILABLE_COMMANDS
|
while time.time() - start < self.timeout:
|
||||||
if cmd == "/quit" or cmd == "/exit":
|
try:
|
||||||
return "GOODBYE"
|
url = self._build_url()
|
||||||
return f"Unknown command: {cmd}"
|
req = urllib.request.Request(
|
||||||
|
url, headers={"User-Agent": "mainline-cmdline/0.1"}
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||||
|
for line in resp:
|
||||||
|
try:
|
||||||
|
data = json.loads(line.decode("utf-8", errors="replace"))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
if data.get("event") == "message":
|
||||||
|
self._last_id = data.get("id")
|
||||||
|
msg = data.get("message", "")
|
||||||
|
if msg:
|
||||||
|
return msg
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
time.sleep(0.5)
|
||||||
|
return "Timeout waiting for response"
|
||||||
|
|
||||||
|
|
||||||
AVAILABLE_COMMANDS = """Available commands:
|
AVAILABLE_COMMANDS = """Available commands:
|
||||||
@@ -102,9 +114,6 @@ AVAILABLE_COMMANDS = """Available commands:
|
|||||||
/effects stats - Show performance statistics
|
/effects stats - Show performance statistics
|
||||||
/help - Show this help
|
/help - Show this help
|
||||||
/quit - Exit
|
/quit - Exit
|
||||||
|
|
||||||
Local commands (don't require running mainline):
|
|
||||||
/effects * - All /effects commands work locally
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
@@ -124,9 +133,7 @@ def interactive_mode():
|
|||||||
import readline
|
import readline
|
||||||
|
|
||||||
print_header()
|
print_header()
|
||||||
|
poller = NtfyResponsePoller(TOPIC)
|
||||||
history = []
|
|
||||||
history_index = -1
|
|
||||||
|
|
||||||
print(f"{G_DIM}Type /help for available commands, /quit to exit{RST}")
|
print(f"{G_DIM}Type /help for available commands, /quit to exit{RST}")
|
||||||
print()
|
print()
|
||||||
@@ -142,20 +149,20 @@ def interactive_mode():
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
if cmd.startswith("/"):
|
if cmd.startswith("/"):
|
||||||
history.append(cmd)
|
|
||||||
history_index = len(history)
|
|
||||||
|
|
||||||
if cmd == "/quit" or cmd == "/exit":
|
if cmd == "/quit" or cmd == "/exit":
|
||||||
print(f"{G_DIM}Goodbye!{RST}")
|
print(f"{G_DIM}Goodbye!{RST}")
|
||||||
break
|
break
|
||||||
|
|
||||||
result = local_command(cmd)
|
if cmd == "/help":
|
||||||
|
print(f"\n{AVAILABLE_COMMANDS}\n")
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f"{G_DIM}Sending to mainline...{RST}")
|
||||||
|
result = poller.send_and_wait(cmd)
|
||||||
print(f"\n{result}\n")
|
print(f"\n{result}\n")
|
||||||
|
|
||||||
else:
|
else:
|
||||||
print(
|
print(f"{G_DIM}Commands must start with / - type /help{RST}\n")
|
||||||
f"{G_DIM}Commands must start with / - type /help for available commands{RST}\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
print(CURSOR_ON, end="")
|
print(CURSOR_ON, end="")
|
||||||
|
|
||||||
@@ -172,12 +179,6 @@ def main():
|
|||||||
default=None,
|
default=None,
|
||||||
help="Command to send (e.g., /effects list)",
|
help="Command to send (e.g., /effects list)",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
|
||||||
"--local",
|
|
||||||
"-l",
|
|
||||||
action="store_true",
|
|
||||||
help="Run command locally (no ntfy required)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--watch",
|
"--watch",
|
||||||
"-w",
|
"-w",
|
||||||
@@ -191,17 +192,14 @@ def main():
|
|||||||
interactive_mode()
|
interactive_mode()
|
||||||
return
|
return
|
||||||
|
|
||||||
if args.local:
|
poller = NtfyResponsePoller(TOPIC)
|
||||||
result = local_command(args.command)
|
|
||||||
print(result)
|
|
||||||
return
|
|
||||||
|
|
||||||
if args.watch and "/effects stats" in args.command:
|
if args.watch and "/effects stats" in args.command:
|
||||||
print_header()
|
print_header()
|
||||||
print(f"{G_DIM}Watching /effects stats (Ctrl+C to exit)...{RST}\n")
|
print(f"{G_DIM}Watching /effects stats (Ctrl+C to exit)...{RST}\n")
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
result = local_command(args.command)
|
result = poller.send_and_wait(args.command)
|
||||||
print(f"\033[2J\033[1;1H", end="")
|
print(f"\033[2J\033[1;1H", end="")
|
||||||
print(f"{G_HI}Performance Stats - {time.strftime('%H:%M:%S')}{RST}")
|
print(f"{G_HI}Performance Stats - {time.strftime('%H:%M:%S')}{RST}")
|
||||||
print(f"{G_DIM}{'─' * 40}{RST}")
|
print(f"{G_DIM}{'─' * 40}{RST}")
|
||||||
@@ -211,7 +209,7 @@ def main():
|
|||||||
print(f"\n{G_DIM}Stopped watching{RST}")
|
print(f"\n{G_DIM}Stopped watching{RST}")
|
||||||
return
|
return
|
||||||
|
|
||||||
result = send_command(args.command)
|
result = poller.send_and_wait(args.command)
|
||||||
print(result)
|
print(result)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -195,6 +195,7 @@ 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_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
|
||||||
|
|
||||||
|
|||||||
101
tests/test_ntfy_integration.py
Normal file
101
tests/test_ntfy_integration.py
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
"""
|
||||||
|
Integration tests for ntfy topics.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
|
||||||
|
class TestNtfyTopics:
|
||||||
|
def test_cc_topic_exists_and_writable(self):
|
||||||
|
"""Verify C&C topic exists and accepts messages."""
|
||||||
|
from engine.config import NTFY_CC_TOPIC
|
||||||
|
|
||||||
|
topic_url = NTFY_CC_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 topic: {e}") from e
|
||||||
|
|
||||||
|
def test_message_topic_exists_and_writable(self):
|
||||||
|
"""Verify message topic exists and accepts messages."""
|
||||||
|
from engine.config import NTFY_TOPIC
|
||||||
|
|
||||||
|
topic_url = NTFY_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 message topic: {e}") from e
|
||||||
|
|
||||||
|
def test_cc_topic_readable(self):
|
||||||
|
"""Verify we can read messages from C&C topic."""
|
||||||
|
from engine.config import NTFY_CC_TOPIC
|
||||||
|
|
||||||
|
test_message = f"integration_test_{int(time.time())}"
|
||||||
|
topic_url = NTFY_CC_TOPIC.replace("/json", "")
|
||||||
|
|
||||||
|
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:
|
||||||
|
urllib.request.urlopen(req, timeout=10)
|
||||||
|
except Exception as e:
|
||||||
|
raise AssertionError(f"Failed to write to C&C topic: {e}") from e
|
||||||
|
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
poll_url = f"{NTFY_CC_TOPIC}?poll=1&limit=1"
|
||||||
|
req = urllib.request.Request(
|
||||||
|
poll_url,
|
||||||
|
headers={"User-Agent": "mainline-test/0.1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||||
|
body = resp.read().decode("utf-8")
|
||||||
|
if body.strip():
|
||||||
|
data = json.loads(body.split("\n")[0])
|
||||||
|
assert isinstance(data, dict)
|
||||||
|
except Exception as e:
|
||||||
|
raise AssertionError(f"Failed to read from C&C topic: {e}") from e
|
||||||
|
|
||||||
|
def test_topics_are_different(self):
|
||||||
|
"""Verify C&C and message topics are different."""
|
||||||
|
from engine.config import NTFY_CC_TOPIC, NTFY_TOPIC
|
||||||
|
|
||||||
|
assert NTFY_CC_TOPIC != NTFY_TOPIC
|
||||||
|
assert "_cc" in NTFY_CC_TOPIC
|
||||||
Reference in New Issue
Block a user