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

@@ -8,11 +8,11 @@ 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
def test_cc_cmd_topic_exists_and_writable(self):
"""Verify C&C CMD topic exists and accepts messages."""
from engine.config import NTFY_CC_CMD_TOPIC
topic_url = NTFY_CC_TOPIC.replace("/json", "")
topic_url = NTFY_CC_CMD_TOPIC.replace("/json", "")
test_message = f"test_{int(time.time())}"
req = urllib.request.Request(
@@ -29,7 +29,30 @@ class TestNtfyTopics:
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
raise AssertionError(f"Failed to write to C&C CMD topic: {e}") from e
def test_cc_resp_topic_exists_and_writable(self):
"""Verify C&C RESP topic exists and accepts messages."""
from engine.config import NTFY_CC_RESP_TOPIC
topic_url = NTFY_CC_RESP_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 RESP topic: {e}") from e
def test_message_topic_exists_and_writable(self):
"""Verify message topic exists and accepts messages."""
@@ -54,12 +77,12 @@ class TestNtfyTopics:
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
def test_cc_cmd_topic_readable(self):
"""Verify we can read messages from C&C CMD topic."""
from engine.config import NTFY_CC_CMD_TOPIC
test_message = f"integration_test_{int(time.time())}"
topic_url = NTFY_CC_TOPIC.replace("/json", "")
topic_url = NTFY_CC_CMD_TOPIC.replace("/json", "")
req = urllib.request.Request(
topic_url,
@@ -74,11 +97,11 @@ class TestNtfyTopics:
try:
urllib.request.urlopen(req, timeout=10)
except Exception as e:
raise AssertionError(f"Failed to write to C&C topic: {e}") from e
raise AssertionError(f"Failed to write to C&C CMD topic: {e}") from e
time.sleep(1)
poll_url = f"{NTFY_CC_TOPIC}?poll=1&limit=1"
poll_url = f"{NTFY_CC_CMD_TOPIC}?poll=1&limit=1"
req = urllib.request.Request(
poll_url,
headers={"User-Agent": "mainline-test/0.1"},
@@ -91,11 +114,14 @@ class TestNtfyTopics:
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
raise AssertionError(f"Failed to read from C&C CMD 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
"""Verify C&C CMD/RESP and message topics are different."""
from engine.config import NTFY_CC_CMD_TOPIC, NTFY_CC_RESP_TOPIC, NTFY_TOPIC
assert NTFY_CC_TOPIC != NTFY_TOPIC
assert "_cc" in NTFY_CC_TOPIC
assert NTFY_CC_CMD_TOPIC != NTFY_TOPIC
assert NTFY_CC_RESP_TOPIC != NTFY_TOPIC
assert NTFY_CC_CMD_TOPIC != NTFY_CC_RESP_TOPIC
assert "_cc_cmd" in NTFY_CC_CMD_TOPIC
assert "_cc_resp" in NTFY_CC_RESP_TOPIC