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:
2026-03-15 18:37:36 -07:00
parent 40ad935dda
commit bc2e086f2f
14 changed files with 633 additions and 82 deletions

View File

@@ -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__":