FDS/OS 1.0
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
@@ -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 (¶meter, &value) in &config.startup {
|
||||
parameter.validate(value)?;
|
||||
}
|
||||
Ok(config)
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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(¶meter);
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
}))
|
||||
}
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user