#!/usr/bin/env python3 """Exercise the real daemon/IPC/reconnect/persistence using a fake serial monitor. No root access, physical display changes, USB claims or Python runtime dependency in the shipped daemon. Run after cargo build --release. """ import errno import json import os from pathlib import Path import pty import select import signal import socket import subprocess import tempfile import threading import time ROOT = Path(__file__).resolve().parents[1] BINARY = Path(os.environ.get("DASUNGD_BIN", ROOT.parents[1] / "target/x86_64-unknown-linux-gnu/release/dasungd")) class Monitor: def __init__(self, link): self.master, slave = pty.openpty() self.values = {1: 4, 2: 1, 7: 2, 8: 0, 9: 40, 0x10: 0x31, 0x13: 2} self.keepalives = 0 self.running = True self.respond = True link.unlink(missing_ok=True) link.symlink_to(os.ttyname(slave)) os.close(slave) self.thread = threading.Thread(target=self.loop) self.thread.start() def loop(self): buffer = b"" while self.running: try: if not select.select([self.master], [], [], 0.1)[0]: continue data = os.read(self.master, 4096) buffer += data while b"5FF5" in buffer and len(buffer) >= 24: buffer = buffer[buffer.index(b"5FF5"):] if len(buffer) < 24: break frame, buffer = buffer[:24], buffer[24:] if frame[-4:] != b"A0FA": continue cmd, opt = int(frame[4:6], 16), int(frame[6:8], 16) if cmd == 0x20: self.keepalives += opt == 1 elif cmd == 0x0A and self.respond: reply = f"5FF5F00A{opt:02X}{self.values.get(opt,0):02X}000000A0FA".encode() # Fragment actual 22-byte query responses across reads. os.write(self.master, reply[:9]) time.sleep(0.005) os.write(self.master, reply[9:]) elif cmd in self.values: self.values[cmd] = opt if self.respond: os.write(self.master, frame) except OSError as exc: if exc.errno in (errno.EIO, errno.EBADF): time.sleep(0.05) else: raise def close(self): self.running = False self.thread.join(timeout=2) os.close(self.master) def wait_for(predicate, timeout=12): deadline = time.monotonic() + timeout while time.monotonic() < deadline: try: value = predicate() if value: return value except (FileNotFoundError, ConnectionRefusedError, json.JSONDecodeError): pass time.sleep(0.1) raise AssertionError("condition did not become true") with tempfile.TemporaryDirectory(prefix="dasungd-smoke-") as directory: d = Path(directory) sock = d / "control.sock" link = d / "uart" cfg = d / "config.toml" cfg.write_text(f'''monitor_serial = "TEST-NOT-A-PHYSICAL-MONITOR" socket = "{sock}" state_file = "{d / 'state.json'}" transport = "serial" serial_device = "{link}" require_display = false keepalive_ms = 500 reconnect_ms = 500 watchdog_ms = 20000 [display] enabled = false ''') def request(value): with socket.socket(socket.AF_UNIX) as connection: connection.settimeout(5) connection.connect(str(sock)) connection.sendall(json.dumps(value).encode() + b"\n") with connection.makefile("r") as reader: return json.loads(reader.readline()) def status(): return request({"op": "status"})["status"] log = (d / "daemon.log").open("w+") process = None monitor = None try: # Start with the monitor absent, then plug it in. process = subprocess.Popen([str(BINARY), "--config", str(cfg), "daemon"], stderr=log) wait_for(lambda: sock.exists()) assert not status()["connected"] monitor = Monitor(link) wait_for(lambda: status()["parameters"].get("0x02") == 1) wait_for(lambda: monitor.keepalives >= 2) assert not request({"op": "set", "parameter": "mode", "value": 255})["ok"] assert request({"op": "set", "parameter": "contrast", "value": 5, "save": True})["ok"] wait_for(lambda: monitor.values[1] == 5) # USB/serial unplug, node replacement, automatic reinitialization. monitor.close() monitor = None wait_for(lambda: not status()["connected"]) monitor = Monitor(link) wait_for(lambda: monitor.values[1] == 5) assert status()["reconnects"] >= 2 wait_for(lambda: monitor.keepalives >= 2) # Only one daemon can own a given socket; the first remains reachable. duplicate = subprocess.run([str(BINARY), "--config", str(cfg), "daemon"], capture_output=True) assert duplicate.returncode != 0 assert status()["connected"] # A control board that stops replying gets reopened by the watchdog. old_reconnects = status()["reconnects"] monitor.respond = False wait_for(lambda: status()["reconnects"] > old_reconnects, timeout=25) monitor.respond = True wait_for(lambda: status()["parameters"].get("0x02") == 1) # Restart the process and verify saved settings survive. process.send_signal(signal.SIGTERM) assert process.wait(timeout=5) == 0 monitor.values[1] = 4 process = subprocess.Popen([str(BINARY), "--config", str(cfg), "daemon"], stderr=log) wait_for(lambda: monitor.values[1] == 5) assert request({"op": "forget", "parameter": "contrast"})["ok"] assert json.loads((d / "state.json").read_text()) == {} print("PASS: late attach, keepalive, fragmented replies, IPC validation, saved settings, unplug/replug, duplicate exclusion, reply watchdog, restart and forget") finally: if process is not None and process.poll() is None: process.terminate() process.wait(timeout=5) if monitor is not None: monitor.close() log.flush() log.seek(0) print(log.read())