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:
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
|
||||
|
||||
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:
|
||||
CC_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline_cc/json"
|
||||
|
||||
TOPIC = CC_TOPIC
|
||||
CC_CMD_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd/json"
|
||||
CC_RESP_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline_cc_resp/json"
|
||||
|
||||
|
||||
class NtfyResponsePoller:
|
||||
"""Polls ntfy for command responses."""
|
||||
|
||||
def __init__(self, topic_url: str, timeout: float = 10.0):
|
||||
self.topic_url = topic_url
|
||||
def __init__(self, cmd_topic: str, resp_topic: str, timeout: float = 10.0):
|
||||
self.cmd_topic = cmd_topic
|
||||
self.resp_topic = resp_topic
|
||||
self.timeout = timeout
|
||||
self._last_id = None
|
||||
self._lock = threading.Lock()
|
||||
@@ -51,7 +52,7 @@ class NtfyResponsePoller:
|
||||
def _build_url(self) -> str:
|
||||
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["since"] = [self._last_id if self._last_id else "20s"]
|
||||
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:
|
||||
"""Send command and wait for response."""
|
||||
url = self.topic_url.replace("/json", "")
|
||||
url = self.cmd_topic.replace("/json", "")
|
||||
data = cmd.encode("utf-8")
|
||||
|
||||
req = urllib.request.Request(
|
||||
@@ -77,9 +78,9 @@ class NtfyResponsePoller:
|
||||
except Exception as 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."""
|
||||
start = time.time()
|
||||
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"
|
||||
)
|
||||
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()
|
||||
|
||||
|
||||
@@ -151,7 +153,7 @@ def interactive_mode():
|
||||
import readline
|
||||
|
||||
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()
|
||||
@@ -182,6 +184,7 @@ def interactive_mode():
|
||||
print(f"\n \033[1;38;5;196m⚠ Commands must start with /{RST}\n")
|
||||
|
||||
print(CURSOR_ON, end="")
|
||||
return 0
|
||||
|
||||
|
||||
def main():
|
||||
@@ -206,12 +209,20 @@ def main():
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command is None:
|
||||
interactive_mode()
|
||||
return
|
||||
return interactive_mode()
|
||||
|
||||
poller = NtfyResponsePoller(TOPIC)
|
||||
poller = NtfyResponsePoller(CC_CMD_TOPIC, CC_RESP_TOPIC)
|
||||
|
||||
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(f" \033[38;5;245mWatching /effects stats (Ctrl+C to exit)...{RST}\n")
|
||||
try:
|
||||
@@ -227,10 +238,12 @@ def main():
|
||||
time.sleep(2)
|
||||
except KeyboardInterrupt:
|
||||
print(f"\n \033[1;38;5;245mStopped watching{RST}")
|
||||
return
|
||||
return 0
|
||||
return 0
|
||||
|
||||
result = poller.send_and_wait(args.command)
|
||||
print(result)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user