FDS/OS 1.0

This commit is contained in:
2026-09-21 22:29:23 +08:00
commit 99bc3d15c5
430 changed files with 34876 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "dasungd"
version = "0.1.0"
edition = "2024"
description = "DASUNG Paperlike USB keepalive, control socket and Linux display profile daemon"
license = "MIT"
[features]
vendored = ["rusb/vendored"]
[dependencies]
anyhow = "1"
clap.workspace = true
ctrlc = { version = "3", features = ["termination"] }
libc = "0.2"
rusb = "0.9"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
+27
View File
@@ -0,0 +1,27 @@
# Imported Dasung controller source
Source task: Fix Dasung monitor black screen
Task ID: `01a0b7d0-7020-71e0-bfec-13c9568b5872`
Imported from the local `dasungd` project on 2026-09-20.
The original task directory is not a build dependency. FDS owns the imported
source snapshot and subsequent adaptations. Original input hashes:
```text
cc97a9bf34829b213bf6c882729e0e272438fb60253ba4e248d6714c8325a637 Cargo.toml
8771f6322c399ab10bb321f341ca8f9b2a31e055ef72d8c5acd169f626fdd27a Cargo.lock
1126322e2cc8d165adc4c792eeb195717de2bcc7b39be1ce77959d78e87ef685 LICENSE
4fda0939b3ae291fc6ff5c2b57933dc4a00fb7d3e44bf4d4b1f42f855c1ac63a src/config.rs
55498d77a942f73f4c737b4f2efd4cc80dfc6985c93d942d1a21f77809774afc src/display.rs
be1d2956bec44dc396b37ba66016d333defff368bc62777573f1198aeb447f7f src/main.rs
5c3a1d0a6fa97a9b020433cc7a66482eb75ae2330cfd2b094aa347b29313e02d src/protocol.rs
53e412f7fac3be01cd3e03144ba7a6cdb44059321e820ecd1ab45be32916e53f src/transport.rs
b6c1e0a8d315d9cf5c2fb83784a177530bcc085c2d0724dc7478a9c12449e4f4 profiles/paperlike13k-37hz.edid
```
FDS changes: workspace integration, target filesystem paths, native s6 packaging,
and a portable simulator binary path. The original AMD/systemd installer and
workstation configuration are not part of the target package.
See [the FDS guide](../../docs/dasung.md) and the historical
[hardware validation record](VALIDATION.md).
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+16
View File
@@ -0,0 +1,16 @@
# dasungd in FDS/OS
The controller for the user's grayscale Dasung Paperlike 13K is maintained as an
FDS Rust workspace member. It provides USB keepalive, reconnect/watchdog handling,
configuration, and a local control socket.
Use the [FDS integration and usage guide](../../docs/dasung.md) for build commands,
base packaging, native s6 supervision, Pi display configuration, and validation
limits. From the repository root, run `make dasung` followed by `make dasung-test`.
These targets do not operate the workstation's live monitor.
[IMPORT.md](IMPORT.md) records the source task and original input hashes.
[VALIDATION.md](VALIDATION.md) is the original workstation acceptance record; its
references to the original README and systemd installation are historical. The
FDS target uses the native s6 integration documented in the guide above. Pi boot
and corrected physical cold-start recovery are not yet verified.
+29
View File
@@ -0,0 +1,29 @@
# Validation record — 2026-09-20
## Confirmed before installation
The user confirmed 3200×2400 at 36.9998735 Hz, 304.21 MHz, 2× scaling was **very stable** using the Python control helper. The full timing is in README.md. The monitor's SPI interface and native UART driver were unbound during that confirmation.
## Software and initial live handoff
- Six Rust unit tests pass: exact outgoing framing, fragmented/concatenated real replies, malformed stream recovery, parameter validation, profile checksum/identity/clock, bounded IPC and saved-setting round trip (some checks share test cases).
- `cargo clippy --locked --all-targets -- -D warnings` passes; release build succeeds.
- The pseudo-terminal integration test passes late attachment, keepalive, real response fragmentation, invalid command rejection, saved parameter replay, unplug/node replacement/replug, duplicate daemon exclusion, reply watchdog, process restart and forgetting saved settings.
- Systemd unit and udev rule syntax verification pass.
- Live Rust handoff received all expected queries: mode 1, contrast 4, front-light 2, brightness 40, temperature 0, protocol version 0x31. Mode 1 and contrast 4 writes were read back correctly and saved.
- Service restart and Hyprland reload retained the exact 304210 kHz timing and 2× scaling. The ASUS remained 2560×1440 at 144 Hz, scale 1. The diagnostic mpv window is closed.
- Service is enabled at boot. Current initramfs contains neither CH341 UART nor CH341 SPI modules; no bootloader or initramfs changes were required.
## Physical reconnect exposed a remaining cold-start issue
The first full power/reconnect test returned a dark picture. Video timing/scaling restored, and the UART was rediscovered at a new USB address. However, the kernel had bound `spi_ch341` to the second USB chip and replies stopped after the first two queries. Removing the driver and resetting that USB chip afterward did not recover replies.
Corrections now installed:
- Scoped udev autoload suppression for the monitor's hub/UART/SPI combination. A real synthetic add event left `spi_ch341` unloaded.
- The USB transport reserves the companion SPI interface without configuring it or sending SPI data, preventing a later-loaded driver from binding while the daemon owns it.
- Minimum 150 ms packet spacing and one-second startup query spacing, matching the proven Python helper; the initial Rust version could send adjacent keepalive/query packets.
- Reply timeouts reopen only control; they do not continually retrain an otherwise unchanged video signal.
- Status distinguishes an open transport (`connected`) from receipt of valid monitor frames (`responsive`).
**A clean physical power cycle with these corrections is still pending user confirmation. Persistence is installed, but cold-start picture recovery must not yet be described as verified.** No computer reboot has been performed.
Binary file not shown.
+103
View File
@@ -0,0 +1,103 @@
use crate::protocol::Parameter;
use anyhow::{Context, Result, ensure};
use serde::{Deserialize, Serialize};
use std::{
collections::BTreeMap,
fs,
path::{Path, PathBuf},
};
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct Config {
pub monitor_serial: String,
pub socket: PathBuf,
pub state_file: PathBuf,
pub transport: String,
pub serial_device: Option<PathBuf>,
pub usb_path: Option<String>,
pub require_companion: bool,
pub require_display: bool,
pub keepalive_ms: u64,
pub reconnect_ms: u64,
pub watchdog_ms: u64,
pub display: Display,
pub startup: BTreeMap<Parameter, u8>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct Display {
pub enabled: bool,
pub edid: PathBuf,
// AMD's virtual hotplug API was verified on the actual machine. Other GPUs
// should use the portable firmware EDID mechanism or their KMS client.
pub hotplug: String,
}
impl Default for Display {
fn default() -> Self {
Self {
enabled: false,
edid: "/usr/lib/firmware/edid/dasung-paperlike13k-37hz.bin".into(),
hotplug: "none".into(),
}
}
}
impl Default for Config {
fn default() -> Self {
Self {
monitor_serial: "L56051794302".into(),
socket: "/run/dasungd/control.sock".into(),
state_file: "/run/dasungd/settings.json".into(),
transport: "usb".into(),
serial_device: None,
usb_path: None,
require_companion: true,
require_display: true,
keepalive_ms: 2000,
reconnect_ms: 2000,
watchdog_ms: 30000,
display: Display::default(),
startup: BTreeMap::new(),
}
}
}
impl Config {
pub fn load(path: &Path) -> Result<Self> {
let config: Self = toml::from_str(
&fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?,
)?;
ensure!(
!config.monitor_serial.is_empty(),
"monitor_serial must identify the intended monitor"
);
ensure!(
(500..=5000).contains(&config.keepalive_ms),
"keepalive_ms must be 500..5000"
);
ensure!(
(500..=30000).contains(&config.reconnect_ms),
"reconnect_ms must be 500..30000"
);
ensure!(
config.watchdog_ms >= 20000,
"watchdog_ms must be at least 20000"
);
ensure!(
matches!(config.transport.as_str(), "usb" | "serial"),
"transport must be usb or serial"
);
ensure!(
config.transport != "serial" || config.serial_device.is_some(),
"serial transport needs serial_device"
);
ensure!(
matches!(config.display.hotplug.as_str(), "none" | "amdgpu"),
"display.hotplug must be none or amdgpu"
);
for (&parameter, &value) in &config.startup {
parameter.validate(value)?;
}
Ok(config)
}
}
+231
View File
@@ -0,0 +1,231 @@
use crate::config::Config;
use anyhow::{Context, Result, ensure};
use serde::Serialize;
use std::{collections::BTreeMap, fs, path::PathBuf, thread, time::Duration};
pub fn serial(edid: &[u8]) -> Option<String> {
if edid.len() < 128 {
return None;
}
for d in edid[54..126].as_chunks::<18>().0 {
if d[..5] == [0, 0, 0, 0xff, 0] {
return Some(String::from_utf8_lossy(&d[5..18]).trim().to_owned());
}
}
None
}
pub fn validate(edid: &[u8], identity: &str) -> Result<()> {
ensure!(
edid.len() >= 128 && edid.len().is_multiple_of(128),
"EDID must contain complete 128-byte blocks"
);
ensure!(
&edid[..8] == b"\x00\xff\xff\xff\xff\xff\xff\x00",
"invalid EDID header"
);
ensure!(
usize::from(edid[126]) + 1 == edid.len() / 128,
"EDID extension count mismatch"
);
ensure!(
edid.as_chunks::<128>()
.0
.iter()
.all(|b| b.iter().fold(0u8, |sum, v| sum.wrapping_add(*v)) == 0),
"EDID checksum failed"
);
ensure!(
serial(edid).as_deref() == Some(identity),
"EDID profile serial does not match configured monitor_serial"
);
Ok(())
}
#[derive(Clone, Debug, Serialize)]
pub struct Connector {
pub name: String,
pub path: PathBuf,
pub debug: PathBuf,
}
pub fn find(identity: &str) -> Result<Vec<Connector>> {
let mut found = Vec::new();
for entry in fs::read_dir("/sys/class/drm")? {
let path = entry?.path();
if fs::read_to_string(path.join("status"))
.unwrap_or_default()
.trim()
!= "connected"
{
continue;
}
if serial(&fs::read(path.join("edid")).unwrap_or_default()).as_deref() != Some(identity) {
continue;
}
let name = path.file_name().unwrap().to_string_lossy().into_owned();
let Some((card, connector)) = name.split_once('-') else {
continue;
};
let Some(index) = card.strip_prefix("card") else {
continue;
};
found.push(Connector {
debug: PathBuf::from(format!("/sys/kernel/debug/dri/{index}/{connector}")),
name,
path,
});
}
Ok(found)
}
fn redetect(c: &Connector, config: &Config) -> Result<()> {
if config.display.hotplug == "amdgpu" {
let trigger = c.debug.join("trigger_hotplug");
ensure!(
trigger.exists(),
"amdgpu trigger_hotplug absent on {}",
c.name
);
// The current kernel's parser requires newline-terminated numeric writes.
fs::write(&trigger, b"0\n")?;
thread::sleep(Duration::from_millis(500));
fs::write(&trigger, b"1\n")?;
}
Ok(())
}
pub struct Manager {
profile: Vec<u8>,
owned: BTreeMap<String, Connector>,
}
impl Manager {
pub fn recover_stale(&self, config: &Config) -> Result<()> {
if !config.display.enabled {
return Ok(());
}
// A previous process may have been killed without cleanup. Remove only
// this exact profile and reread the physical identity before trusting it.
// This prevents an old port override from impersonating a replacement
// monitor when the service restarts after an unattended cable swap.
for entry in fs::read_dir("/sys/class/drm")? {
let path = entry?.path();
let name = path.file_name().unwrap().to_string_lossy().into_owned();
let Some((card, connector)) = name.split_once('-') else {
continue;
};
let Some(index) = card.strip_prefix("card") else {
continue;
};
let debug = PathBuf::from(format!("/sys/kernel/debug/dri/{index}/{connector}"));
let p = debug.join("edid_override");
if fs::read(&p).unwrap_or_default() != self.profile {
continue;
}
fs::write(&p, b"reset")?;
let c = Connector { name, path, debug };
if fs::read_to_string(c.path.join("status"))
.unwrap_or_default()
.trim()
== "connected"
{
redetect(&c, config)?;
}
}
Ok(())
}
pub fn new(config: &Config) -> Result<Self> {
let profile = if config.display.enabled {
let b = fs::read(&config.display.edid).context("read display.edid")?;
validate(&b, &config.monitor_serial)?;
b
} else {
Vec::new()
};
Ok(Self {
profile,
owned: BTreeMap::new(),
})
}
pub fn tick(&mut self, config: &Config) -> Result<Vec<String>> {
if !config.display.enabled {
return Ok(find(&config.monitor_serial)?
.iter()
.map(|c| c.name.clone())
.collect());
}
// A connector override must not remain attached to a port after unplug.
let removed: Vec<_> = self
.owned
.iter()
.filter(|(_, c)| {
fs::read_to_string(c.path.join("status"))
.unwrap_or_default()
.trim()
!= "connected"
})
.map(|(n, _)| n.clone())
.collect();
for n in removed {
let c = self.owned.get(&n).unwrap();
self.clear(c)?;
self.owned.remove(&n);
}
let found = find(&config.monitor_serial)?;
ensure!(
found.len() <= 1,
"multiple connected outputs match monitor serial; connect video by one cable"
);
for c in &found {
let override_path = c.debug.join("edid_override");
let existing = fs::read(&override_path)
.context("read EDID override; mount debugfs and check permissions")?;
ensure!(
existing.is_empty() || existing == self.profile,
"another EDID override exists on {}; refusing to overwrite it",
c.name
);
self.owned.insert(c.name.clone(), c.clone());
if existing != self.profile {
fs::write(&override_path, &self.profile)?;
redetect(c, config)?;
eprintln!("{}: applied confirmed 304.21 MHz EDID", c.name);
}
}
Ok(found.iter().map(|c| c.name.clone()).collect())
}
fn clear(&self, c: &Connector) -> Result<()> {
let p = c.debug.join("edid_override");
if p.exists() && fs::read(&p)? == self.profile {
fs::write(p, b"reset")?;
}
Ok(())
}
pub fn cleanup(&mut self, config: &Config, reprobe: bool) {
for c in self.owned.values() {
if let Err(e) = self.clear(c) {
eprintln!("clear {}: {e:#}", c.name);
continue;
}
if reprobe && let Err(e) = redetect(c, config) {
eprintln!("redetect {}: {e:#}", c.name);
}
}
self.owned.clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn profile_has_valid_identity_checksum_and_exact_clock() {
let b = include_bytes!("../profiles/paperlike13k-37hz.edid");
validate(b, "L56051794302").unwrap();
assert_eq!(u16::from_le_bytes([b[54], b[55]]), 30421);
let mut broken = b.to_vec();
broken[60] ^= 1;
assert!(validate(&broken, "L56051794302").is_err());
assert!(validate(b, "another monitor").is_err());
}
}
+573
View File
@@ -0,0 +1,573 @@
mod config;
mod display;
mod protocol;
mod transport;
use anyhow::{Context, Result, bail};
use clap::{Parser, Subcommand};
use config::Config;
use protocol::{Decoder, Parameter, packet};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::{
collections::{BTreeMap, VecDeque},
fs,
io::{BufRead, BufReader, Read, Write},
os::{
fd::AsRawFd,
unix::{
fs::PermissionsExt,
net::{UnixListener, UnixStream},
},
},
path::{Path, PathBuf},
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
mpsc,
},
thread,
time::{Duration, Instant},
};
#[derive(Parser)]
#[command(version, about)]
struct Cli {
#[arg(long, global = true, default_value = "/etc/dasungd.toml")]
config: PathBuf,
#[arg(long, global = true)]
socket: Option<PathBuf>,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
/// Internal udev matcher: recognize the monitor's SPI/UART hub combination.
#[command(hide = true)]
UdevMatchSpi { syspath: PathBuf },
/// Foreground daemon; a service manager may supervise it.
Daemon,
/// Validate configuration and the optional EDID without changing hardware.
Check,
/// Discover matching video/USB devices without claiming them.
Discover,
/// JSON status including cached hardware settings and connection health.
Status,
/// Ask the monitor to refresh all cached parameter values.
Query,
/// Send one full-refresh command.
Refresh,
/// Set a raw monitor parameter through the daemon's single USB owner.
Set {
#[arg(value_enum)]
parameter: Parameter,
value: u8,
/// Reapply this value after reconnection/restart.
#[arg(long)]
save: bool,
},
/// Forget a saved parameter without changing its current hardware value.
Forget {
#[arg(value_enum)]
parameter: Parameter,
},
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "op", rename_all = "snake_case", deny_unknown_fields)]
enum Request {
Status,
Query,
Refresh,
Set {
parameter: Parameter,
value: u8,
#[serde(default)]
save: bool,
},
Forget {
parameter: Parameter,
},
}
type Work = (Request, mpsc::Sender<Value>);
#[derive(Default, Serialize)]
struct Status {
connected: bool,
responsive: bool,
transport: Option<String>,
reconnects: u64,
keepalives_sent: u64,
rx_frames: u64,
parameters: BTreeMap<String, u8>,
saved: BTreeMap<Parameter, u8>,
last_rx_ms_ago: Option<u64>,
usb_error: Option<String>,
displays: Vec<String>,
display_error: Option<String>,
}
fn reply_ok(message: &str) -> Value {
json!({"ok":true,"message":message})
}
fn reply_error(error: impl std::fmt::Display) -> Value {
json!({"ok":false,"error":error.to_string()})
}
fn read_request(stream: &UnixStream) -> Result<Request> {
stream.set_read_timeout(Some(Duration::from_millis(500)))?;
let mut reader = BufReader::new(stream);
let mut line = Vec::new();
// Bound local requests, including requests without a terminating newline.
std::io::Read::by_ref(&mut reader)
.take(4097)
.read_until(b'\n', &mut line)?;
if line.len() > 4096 || !line.ends_with(b"\n") {
bail!("request must be one JSON line of at most 4096 bytes");
}
Ok(serde_json::from_slice(&line)?)
}
fn control_server(
listener: UnixListener,
sender: mpsc::SyncSender<Work>,
running: Arc<AtomicBool>,
) {
while running.load(Ordering::Relaxed) {
match listener.accept() {
Ok((mut stream, _)) => {
let response = match read_request(&stream) {
Ok(request) => {
let (reply, recv) = mpsc::channel();
if sender.try_send((request, reply)).is_err() {
reply_error("daemon busy")
} else {
recv.recv_timeout(Duration::from_secs(4))
.unwrap_or_else(|_| reply_error("request timed out"))
}
}
Err(e) => reply_error(e),
};
let _ = stream.set_write_timeout(Some(Duration::from_millis(500)));
let _ = writeln!(stream, "{response}");
}
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(50))
}
Err(e) => {
eprintln!("control socket: {e}");
thread::sleep(Duration::from_millis(250));
}
}
}
}
fn save_settings(path: &Path, settings: &BTreeMap<Parameter, u8>) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let temporary = path.with_extension("tmp");
let mut file = fs::File::create(&temporary)?;
file.set_permissions(fs::Permissions::from_mode(0o600))?;
file.write_all(serde_json::to_string_pretty(settings)?.as_bytes())?;
file.sync_all()?;
fs::rename(temporary, path)?;
Ok(())
}
fn query_all(queue: &mut VecDeque<(u8, u8)>) {
for parameter in [0x10, 0x13, 1, 2, 7, 9, 8] {
if !queue.contains(&(0x0a, parameter)) {
queue.push_back((0x0a, parameter));
}
}
}
fn daemon(config: Config) -> Result<()> {
// Lock a separate inode before replacing any stale socket. A second instance
// must never steal the active daemon's pathname or USB interface.
fs::create_dir_all(
config
.socket
.parent()
.context("socket needs a parent directory")?,
)?;
let lock = fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(config.socket.with_extension("lock"))?;
// SAFETY: flock receives a live fd and defined operation flags.
if unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } != 0 {
bail!("another daemon owns this socket");
}
if config.socket.exists() {
fs::remove_file(&config.socket)?;
}
let listener = UnixListener::bind(&config.socket)?;
fs::set_permissions(&config.socket, fs::Permissions::from_mode(0o660))?;
listener.set_nonblocking(true)?;
let running = Arc::new(AtomicBool::new(true));
let signal_flag = running.clone();
ctrlc::set_handler(move || signal_flag.store(false, Ordering::Relaxed))?;
let mut saved = if config.state_file.exists() {
serde_json::from_slice::<BTreeMap<Parameter, u8>>(&fs::read(&config.state_file)?)?
} else {
BTreeMap::new()
};
for (&p, &v) in &saved {
p.validate(v)?;
}
let status = Arc::new(Mutex::new(Status {
saved: saved.clone(),
..Default::default()
}));
let mut manager = display::Manager::new(&config)?;
let ds = status.clone();
let dc = config.clone();
let dr = running.clone();
let invalidate = Arc::new(AtomicBool::new(false));
let di = invalidate.clone();
let display_thread = thread::spawn(move || {
let mut last_error = String::new();
// Do not apply any profile if stale-override recovery failed. Retrying
// keeps the daemon useful when debugfs appears later during boot.
let mut recovered = false;
while dr.load(Ordering::Relaxed) {
if !recovered {
match manager.recover_stale(&dc) {
Ok(()) => recovered = true,
Err(e) => {
let message = format!("stale EDID recovery: {e:#}");
if message != last_error {
eprintln!("{message}");
last_error = message.clone();
}
ds.lock().unwrap().display_error = Some(message);
thread::sleep(Duration::from_secs(1));
continue;
}
}
}
if di.swap(false, Ordering::Relaxed) {
manager.cleanup(&dc, true);
}
match manager.tick(&dc) {
Ok(displays) => {
let mut s = ds.lock().unwrap();
s.displays = displays;
s.display_error = None;
last_error.clear();
}
Err(e) => {
let message = format!("{e:#}");
if message != last_error {
eprintln!("display: {message}");
last_error = message.clone();
}
ds.lock().unwrap().display_error = Some(message);
}
}
thread::sleep(Duration::from_millis(250));
}
manager.cleanup(&dc, false);
});
let (sender, receiver) = mpsc::sync_channel::<Work>(16);
let sr = running.clone();
let server = thread::spawn(move || control_server(listener, sender, sr));
let mut port: Option<Box<dyn transport::Transport>> = None;
let mut decoder = Decoder::default();
let mut queue = VecDeque::new();
let mut reconnect = Instant::now();
let mut keepalive = Instant::now();
let mut query = Instant::now();
let mut control = Instant::now();
let mut last_rx = Instant::now();
let mut last_error = String::new();
eprintln!(
"dasungd {} ready; waiting for {}",
env!("CARGO_PKG_VERSION"),
config.monitor_serial
);
while running.load(Ordering::Relaxed) {
let now = Instant::now();
if port.is_none() && now >= reconnect {
let attempt = (|| -> Result<Box<dyn transport::Transport>> {
if config.require_display && display::find(&config.monitor_serial)?.is_empty() {
bail!("waiting for matching display EDID");
}
transport::open(&config)
})();
match attempt {
Ok(p) => {
eprintln!("connected {}", p.label());
let mut s = status.lock().unwrap();
s.connected = true;
s.responsive = false;
s.last_rx_ms_ago = None;
s.transport = Some(p.label().into());
s.reconnects += 1;
s.usb_error = None;
s.parameters.clear();
last_error.clear();
port = Some(p);
decoder = Decoder::default();
queue.clear();
query_all(&mut queue);
let mut startup = config.startup.clone();
startup.extend(saved.clone());
for (parameter, value) in startup {
queue.push_back((parameter.command(), value));
}
queue.push_back((3, 0));
last_rx = now;
keepalive = now + Duration::from_secs(1);
control = now;
query = now + Duration::from_secs(10);
}
Err(e) => {
let message = format!("{e:#}");
if message != last_error {
eprintln!("USB: {message}");
last_error = message.clone();
}
status.lock().unwrap().usb_error = Some(message);
reconnect = now + Duration::from_millis(config.reconnect_ms);
}
}
}
while let Ok((request, reply)) = receiver.try_recv() {
let result = (|| -> Result<Value> {
if matches!(request, Request::Status) {
return Ok(json!({"ok":true,"status":*status.lock().unwrap()}));
}
if let Request::Forget { parameter } = request {
let mut updated = saved.clone();
updated.remove(&parameter);
save_settings(&config.state_file, &updated)?;
saved = updated;
status.lock().unwrap().saved = saved.clone();
return Ok(reply_ok(
"saved setting removed; configured startup defaults still apply",
));
}
let p = port.as_mut().context("monitor is not connected")?;
match request {
Request::Query => {
query_all(&mut queue);
Ok(reply_ok(
"parameter queries queued; read status for replies",
))
}
Request::Refresh => {
p.write(&packet(3, 0))?;
Ok(reply_ok("refresh packet written"))
}
Request::Set {
parameter,
value,
save,
} => {
parameter.validate(value)?;
p.write(&packet(parameter.command(), value))?;
queue.push_front((0x0a, parameter.command()));
if save {
let mut updated = saved.clone();
updated.insert(parameter, value);
save_settings(&config.state_file, &updated)?;
saved = updated;
status.lock().unwrap().saved = saved.clone();
}
Ok(reply_ok(
"setting packet written; status reports the monitor's observed value",
))
}
_ => unreachable!(),
}
})();
let _ = reply.send(result.unwrap_or_else(reply_error));
}
if let Some(p) = port.as_mut() {
let mut reply_timeout = false;
let result = (|| -> Result<()> {
if now >= keepalive {
p.write(&packet(0x20, 1))?;
keepalive = Instant::now() + Duration::from_millis(config.keepalive_ms);
status.lock().unwrap().keepalives_sent += 1;
}
if now >= query {
if !queue.contains(&(0x0a, 2)) {
queue.push_back((0x0a, 2));
}
query = now + Duration::from_secs(10);
}
if now >= control
&& let Some((cmd, opt)) = queue.pop_front()
{
p.write(&packet(cmd, opt))?;
control = Instant::now() + Duration::from_secs(1);
}
let mut bytes = [0; 256];
let n = p.read(&mut bytes)?;
for frame in decoder.push(&bytes[..n]) {
let mut s = status.lock().unwrap();
s.rx_frames += 1;
s.responsive = true;
last_rx = Instant::now();
if let Some((parameter, value)) = frame.parameter() {
s.parameters.insert(format!("0x{parameter:02X}"), value);
}
}
let age = last_rx.elapsed();
{
let mut s = status.lock().unwrap();
s.last_rx_ms_ago = s.responsive.then_some(age.as_millis() as u64);
}
if age > Duration::from_millis(config.watchdog_ms) {
reply_timeout = true;
bail!(
"no valid monitor replies for {} seconds; reopening control channel",
age.as_secs()
);
}
Ok(())
})();
if let Err(e) = result {
eprintln!("control connection lost: {e:#}");
port = None;
queue.clear();
let mut s = status.lock().unwrap();
s.connected = false;
s.responsive = false;
s.last_rx_ms_ago = None;
s.transport = None;
s.usb_error = Some(format!("{e:#}"));
reconnect = Instant::now() + Duration::from_millis(config.reconnect_ms);
// A silent control MCU needs a UART reopen, not a video mode
// reset. Actual transport loss invalidates the port association.
if !reply_timeout {
invalidate.store(true, Ordering::Relaxed);
}
}
} else {
thread::sleep(Duration::from_millis(50));
}
}
if let Some(mut p) = port {
let _ = p.write(&packet(0x20, 0));
}
let _ = server.join();
let _ = display_thread.join();
let _ = fs::remove_file(&config.socket);
eprintln!("stopped; released control interface and owned runtime EDID overrides");
Ok(())
}
fn client(socket: &Path, request: Request) -> Result<()> {
let mut stream = UnixStream::connect(socket)
.with_context(|| format!("connect to {}; is dasungd running?", socket.display()))?;
stream.set_read_timeout(Some(Duration::from_secs(6)))?;
writeln!(stream, "{}", serde_json::to_string(&request)?)?;
let mut line = String::new();
BufReader::new(stream).read_line(&mut line)?;
let reply: Value = serde_json::from_str(&line)?;
println!("{}", serde_json::to_string_pretty(&reply)?);
if reply["ok"] != true {
bail!("daemon rejected the request");
}
Ok(())
}
fn run() -> Result<()> {
let cli = Cli::parse();
if let Command::UdevMatchSpi { syspath } = &cli.command {
if transport::is_monitor_spi(syspath) {
println!("yes");
return Ok(());
}
bail!("not a matching monitor SPI interface");
}
if matches!(
cli.command,
Command::Daemon | Command::Check | Command::Discover
) {
let mut config = Config::load(&cli.config)?;
if let Some(socket) = cli.socket {
config.socket = socket;
}
return match cli.command {
Command::Daemon => daemon(config),
Command::Check => {
display::Manager::new(&config)?;
println!("Configuration and EDID valid");
Ok(())
}
Command::Discover => {
println!(
"{}",
serde_json::to_string_pretty(
&json!({"displays":display::find(&config.monitor_serial)?,"usb":transport::usb_candidates(&config)?})
)?
);
Ok(())
}
_ => unreachable!(),
};
}
let socket = match cli.socket {
Some(socket) => socket,
None if cli.config.exists() => Config::load(&cli.config)?.socket,
None => "/run/dasungd/control.sock".into(),
};
let request = match cli.command {
Command::Status => Request::Status,
Command::Query => Request::Query,
Command::Refresh => Request::Refresh,
Command::Set {
parameter,
value,
save,
} => {
parameter.validate(value)?;
Request::Set {
parameter,
value,
save,
}
}
Command::Forget { parameter } => Request::Forget { parameter },
_ => unreachable!(),
};
client(&socket, request)
}
fn main() {
if let Err(e) = run() {
eprintln!("dasungd: {e:#}");
std::process::exit(1);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn invalid_or_oversized_ipc_is_rejected() {
let (a, mut b) = UnixStream::pair().unwrap();
b.write_all(b"{\"op\":\"set\",\"parameter\":\"firmware\",\"value\":3}\n")
.unwrap();
assert!(read_request(&a).is_err());
let (a, mut b) = UnixStream::pair().unwrap();
b.write_all(&vec![b'x'; 4097]).unwrap();
assert!(read_request(&a).is_err());
}
#[test]
fn saved_settings_survive_reload() {
let dir = std::env::temp_dir().join(format!("dasungd-test-{}", std::process::id()));
let p = dir.join("settings.json");
let settings = BTreeMap::from([(Parameter::Mode, 7)]);
save_settings(&p, &settings).unwrap();
let read: BTreeMap<Parameter, u8> = serde_json::from_slice(&fs::read(&p).unwrap()).unwrap();
assert_eq!(read, settings);
fs::remove_dir_all(dir).unwrap();
}
}
+131
View File
@@ -0,0 +1,131 @@
use anyhow::{Result, bail};
use serde::{Deserialize, Serialize};
pub fn packet(command: u8, option: u8) -> Vec<u8> {
format!("5FF5{command:02X}{option:02X}000000000000A0FA").into_bytes()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Frame(pub Vec<u8>);
impl Frame {
pub fn parameter(&self) -> Option<(u8, u8)> {
match self.0.as_slice() {
[0xf0, 0x0a, parameter, value, ..] => Some((*parameter, *value)),
[command @ (1 | 2 | 7 | 8 | 9), value, ..] => Some((*command, *value)),
_ => None,
}
}
}
// USB reads can split frames, concatenate them, or contain unsolicited button events.
// The observed monitor replies have both 22- and 24-character framing.
#[derive(Default)]
pub struct Decoder(Vec<u8>);
impl Decoder {
pub fn push(&mut self, input: &[u8]) -> Vec<Frame> {
self.0.extend(input.iter().map(u8::to_ascii_uppercase));
let mut frames = Vec::new();
loop {
let Some(start) = self.0.windows(4).position(|v| v == b"5FF5") else {
if self.0.len() > 3 {
self.0.drain(..self.0.len() - 3);
}
break;
};
self.0.drain(..start);
let end = [22, 24]
.into_iter()
.find(|&n| self.0.len() >= n && &self.0[n - 4..n] == b"A0FA");
if let Some(n) = end {
let body = &self.0[4..n - 4];
if body.iter().all(u8::is_ascii_hexdigit) {
let bytes = body
.as_chunks::<2>()
.0
.iter()
.map(|p| u8::from_str_radix(std::str::from_utf8(p).unwrap(), 16).unwrap())
.collect();
frames.push(Frame(bytes));
}
self.0.drain(..n);
} else if self.0.len() >= 24 {
self.0.remove(0);
} else {
break;
}
}
frames
}
}
#[derive(
Debug, Clone, Copy, Serialize, Deserialize, clap::ValueEnum, PartialEq, Eq, PartialOrd, Ord,
)]
#[serde(rename_all = "snake_case")]
pub enum Parameter {
Mode,
Contrast,
FrontLight,
Brightness,
Temperature,
}
impl Parameter {
pub fn command(self) -> u8 {
match self {
Self::Contrast => 1,
Self::Mode => 2,
Self::FrontLight => 7,
Self::Temperature => 8,
Self::Brightness => 9,
}
}
pub fn validate(self, value: u8) -> Result<()> {
let valid = match self {
Self::Mode => (1..=8).contains(&value),
Self::Contrast => (1..=9).contains(&value),
Self::FrontLight => value <= 2,
Self::Brightness | Self::Temperature => value <= 100,
};
if !valid {
bail!("value {value} outside the allowed raw range for {self:?}");
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn framing_and_fragmented_real_responses() {
assert_eq!(packet(0x20, 1), b"5FF52001000000000000A0FA");
let mut d = Decoder::default();
assert!(d.push(b"junk5FF5F00").is_empty());
let frames = d.push(b"A020100000000A0FA5FF50207000000000000A0FA");
assert_eq!(
frames
.iter()
.filter_map(Frame::parameter)
.collect::<Vec<_>>(),
[(2, 1), (2, 7)]
);
}
#[test]
fn resynchronizes_after_bad_frame_and_bounds_noise() {
let mut d = Decoder::default();
assert!(d.push(&vec![b'X'; 50000]).is_empty());
assert!(d.0.len() <= 3);
let f = d.push(b"5FF5ZZ02000000000000A0FA5FF5F00A020300000000A0FA");
assert_eq!(f.len(), 1);
assert_eq!(f[0].parameter(), Some((2, 3)));
}
#[test]
fn reject_invalid_settings() {
assert!(Parameter::Mode.validate(0).is_err());
assert!(Parameter::Mode.validate(7).is_ok());
assert!(Parameter::FrontLight.validate(3).is_err());
}
}
+306
View File
@@ -0,0 +1,306 @@
use crate::config::Config;
use anyhow::{Context, Result, bail, ensure};
use rusb::{DeviceHandle, GlobalContext};
use std::{
fs::{self, File, OpenOptions},
io::{Read, Write},
os::{fd::AsRawFd, unix::fs::OpenOptionsExt},
path::Path,
time::{Duration, Instant},
};
pub trait Transport: Send {
fn write(&mut self, bytes: &[u8]) -> Result<()>;
fn read(&mut self, bytes: &mut [u8]) -> Result<usize>;
fn label(&self) -> &str;
}
struct Usb {
handle: DeviceHandle<GlobalContext>,
// Claiming this interface sends no SPI data and prevents a driver loaded
// later for another adapter from probing the monitor's internal bridge.
_spi_guard: Option<DeviceHandle<GlobalContext>>,
last_write: Option<Instant>,
name: String,
}
impl Transport for Usb {
fn write(&mut self, bytes: &[u8]) -> Result<()> {
pace(self.last_write);
let n = self
.handle
.write_bulk(0x02, bytes, Duration::from_millis(500))?;
ensure!(n == bytes.len(), "partial USB write: {n}/{}", bytes.len());
self.last_write = Some(Instant::now());
Ok(())
}
fn read(&mut self, bytes: &mut [u8]) -> Result<usize> {
match self
.handle
.read_bulk(0x82, bytes, Duration::from_millis(50))
{
Ok(n) => Ok(n),
Err(rusb::Error::Timeout) => Ok(0),
Err(e) => Err(e.into()),
}
}
fn label(&self) -> &str {
&self.name
}
}
fn pace(last_write: Option<Instant>) {
// Match the proven helper's inter-command gap. In particular, a keepalive
// and query must not reach the MCU back-to-back at cold startup.
if let Some(last) = last_write {
let gap = Duration::from_millis(150);
if last.elapsed() < gap {
std::thread::sleep(gap.saturating_sub(last.elapsed()));
}
}
}
fn matches_id(path: &Path, vendor: &str, product: &str) -> bool {
fs::read_to_string(path.join("idVendor"))
.unwrap_or_default()
.trim()
== vendor
&& fs::read_to_string(path.join("idProduct"))
.unwrap_or_default()
.trim()
== product
}
pub fn is_monitor_spi(path: &Path) -> bool {
let path = if path.starts_with("/sys") {
path.to_path_buf()
} else {
Path::new("/sys").join(path.strip_prefix("/").unwrap_or(path))
};
let Ok(interface) = path.canonicalize() else {
return false;
};
let Some(device) = interface.parent() else {
return false;
};
let Some(hub) = device.parent() else {
return false;
};
matches_id(device, "1a86", "5512")
&& matches_id(hub, "1a40", "0101")
&& fs::read_dir(hub).is_ok_and(|entries| {
entries
.filter_map(Result::ok)
.any(|e| matches_id(&e.path(), "1a86", "7523"))
})
}
pub fn usb_candidates(config: &Config) -> Result<Vec<(u8, u8, String)>> {
let mut candidates = Vec::new();
for entry in fs::read_dir("/sys/bus/usb/devices")? {
let path = entry?.path();
if !matches_id(&path, "1a86", "7523") {
continue;
}
let name = path.file_name().unwrap().to_string_lossy().into_owned();
if config
.usb_path
.as_ref()
.is_some_and(|wanted| wanted != &name)
{
continue;
}
if config.require_companion {
let actual = path.canonicalize()?;
let Some(parent) = actual.parent() else {
continue;
};
// The monitor contains an SPI bridge and UART under the same hub.
// This avoids claiming an unrelated generic CH340 serial adapter.
let sibling = fs::read_dir(parent)?
.filter_map(Result::ok)
.any(|p| matches_id(&p.path(), "1a86", "5512"));
if !sibling {
continue;
}
}
let bus = fs::read_to_string(path.join("busnum"))?.trim().parse()?;
let address = fs::read_to_string(path.join("devnum"))?.trim().parse()?;
candidates.push((bus, address, name));
}
Ok(candidates)
}
fn usb_open(config: &Config) -> Result<Box<dyn Transport>> {
let candidates = usb_candidates(config)?;
ensure!(
candidates.len() == 1,
"expected one matching monitor UART; found {} (set usb_path to disambiguate)",
candidates.len()
);
let (bus, address, name) = &candidates[0];
let devices = rusb::devices()?;
let spi_guard = if config.require_companion {
let uart_path = Path::new("/sys/bus/usb/devices")
.join(name)
.canonicalize()?;
let hub = uart_path.parent().context("UART hub absent")?;
let path = fs::read_dir(hub)?
.filter_map(Result::ok)
.map(|e| e.path())
.find(|p| matches_id(p, "1a86", "5512"))
.context("SPI companion disappeared")?;
let spi_bus: u8 = fs::read_to_string(path.join("busnum"))?.trim().parse()?;
let spi_address: u8 = fs::read_to_string(path.join("devnum"))?.trim().parse()?;
let dev = devices
.iter()
.find(|d| d.bus_number() == spi_bus && d.address() == spi_address)
.context("SPI companion disappeared from USB enumeration")?;
let descriptor = dev.device_descriptor()?;
ensure!(
descriptor.vendor_id() == 0x1a86 && descriptor.product_id() == 0x5512,
"SPI companion identity changed"
);
let guard = dev.open()?;
ensure!(
!guard.kernel_driver_active(0)?,
"monitor SPI interface has a kernel driver; install the scoped udev rule, unbind/unload spi_ch341 when unused, and power-cycle the monitor"
);
guard.claim_interface(0)?;
Some(guard)
} else {
None
};
let device = devices
.iter()
.find(|d| d.bus_number() == *bus && d.address() == *address)
.context("UART disappeared during enumeration")?;
let descriptor = device.device_descriptor()?;
ensure!(
descriptor.vendor_id() == 0x1a86 && descriptor.product_id() == 0x7523,
"USB device changed during enumeration"
);
let handle = device.open()?;
handle.set_auto_detach_kernel_driver(true)?;
if handle.active_configuration()? != 1 {
handle.set_active_configuration(1)?;
}
handle.claim_interface(0)?;
// Rebinding the kernel ch341 driver is unreliable on the affected host.
// Physical reconnection restores normal kernel probing after daemon exit.
handle.set_auto_detach_kernel_driver(false)?;
let cfg = device.active_config_descriptor()?;
let iface = cfg
.interfaces()
.find(|i| i.number() == 0)
.context("missing CH340 interface 0")?;
let endpoints: Vec<_> = iface
.descriptors()
.flat_map(|i| {
i.endpoint_descriptors()
.map(|e| e.address())
.collect::<Vec<_>>()
})
.collect();
ensure!(
endpoints.contains(&0x02) && endpoints.contains(&0x82),
"unexpected UART endpoints"
);
// Same 115200 8N1 sequence used by the confirmed Python helper. No SPI I/O.
for (request, value, index) in [
(0xa1, 0, 0),
(0x9a, 0x1312, 0xcc03),
(0x9a, 0x2518, 0x00c3),
(0xa4, 0x00ff, 0),
] {
handle.write_control(0x40, request, value, index, &[], Duration::from_secs(1))?;
}
Ok(Box::new(Usb {
handle,
_spi_guard: spi_guard,
last_write: None,
name: format!("usb:{name} ({bus:03}/{address:03})"),
}))
}
struct Serial {
file: File,
name: String,
last_write: Option<Instant>,
}
impl Drop for Serial {
fn drop(&mut self) {
// SAFETY: release only the exclusive flag on our still-open tty.
unsafe {
libc::ioctl(self.file.as_raw_fd(), libc::TIOCNXCL);
}
}
}
impl Transport for Serial {
fn write(&mut self, bytes: &[u8]) -> Result<()> {
pace(self.last_write);
self.file.write_all(bytes)?;
self.last_write = Some(Instant::now());
Ok(())
}
fn read(&mut self, bytes: &mut [u8]) -> Result<usize> {
let mut poll = libc::pollfd {
fd: self.file.as_raw_fd(),
events: libc::POLLIN,
revents: 0,
};
// SAFETY: poll points to one initialized pollfd for a live descriptor.
let n = unsafe { libc::poll(&mut poll, 1, 50) };
if n < 0 {
return Err(std::io::Error::last_os_error().into());
}
if poll.revents & (libc::POLLHUP | libc::POLLERR | libc::POLLNVAL) != 0 {
bail!("serial device disconnected");
}
if n == 0 {
return Ok(0);
}
Ok(self.file.read(bytes)?)
}
fn label(&self) -> &str {
&self.name
}
}
pub fn open(config: &Config) -> Result<Box<dyn Transport>> {
if config.transport == "usb" {
return usb_open(config);
}
let path = config
.serial_device
.as_ref()
.context("missing serial_device")?;
let file = OpenOptions::new()
.read(true)
.write(true)
.custom_flags(libc::O_NOCTTY | libc::O_NONBLOCK)
.open(path)?;
let fd = file.as_raw_fd();
// SAFETY: termios is initialized by tcgetattr; all operations use a live fd.
unsafe {
let mut attrs = std::mem::zeroed();
if libc::tcgetattr(fd, &mut attrs) != 0 {
return Err(std::io::Error::last_os_error().into());
}
libc::cfmakeraw(&mut attrs);
libc::cfsetispeed(&mut attrs, libc::B115200);
libc::cfsetospeed(&mut attrs, libc::B115200);
attrs.c_cflag = (attrs.c_cflag
& !(libc::CRTSCTS | libc::CSTOPB | libc::PARENB | libc::CSIZE))
| libc::CS8
| libc::CLOCAL
| libc::CREAD;
if libc::tcsetattr(fd, libc::TCSANOW, &attrs) != 0 || libc::ioctl(fd, libc::TIOCEXCL) != 0 {
return Err(std::io::Error::last_os_error().into());
}
}
Ok(Box::new(Serial {
file,
last_write: None,
name: format!("serial:{}", path.display()),
}))
}
+168
View File
@@ -0,0 +1,168 @@
#!/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())