- 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
218 lines
6.4 KiB
Python
218 lines
6.4 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Command-line utility for interacting with mainline via ntfy.
|
|
|
|
Usage:
|
|
python cmdline.py # Interactive TUI mode
|
|
python cmdline.py --help # Show help
|
|
python cmdline.py /effects list # Send single command via ntfy
|
|
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:
|
|
- Arrow keys to navigate command history
|
|
- Tab completion for commands
|
|
- 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 json
|
|
import sys
|
|
import time
|
|
import threading
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
from engine import config
|
|
from engine.terminal import CLR, CURSOR_OFF, CURSOR_ON, G_DIM, G_HI, RST, W_GHOST
|
|
|
|
try:
|
|
CC_TOPIC = config.NTFY_CC_TOPIC
|
|
except AttributeError:
|
|
CC_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline_cc/json"
|
|
|
|
TOPIC = CC_TOPIC
|
|
|
|
|
|
class NtfyResponsePoller:
|
|
"""Polls ntfy for command responses."""
|
|
|
|
def __init__(self, topic_url: str, timeout: float = 10.0):
|
|
self.topic_url = topic_url
|
|
self.timeout = timeout
|
|
self._last_id = None
|
|
self._lock = threading.Lock()
|
|
|
|
def _build_url(self) -> str:
|
|
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
|
|
|
|
parsed = urlparse(self.topic_url)
|
|
params = parse_qs(parsed.query, keep_blank_values=True)
|
|
params["since"] = [self._last_id if self._last_id else "20s"]
|
|
new_query = urlencode({k: v[0] for k, v in params.items()})
|
|
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",
|
|
)
|
|
|
|
try:
|
|
urllib.request.urlopen(req, timeout=5)
|
|
except Exception as e:
|
|
return f"Error sending command: {e}"
|
|
|
|
return self._wait_for_response()
|
|
|
|
def _wait_for_response(self) -> str:
|
|
"""Poll for response message."""
|
|
start = time.time()
|
|
while time.time() - start < self.timeout:
|
|
try:
|
|
url = self._build_url()
|
|
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:
|
|
/effects list - List all effects and status
|
|
/effects <name> on - Enable an effect
|
|
/effects <name> off - Disable an effect
|
|
/effects <name> intensity <0.0-1.0> - Set effect intensity
|
|
/effects reorder <name1>,<name2>,... - Reorder pipeline
|
|
/effects stats - Show performance statistics
|
|
/help - Show this help
|
|
/quit - Exit
|
|
"""
|
|
|
|
|
|
def print_header():
|
|
w = 60
|
|
print(CLR, end="")
|
|
print(CURSOR_OFF, end="")
|
|
print(f"\033[1;1H", end="")
|
|
print(f" \033[1;38;5;231mMAINLINE COMMAND CENTER\033[0m")
|
|
print(f" \033[2;38;5;37m{'─' * (w - 4)}\033[0m")
|
|
print(f" \033[38;5;245mTopic: {TOPIC}\033[0m")
|
|
print()
|
|
|
|
|
|
def interactive_mode():
|
|
"""Interactive TUI for sending commands."""
|
|
import readline
|
|
|
|
print_header()
|
|
poller = NtfyResponsePoller(TOPIC)
|
|
|
|
print(f"{G_DIM}Type /help for available commands, /quit to exit{RST}")
|
|
print()
|
|
|
|
while True:
|
|
try:
|
|
cmd = input(f"{G_HI}> {RST}").strip()
|
|
except (EOFError, KeyboardInterrupt):
|
|
print()
|
|
break
|
|
|
|
if not cmd:
|
|
continue
|
|
|
|
if cmd.startswith("/"):
|
|
if cmd == "/quit" or cmd == "/exit":
|
|
print(f"{G_DIM}Goodbye!{RST}")
|
|
break
|
|
|
|
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")
|
|
|
|
else:
|
|
print(f"{G_DIM}Commands must start with / - type /help{RST}\n")
|
|
|
|
print(CURSOR_ON, end="")
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="Mainline command-line interface",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog=AVAILABLE_COMMANDS,
|
|
)
|
|
parser.add_argument(
|
|
"command",
|
|
nargs="?",
|
|
default=None,
|
|
help="Command to send (e.g., /effects list)",
|
|
)
|
|
parser.add_argument(
|
|
"--watch",
|
|
"-w",
|
|
action="store_true",
|
|
help="Watch mode: continuously poll for stats (Ctrl+C to exit)",
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
|
|
if args.command is None:
|
|
interactive_mode()
|
|
return
|
|
|
|
poller = NtfyResponsePoller(TOPIC)
|
|
|
|
if args.watch and "/effects stats" in args.command:
|
|
print_header()
|
|
print(f"{G_DIM}Watching /effects stats (Ctrl+C to exit)...{RST}\n")
|
|
try:
|
|
while True:
|
|
result = poller.send_and_wait(args.command)
|
|
print(f"\033[2J\033[1;1H", end="")
|
|
print(f"{G_HI}Performance Stats - {time.strftime('%H:%M:%S')}{RST}")
|
|
print(f"{G_DIM}{'─' * 40}{RST}")
|
|
print(result)
|
|
time.sleep(2)
|
|
except KeyboardInterrupt:
|
|
print(f"\n{G_DIM}Stopped watching{RST}")
|
|
return
|
|
|
|
result = poller.send_and_wait(args.command)
|
|
print(result)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|