Files
2026-09-21 22:29:23 +08:00

169 lines
6.3 KiB
Python

#!/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())