app: GPU hot-plug (re-detection every minute, WM_DEVICECHANGE on Windows), faulty cards listed with the problem code, integrated GPUs off by default, the card list in the console

5 October 2026: an RX 9070 XT went into PC 1 through a Sonnet USB4 box while the app ran and nothing noticed; the
app detected cards once at start. Now src/hotplug.rs compares every enumeration with the list (key, else vendor +
name when unique; a card whose tool did not answer is never called removed): a new usable card starts a worker,
enabled by default like a card at start, with "New card: <name>, mining" on the strip and in the log; a card
Windows lists with a problem code (Win32_VideoController Status / ConfigManagerErrorCode) is shown as "<name>: not
usable (Code 43)" with the reboot-or-reinstall hint and no worker; a card that disappears has its worker stopped
(quit, 8 s) and its row says removed for five minutes, then hides; an unchanged list touches nothing. The Windows
host sends "detect" on WM_DEVICECHANGE; the engine polls every 60 s (300 s on macOS, no GPU hot-plug there).

detect.rs: the Ryzen iGPU is "gfx1036" to the OpenCL worker, so the APU gfx codes count as integrated, plus the
adapter row's Intel processor string and a dedicated memory under 1 GB; integrated defaults to off with "integrated
GPU, off by default (2 to 3 MH/s for 30 W)" on the row, and the user's choice is kept across re-detections and
restarts (settings, found by key or by vendor + name when the index moved).

Console: the engine logs "cards: <name> [<kind>, <state>] | ..." at start, on every change and every 10 minutes;
relay/lib/parse.mjs reads it and the hot-plug events, the machines API and tools/console.mjs machines show them.

Tests: hotplug.rs (added, removed, moved, errored, recovered, revived, unchanged, twins, user override kept,
the console line), detect.rs (PC 1's adapter lines, the Mac, kind classification, the unusable row),
notices.test.mjs (card notices), relay parse.test.mjs (cards line). cargo test -p igneum-app: 91 passed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-05 17:45:05 +00:00
parent 7d09857f91
commit d7049fdfda
14 changed files with 1133 additions and 133 deletions

View file

@ -1,6 +1,8 @@
//! GPU detection with the real names. macOS: the Metal worker's ready line (the device Metal reports) plus the core
//! count from system_profiler. Windows: nvidia-smi for NVIDIA cards, the OpenCL worker's --list for the rest
//! (AMD, Intel), and the WMI name list as a last resort when neither tool runs.
//! (AMD, Intel), the Windows adapter list (Win32_VideoController: status, problem code, memory) for the cards no
//! worker can drive and for the integrated-or-discrete call, and that list's names as a last resort when neither
//! tool runs. The engine runs this at start and again every minute (src/hotplug.rs compares the two lists).
use crate::state::CardState;
use std::io::Write;
@ -18,6 +20,36 @@ pub struct Bins {
pub dir: std::path::PathBuf,
}
/// One enumeration: the cards, the notes for the setup screen, and which tools answered. A tool that did not answer
/// (nvidia-smi timed out, the OpenCL worker crashed) says nothing about its cards: the engine keeps them rather
/// than calling them removed (src/hotplug.rs).
#[derive(Clone, Default)]
pub struct Detection {
pub cards: Vec<CardState>,
pub notes: Vec<String>,
pub nvidia_listed: bool,
pub opencl_listed: bool,
pub adapters_listed: bool,
pub metal_listed: bool,
}
impl Detection {
/// Whether this enumeration can say that `c` is gone: the tool that lists its vendor answered.
pub fn listed(&self, c: &CardState) -> bool {
if !c.problem.is_empty() {
return self.adapters_listed;
}
match c.vendor.as_str() {
"apple" => self.metal_listed,
"nvidia" => self.nvidia_listed,
_ => self.opencl_listed || (c.device.is_empty() && self.adapters_listed),
}
}
}
/// The hint on a card the OS reports as faulty (Windows Code 43, 12, 31 and friends).
pub const PROBLEM_HINT: &str = "reboot with the card attached; if it persists, reinstall the driver with the card attached";
/// Runs a command with a time limit; returns stdout (and stderr appended) or None.
pub fn run_timeout(cmd: &mut Command, stdin_text: Option<&str>, limit: Duration) -> Option<String> {
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
@ -68,14 +100,125 @@ fn card(index: usize, name: &str, vendor: &str, worker: &str, detail: &str, devi
}
}
/// Integrated GPUs by name: AMD APUs ("Radeon Graphics", "Vega 8"), Intel iGPUs (Iris, UHD, HD Graphics, Arc A3xx is discrete).
#[allow(dead_code)]
/// Integrated GPUs by name: AMD APUs ("Radeon Graphics", "Vega 8"), Intel iGPUs (Iris, UHD, HD Graphics, Arc A3xx is
/// discrete), and the gfx codes AMD's OpenCL runtime reports instead of a marketing name (the worker's --list prints
/// CL_DEVICE_NAME: PC 1's Ryzen iGPU is "gfx1036", 5 October 2026).
pub fn looks_integrated(name: &str) -> bool {
let n = name.to_ascii_lowercase();
let integrated = ["radeon(tm) graphics", "radeon graphics", "vega 8", "vega 7", "vega 6", "vega 3", "vega 11", "iris", "uhd graphics", "hd graphics", "intel(r) graphics", "intel graphics", "apu", "780m", "760m", "680m", "610m", "890m", "880m"];
integrated.iter().any(|k| n.contains(k)) && !n.contains("arc ")
if integrated.iter().any(|k| n.contains(k)) && !n.contains("arc ") {
return true;
}
// AMD APU graphics by gfx code (approximate list from AMD's ROCm and Mesa target tables): Raven/Picasso gfx902 and
// gfx909, Renoir/Cezanne/Lucienne gfx90c, Van Gogh gfx1033, Rembrandt gfx1035, Raphael/Granite Ridge gfx1036,
// Mendocino gfx1037, Phoenix gfx1103, Strix gfx1150 to gfx1152. Discrete codes (gfx1030 and so on) are not here.
let apu = ["gfx902", "gfx909", "gfx90c", "gfx1033", "gfx1035", "gfx1036", "gfx1037", "gfx1103", "gfx1150", "gfx1151", "gfx1152"];
let code = n.trim();
apu.iter().any(|k| code == *k || code.starts_with(&format!("{k}:")) || code.starts_with(&format!("{k} ")))
}
/// One row of Windows' adapter list (Win32_VideoController), the part this app reads.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Adapter {
pub name: String,
/// "OK", "Error", "Degraded", ... (the Status property)
pub status: String,
/// the PnP problem code (ConfigManagerErrorCode): 0 = fine, 43 = the driver stopped it, 12 = no resources, 31 = not loaded
pub code: u32,
/// AdapterRAM in MB; 0 = unknown (a faulty card reports 0, and the property caps at 4 GB on 32-bit values)
pub ram_mb: u64,
pub processor: String,
pub pnp_id: String,
}
impl Adapter {
/// "Code 43" for a problem code, "status Error" for a bad status without one, None when the device is fine.
pub fn problem(&self) -> Option<String> {
if self.code != 0 {
return Some(format!("Code {}", self.code));
}
let st = self.status.trim();
if !st.is_empty() && !st.eq_ignore_ascii_case("ok") {
return Some(format!("status {st}"));
}
None
}
}
/// Integrated or discrete, from the name (APU and iGPU names, AMD gfx codes) and, when Windows' adapter row is
/// known, its processor string or a dedicated memory under 1 GB (a shared-memory iGPU; 0 = unknown, says nothing).
pub fn classify_kind(name: &str, adapter: Option<&Adapter>) -> &'static str {
if looks_integrated(name) {
return "integrated";
}
if let Some(a) = adapter {
// the processor string names Intel iGPUs ("Intel(R) Iris(R) Xe Graphics Family"); AMD's reads "AMD Radeon
// Graphics Processor (0x7550)" for discrete cards too, so only the Intel markers count here
let proc_ = a.processor.to_ascii_lowercase();
if looks_integrated(&a.name) || (["iris", "uhd graphics", "hd graphics"].iter().any(|k| proc_.contains(k)) && !proc_.contains("arc")) {
return "integrated";
}
if a.ram_mb > 0 && a.ram_mb < 1024 {
return "integrated";
}
}
"discrete"
}
pub fn vendor_of(name: &str) -> &'static str {
let n = name.to_ascii_lowercase();
if n.contains("nvidia") || n.contains("geforce") {
"nvidia"
} else if n.contains("amd") || n.contains("radeon") || n.starts_with("gfx") {
"amd"
} else if n.contains("apple") {
"apple"
} else {
"other"
}
}
/// Parses `Get-CimInstance Win32_VideoController | Select-Object ... | ConvertTo-Json` (one object or an array).
pub fn parse_adapters(json: &str) -> Vec<Adapter> {
let Ok(v) = serde_json::from_str::<serde_json::Value>(json.trim()) else { return Vec::new() };
let rows: Vec<serde_json::Value> = match v {
serde_json::Value::Array(a) => a,
o @ serde_json::Value::Object(_) => vec![o],
_ => Vec::new(),
};
let s = |r: &serde_json::Value, k: &str| r.get(k).and_then(|x| x.as_str()).unwrap_or("").trim().to_string();
let n = |r: &serde_json::Value, k: &str| r.get(k).and_then(|x| x.as_u64().or_else(|| x.as_str().and_then(|t| t.trim().parse::<u64>().ok()))).unwrap_or(0);
rows.iter()
.map(|r| Adapter { name: s(r, "Name"), status: s(r, "Status"), code: n(r, "ConfigManagerErrorCode") as u32, ram_mb: n(r, "AdapterRAM") / (1024 * 1024), processor: s(r, "VideoProcessor"), pnp_id: s(r, "PNPDeviceID") })
.filter(|a| !a.name.is_empty())
.collect()
}
/// Windows' adapter list through PowerShell (about a second); None when PowerShell did not answer.
#[cfg(windows)]
pub fn adapters() -> Option<Vec<Adapter>> {
let script = "Get-CimInstance Win32_VideoController | Select-Object Name,Status,ConfigManagerErrorCode,AdapterRAM,VideoProcessor,PNPDeviceID | ConvertTo-Json -Compress";
let out = run_timeout(Command::new(crate::platform::tool("powershell")).args(["-NoProfile", "-Command", script]), None, Duration::from_secs(15))?;
let start = out.find(|c| c == '[' || c == '{')?;
Some(parse_adapters(&out[start..]))
}
#[cfg(not(windows))]
#[allow(dead_code)]
pub fn adapters() -> Option<Vec<Adapter>> {
None
}
/// Windows' row for a detected card, by name (nvidia-smi and Windows agree on NVIDIA names; AMD's OpenCL runtime
/// reports gfx codes, which match nothing here and fall back to the name rules).
pub fn adapter_for<'a>(name: &str, adapters: &'a [Adapter]) -> Option<&'a Adapter> {
let n = name.trim().to_ascii_lowercase();
adapters.iter().find(|a| a.name.trim().to_ascii_lowercase() == n)
}
/// The row's words for an integrated GPU that is off by default (the switch turns it on; the choice is kept).
pub const INTEGRATED_REASON: &str = "integrated GPU, off by default (2 to 3 MH/s for 30 W)";
/// The defaults the launchers use: discrete cards on (8 identities on a big card, 2 on a small one), integrated off
/// (1 identity), Apple silicon on with 1.
pub fn apply_defaults(c: &mut CardState) {
@ -87,7 +230,7 @@ pub fn apply_defaults(c: &mut CardState) {
"integrated" => {
c.enabled = false;
c.identities = 1;
c.reason = "integrated: about 3 MH/s and it shares your system memory. Switch it on if you want it.".into();
c.reason = INTEGRATED_REASON.into();
}
_ => {
c.enabled = true;
@ -148,19 +291,20 @@ pub fn nvidia_power_limits() -> std::collections::HashMap<String, (f64, f64, f64
}
#[cfg(target_os = "macos")]
pub fn detect(bins: &Bins, notes: &mut Vec<String>) -> Vec<CardState> {
let mut cards = Vec::new();
pub fn detect(bins: &Bins) -> Detection {
let mut d = Detection::default();
let Some(metal) = bins.metal.as_ref() else {
notes.push("the Metal worker (igneum-bench) is missing from the app".into());
return cards;
d.notes.push("the Metal worker (igneum-bench) is missing from the app".into());
return d;
};
// the worker's own ready line: "ready metal Apple_M5_Max dataset-log2 28 batch 4194304 prepare 1"
let out = run_timeout(Command::new(metal).arg("--serve"), Some("quit\n"), Duration::from_secs(20)).unwrap_or_default();
let ready = out.lines().find(|l| l.starts_with("ready "));
let Some(ready) = ready else {
notes.push(format!("the Metal worker did not report ready: {}", out.lines().last().unwrap_or("no output")));
return cards;
d.notes.push(format!("the Metal worker did not report ready: {}", out.lines().last().unwrap_or("no output")));
return d;
};
d.metal_listed = true;
let fields: Vec<&str> = ready.split_whitespace().collect();
let name = fields.get(2).map(|s| s.replace('_', " ")).unwrap_or_else(|| "Apple GPU".into());
let prepare = fields.windows(2).any(|w| w[0] == "prepare" && w[1] == "1");
@ -175,58 +319,62 @@ pub fn detect(bins: &Bins, notes: &mut Vec<String>) -> Vec<CardState> {
}
}
}
if let Some(mem) = run_timeout(Command::new(crate::platform::tool("sysctl")).args(["-n", "hw.memsize"]), None, Duration::from_secs(3)) {
if let Ok(b) = mem.trim().parse::<u64>() {
let gb = b / (1024 * 1024 * 1024);
detail = if detail.is_empty() { format!("{gb} GB unified memory") } else { format!("{detail}, {gb} GB unified memory") };
}
let mem = run_timeout(Command::new(crate::platform::tool("sysctl")).args(["-n", "hw.memsize"]), None, Duration::from_secs(3)).and_then(|m| m.trim().parse::<u64>().ok());
if let Some(b) = mem {
let gb = b / (1024 * 1024 * 1024);
detail = if detail.is_empty() { format!("{gb} GB unified memory") } else { format!("{detail}, {gb} GB unified memory") };
}
if !prepare {
notes.push("this Metal worker has no prepare support; the miner restarts at the hour boundary".into());
d.notes.push("this Metal worker has no prepare support; the miner restarts at the hour boundary".into());
}
let mut c = card(0, &name, "apple", "Metal", &detail, "");
c.kind = "apple".into();
c.path = "prebuilt".into();
if let Some(mem) = run_timeout(Command::new(crate::platform::tool("sysctl")).args(["-n", "hw.memsize"]), None, Duration::from_secs(3)) {
c.vram_mb = mem.trim().parse::<u64>().map(|b| b / (1024 * 1024)).unwrap_or(0);
}
c.vram_mb = mem.map(|b| b / (1024 * 1024)).unwrap_or(0);
apply_defaults(&mut c);
mark_sweep_support(&mut c);
cards.push(c);
cards
d.cards.push(c);
d
}
#[cfg(not(target_os = "macos"))]
pub fn detect(bins: &Bins, notes: &mut Vec<String>) -> Vec<CardState> {
let mut cards: Vec<CardState> = Vec::new();
pub fn detect(bins: &Bins) -> Detection {
let mut d = Detection::default();
// Windows' own view of every adapter: status and problem code (a Code 43 card is in no tool's list), memory and
// processor for the integrated call, names for the last resort
let adapters = adapters();
d.adapters_listed = adapters.is_some();
let adapters = adapters.unwrap_or_default();
// NVIDIA: nvidia-smi ships with the driver
let smi = run_timeout(Command::new(crate::platform::tool("nvidia-smi")).args(["--query-gpu=index,name,memory.total", "--format=csv,noheader"]), None, Duration::from_secs(10));
let smi = run_timeout(Command::new(crate::platform::tool("nvidia-smi")).args(["--query-gpu=index,name,memory.total,pci.bus_id", "--format=csv,noheader"]), None, Duration::from_secs(10));
match smi {
Some(out) => {
d.nvidia_listed = true;
for line in out.lines() {
let parts: Vec<&str> = line.split(',').map(|s| s.trim()).collect();
if parts.len() >= 2 && parts[0].chars().all(|c| c.is_ascii_digit()) && !parts[0].is_empty() {
let mem_mb: u64 = parts.get(2).and_then(|m| m.split_whitespace().next()).and_then(|n| n.parse::<f64>().ok()).map(|v| v as u64).unwrap_or(0);
let detail = if mem_mb > 0 { format!("{} GB", (mem_mb + 512) / 1024) } else { String::new() };
let worker_ok = bins.cuda.is_some();
let mut c = card(cards.len(), parts[1], "nvidia", "CUDA", &detail, parts[0]);
c.kind = if looks_integrated(parts[1]) { "integrated".into() } else { "discrete".into() };
let mut c = card(d.cards.len(), parts[1], "nvidia", "CUDA", &detail, parts[0]);
c.bus = parts.get(3).map(|b| b.to_string()).unwrap_or_default();
c.kind = classify_kind(parts[1], adapter_for(parts[1], &adapters)).into();
c.vram_mb = mem_mb;
c.path = if worker_ok { "prebuilt".into() } else { "build".into() };
if !worker_ok {
c.message = "no prebuilt CUDA worker in the package; built from source on first run (needs the CUDA Toolkit and Visual Studio)".into();
}
apply_defaults(&mut c);
cards.push(c);
d.cards.push(c);
}
}
if cards.is_empty() {
notes.push("nvidia-smi ran but listed no card".into());
if d.cards.is_empty() {
d.notes.push("nvidia-smi ran but listed no card".into());
}
let limits = nvidia_power_limits();
for c in cards.iter_mut() {
if let Some((d, cur, lo, hi)) = limits.get(&c.device) {
c.power_default_w = *d;
for c in d.cards.iter_mut() {
if let Some((dflt, cur, lo, hi)) = limits.get(&c.device) {
c.power_default_w = *dflt;
c.power_limit_w = *cur;
c.power_before_w = *cur;
c.power_min_w = *lo;
@ -235,12 +383,13 @@ pub fn detect(bins: &Bins, notes: &mut Vec<String>) -> Vec<CardState> {
mark_sweep_support(c);
}
}
None => notes.push("nvidia-smi is not on this PC (no NVIDIA driver): no NVIDIA card".into()),
None => d.notes.push("nvidia-smi is not on this PC (no NVIDIA driver): no NVIDIA card".into()),
}
// OpenCL: the worker's own device list (AMD, Intel; NVIDIA shows there too and is skipped)
if let Some(cl) = bins.opencl.as_ref() {
if let Some(out) = run_timeout(Command::new(cl).arg("--list"), None, Duration::from_secs(15)) {
let lines: Vec<&str> = out.lines().collect();
d.opencl_listed = lines.iter().any(|l| l.starts_with("OpenCL devices"));
for (i, line) in lines.iter().enumerate() {
let t = line.trim_start_matches(|c| c == ' ' || c == '*').trim();
if !t.starts_with('[') {
@ -256,34 +405,59 @@ pub fn detect(bins: &Bins, notes: &mut Vec<String>) -> Vec<CardState> {
if !is_gpu || name.contains("NVIDIA") || vendor_s.contains("NVIDIA") {
continue;
}
let vendor = if vendor_s.contains("Advanced Micro") || name.contains("Radeon") || name.contains("AMD") { "amd" } else { "other" };
let vendor = if vendor_s.contains("Advanced Micro") || name.contains("Radeon") || name.contains("AMD") || name.starts_with("gfx") { "amd" } else { "other" };
let units = info.split(", ").find(|p| p.contains("compute units")).unwrap_or("").to_string();
let mut c = card(cards.len(), name, vendor, "OpenCL", &units, idx);
let mut c = card(d.cards.len(), name, vendor, "OpenCL", &units, idx);
c.device = idx.to_string();
c.kind = if looks_integrated(name) { "integrated".into() } else { "discrete".into() };
c.kind = classify_kind(name, adapter_for(name, &adapters)).into();
c.path = "prebuilt".into();
apply_defaults(&mut c);
mark_sweep_support(&mut c);
cards.push(c);
d.cards.push(c);
}
}
} else if cards.is_empty() {
notes.push("the OpenCL worker is not installed; AMD and Intel cards cannot be listed".into());
} else if d.cards.is_empty() {
d.notes.push("the OpenCL worker is not installed; AMD and Intel cards cannot be listed".into());
}
if cards.is_empty() {
if d.cards.is_empty() {
// last resort: the names Windows knows, so the screen can at least say what is in the PC
if let Some(out) = run_timeout(Command::new(crate::platform::tool("powershell")).args(["-NoProfile", "-Command", "Get-CimInstance Win32_VideoController | ForEach-Object { $_.Name }"]), None, Duration::from_secs(15)) {
for n in out.lines().map(|l| l.trim()).filter(|l| !l.is_empty()) {
let vendor = if n.contains("NVIDIA") { "nvidia" } else if n.contains("AMD") || n.contains("Radeon") { "amd" } else { "other" };
let mut c = card(cards.len(), n, vendor, if vendor == "nvidia" { "CUDA" } else { "OpenCL" }, "", "0");
c.kind = if looks_integrated(n) { "integrated".into() } else { "unknown".into() };
c.enabled = false;
c.reason = "seen by Windows, but no worker can drive it (no NVIDIA driver and no OpenCL worker)".into();
cards.push(c);
}
for a in adapters.iter().filter(|a| a.problem().is_none()) {
let vendor = vendor_of(&a.name);
let mut c = card(d.cards.len(), &a.name, vendor, if vendor == "nvidia" { "CUDA" } else { "OpenCL" }, "", "");
c.kind = if looks_integrated(&a.name) { "integrated".into() } else { "unknown".into() };
c.enabled = false;
c.reason = "seen by Windows, but no worker can drive it (no NVIDIA driver and no OpenCL worker)".into();
d.cards.push(c);
}
}
cards
// the cards Windows lists with a problem (Code 43 after an eGPU hot-plug on PC 1, 5 October 2026): shown, never driven
for a in adapters.iter() {
let Some(problem) = a.problem() else { continue };
if d.cards.iter().any(|c| c.name.trim().eq_ignore_ascii_case(a.name.trim())) {
continue;
}
let vendor = vendor_of(&a.name);
let mut c = card(d.cards.len(), &a.name, vendor, if vendor == "nvidia" { "CUDA" } else { "OpenCL" }, "", "");
c.bus = a.pnp_id.clone();
c.kind = classify_kind(&a.name, Some(a)).into();
c.vram_mb = a.ram_mb;
mark_unusable(&mut c, &problem);
d.cards.push(c);
}
d
}
/// A listed card no worker can drive: off, no switch, the problem on the row and the hint under it.
pub fn mark_unusable(c: &mut CardState, problem: &str) {
c.problem = problem.to_string();
c.enabled = false;
c.identities = 1;
c.state = "unusable".into();
c.message = format!("not usable ({problem})");
c.reason = PROBLEM_HINT.into();
c.sweep_supported = false;
c.sweep_state = "unsupported".into();
c.sweep_note = c.message.clone();
}
/// Finds the binaries next to the engine (Windows, a plain folder) or in Contents/Resources/bin (macOS bundle).
@ -322,3 +496,105 @@ pub fn node_version(node: &Path) -> String {
.and_then(|o| o.lines().next().map(|l| l.trim().to_string()))
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
// PC 1's adapter list on the evening of 5 October 2026, after the RX 9070 XT went in through the Sonnet eGPU box
// while the app ran: "AMD Radeon RX 9070 XT | status Error | ram 0 GB", "AMD Radeon(TM) Graphics | status OK |
// ram 2 GB" (the Ryzen iGPU, gfx1036 to OpenCL), "NVIDIA GeForce RTX 5090 | status OK".
fn pc1() -> Vec<Adapter> {
parse_adapters(r#"[{"Name":"AMD Radeon RX 9070 XT","Status":"Error","ConfigManagerErrorCode":43,"AdapterRAM":0,"VideoProcessor":"AMD Radeon Graphics Processor (0x7550)","PNPDeviceID":"PCI\\VEN_1002&DEV_7550&SUBSYS_0E4E1002&REV_C0\\6&1A2B3C4D&0&00000008"},
{"Name":"AMD Radeon(TM) Graphics","Status":"OK","ConfigManagerErrorCode":0,"AdapterRAM":2147483648,"VideoProcessor":"AMD Radeon Graphics Processor (0x164E)","PNPDeviceID":"PCI\\VEN_1002&DEV_164E&SUBSYS_00000000&REV_C1\\4&2E5A1B3&0&0041"},
{"Name":"NVIDIA GeForce RTX 5090","Status":"OK","ConfigManagerErrorCode":0,"AdapterRAM":4293918720,"VideoProcessor":"NVIDIA GeForce RTX 5090","PNPDeviceID":"PCI\\VEN_10DE&DEV_2B85&SUBSYS_10621043&REV_A1\\4&1F2E3D4C&0&0019"}]"#)
}
#[test]
fn adapters_parse_with_status_code_and_memory() {
let a = pc1();
assert_eq!(a.len(), 3);
assert_eq!(a[0].name, "AMD Radeon RX 9070 XT");
assert_eq!(a[0].status, "Error");
assert_eq!(a[0].code, 43);
assert_eq!(a[0].ram_mb, 0);
assert_eq!(a[0].problem().as_deref(), Some("Code 43"));
assert_eq!(a[1].ram_mb, 2048);
assert_eq!(a[1].problem(), None);
assert_eq!(a[2].problem(), None);
// a single adapter: ConvertTo-Json gives one object, not an array
let one = parse_adapters(r#"{"Name":"NVIDIA GeForce RTX 5090","Status":"OK","ConfigManagerErrorCode":0,"AdapterRAM":4293918720,"VideoProcessor":"NVIDIA GeForce RTX 5090","PNPDeviceID":"PCI\\VEN_10DE"}"#);
assert_eq!(one.len(), 1);
assert!(parse_adapters("not json").is_empty());
}
#[test]
fn problem_without_a_code_is_the_status_word() {
let a = Adapter { name: "x".into(), status: "Degraded".into(), ..Default::default() };
assert_eq!(a.problem().as_deref(), Some("status Degraded"));
let fine = Adapter { name: "x".into(), status: "OK".into(), ..Default::default() };
assert_eq!(fine.problem(), None);
let code12 = Adapter { name: "x".into(), status: "Error".into(), code: 12, ..Default::default() };
assert_eq!(code12.problem().as_deref(), Some("Code 12"));
}
#[test]
fn kind_from_pc1_lines_and_the_mac() {
let a = pc1();
// the discrete cards, with and without their adapter row
assert_eq!(classify_kind("AMD Radeon RX 9070 XT", adapter_for("AMD Radeon RX 9070 XT", &a)), "discrete");
assert_eq!(classify_kind("NVIDIA GeForce RTX 5090", adapter_for("NVIDIA GeForce RTX 5090", &a)), "discrete");
assert_eq!(classify_kind("NVIDIA GeForce RTX 5090", None), "discrete");
// the Ryzen iGPU: by its Windows name, and by the gfx code the OpenCL worker prints (no adapter row matches a code)
assert_eq!(classify_kind("AMD Radeon(TM) Graphics", adapter_for("AMD Radeon(TM) Graphics", &a)), "integrated");
assert_eq!(classify_kind("gfx1036", adapter_for("gfx1036", &a)), "integrated");
assert_eq!(classify_kind("gfx1036", None), "integrated");
assert_eq!(classify_kind("gfx1036:xnack-", None), "integrated");
// a discrete gfx code stays discrete; an Arc card is discrete despite "Intel"
assert_eq!(classify_kind("gfx1201", None), "discrete");
assert_eq!(classify_kind("gfx1030", None), "discrete");
assert_eq!(classify_kind("Intel(R) Arc(TM) A770 Graphics", None), "discrete");
// an Intel iGPU by its processor string; an AMD discrete card's processor string ("AMD Radeon Graphics Processor") does not count
let intel = Adapter { name: "Intel(R) Iris(R) Xe Graphics".into(), processor: "Intel(R) Iris(R) Xe Graphics Family".into(), ram_mb: 1024, ..Default::default() };
assert_eq!(classify_kind("Intel(R) Iris(R) Xe Graphics", Some(&intel)), "integrated");
assert_eq!(a[0].processor, "AMD Radeon Graphics Processor (0x7550)");
// shared memory under 1 GB on the adapter row makes an unknown name integrated; 0 says nothing
let small = Adapter { name: "Some iGPU".into(), ram_mb: 512, ..Default::default() };
assert_eq!(classify_kind("Some iGPU", Some(&small)), "integrated");
let unknown = Adapter { name: "Some card".into(), ram_mb: 0, ..Default::default() };
assert_eq!(classify_kind("Some card", Some(&unknown)), "discrete");
// the Mac: detect() labels Apple silicon "apple" itself; the name rules do not call it integrated
assert!(!looks_integrated("Apple M5 Max"));
assert_eq!(vendor_of("Apple M5 Max"), "apple");
assert_eq!(vendor_of("gfx1036"), "amd");
assert_eq!(vendor_of("AMD Radeon RX 9070 XT"), "amd");
assert_eq!(vendor_of("NVIDIA GeForce RTX 5090"), "nvidia");
}
#[test]
fn unusable_card_row() {
let mut c = card(2, "AMD Radeon RX 9070 XT", "amd", "OpenCL", "", "");
mark_unusable(&mut c, "Code 43");
assert!(!c.enabled);
assert_eq!(c.state, "unusable");
assert_eq!(c.message, "not usable (Code 43)");
assert_eq!(c.reason, PROBLEM_HINT);
assert!(!c.present());
}
#[test]
fn integrated_default_is_off_with_the_row_words() {
let mut c = card(1, "gfx1036", "amd", "OpenCL", "2 compute units", "1");
c.kind = classify_kind("gfx1036", None).into();
apply_defaults(&mut c);
assert!(!c.enabled);
assert_eq!(c.identities, 1);
assert_eq!(c.reason, INTEGRATED_REASON);
let mut big = card(0, "NVIDIA GeForce RTX 5090", "nvidia", "CUDA", "32 GB", "0");
big.kind = "discrete".into();
big.vram_mb = 32768;
apply_defaults(&mut big);
assert!(big.enabled);
assert_eq!(big.identities, 8);
}
}

View file

@ -29,7 +29,8 @@ pub struct CardChoice {
pub enum Cmd {
Detect,
ApplyCards(Vec<CardChoice>),
Detected(Vec<CardState>, Vec<String>),
/// one enumeration of the cards finished (the first, or a re-detection: src/hotplug.rs)
Detected(crate::detect::Detection),
Start,
Pause,
Resume,
@ -436,6 +437,12 @@ pub struct Engine {
wrapper: bool,
last_state_print: Instant,
detected: bool,
/// hot-plug (src/hotplug.rs): an enumeration thread is running; one more was asked for meanwhile; when the
/// next periodic one is due; when the app log last carried the card list
detect_busy: bool,
detect_again: bool,
detect_next: Instant,
last_cards_line: Instant,
clock_samples: Vec<f64>,
clock_node_at: Option<Instant>,
clock_node_behind: f64,
@ -530,6 +537,10 @@ impl Engine {
wrapper,
last_state_print: now,
detected: false,
detect_busy: false,
detect_again: false,
detect_next: now + Duration::from_secs(crate::hotplug::POLL_S),
last_cards_line: now,
clock_samples: Vec::new(),
clock_node_at: None,
clock_node_behind: 0.0,
@ -635,62 +646,34 @@ impl Engine {
fn command(&mut self, c: Cmd) {
match c {
Cmd::Detect => {
if self.detected {
// the first run, /api/detect, the host's "detect" on a device change, and the minute poll all land
// here; one enumeration at a time, and a request during one runs once more after it
if self.detect_busy {
self.detect_again = true;
return;
}
self.st().detecting = true;
self.detect_busy = true;
if !self.detected {
self.st().detecting = true;
}
let bins = self.bins.clone();
let shared = self.shared.clone();
std::thread::spawn(move || {
let mut notes = Vec::new();
let cards = crate::detect::detect(&bins, &mut notes);
shared.send(Cmd::Detected(cards, notes));
let d = crate::detect::detect(&bins);
shared.send(Cmd::Detected(d));
});
}
Cmd::Detected(cards, notes) => {
self.detected = true;
let names: Vec<String> = cards.iter().map(|c| format!("{} ({})", c.name, c.worker)).collect();
self.shared.log(&format!("GPUs: {}", if names.is_empty() { "none usable".to_string() } else { names.join("; ") }));
for n in &notes {
self.shared.log(&format!("detection: {n}"));
Cmd::Detected(d) => {
self.detect_busy = false;
self.detect_next = Instant::now() + Duration::from_secs(crate::hotplug::POLL_S);
if self.detected {
self.merge_detection(d);
} else {
self.first_detection(d);
}
let prefs = self.shared.settings.lock().unwrap().cards.clone();
let mut st = self.st();
st.detecting = false;
st.detect_message = notes.join(". ");
st.mining.cards = cards;
for c in st.mining.cards.iter_mut() {
if let Some(p) = prefs.get(&c.key) {
c.enabled = p.enabled && c.kind != "unknown";
c.identities = p.identities.clamp(1, 64);
c.reason = String::new();
if c.vendor == "nvidia" && p.power_pct > 0 {
c.power_pct = p.power_pct.clamp(crate::sweep::MIN_PCT, 100);
}
c.pinned = p.pinned;
c.sweep_pct = p.sweep_pct;
c.sweep_eff = p.sweep_eff;
c.sweep_watts = p.sweep_watts;
c.sweep_mhs = p.sweep_mhs;
c.sweep_at = p.sweep_at as f64;
}
}
let supported: Vec<String> = st.mining.cards.iter().filter(|c| c.sweep_supported && c.enabled).map(|c| c.key.clone()).collect();
let unsupported: Vec<String> = st.mining.cards.iter().filter(|c| !c.sweep_supported).map(|c| format!("{}: {}", c.name, c.sweep_note)).collect();
drop(st);
for u in &unsupported {
self.shared.log(&format!("efficiency sweep {u}"));
}
if self.shared.runtime.sweep_only {
if supported.is_empty() {
self.sweep_say("SWEEP none reason=no_supported_card");
self.shared.send(Cmd::Quit);
} else {
self.sweep_queue = supported;
}
}
if self.running {
self.plan_miners();
if self.detect_again {
self.detect_again = false;
self.shared.send(Cmd::Detect);
}
}
Cmd::ApplyCards(choices) => self.apply_cards(choices),
@ -850,7 +833,7 @@ impl Engine {
let mut missing = Vec::new();
{
let mut st = self.st();
for c in st.mining.cards.iter_mut().filter(|c| c.vendor == "nvidia" && c.enabled && c.power_default_w > 0.0) {
for c in st.mining.cards.iter_mut().filter(|c| c.vendor == "nvidia" && c.enabled && c.present() && c.power_default_w > 0.0) {
let want = requested_watts(c);
let got = readback.get(&c.device).copied().unwrap_or(c.power_limit_w);
if got > 0.0 {
@ -971,6 +954,139 @@ impl Engine {
// ---- start, node ------------------------------------------------------------------------------------------
/// The first enumeration: the list as detected, the saved choices applied, the setup screen's notes.
fn first_detection(&mut self, d: crate::detect::Detection) {
self.detected = true;
let crate::detect::Detection { cards, notes, .. } = d;
let names: Vec<String> = cards.iter().map(|c| format!("{} ({}{})", c.name, c.worker, if c.problem.is_empty() { String::new() } else { format!(", {}", c.problem) })).collect();
self.shared.log(&format!("GPUs: {}", if names.is_empty() { "none usable".to_string() } else { names.join("; ") }));
for n in &notes {
self.shared.log(&format!("detection: {n}"));
}
let prefs = self.shared.settings.lock().unwrap().cards.clone();
let mut st = self.st();
st.detecting = false;
st.detect_message = notes.join(". ");
st.mining.cards = cards;
for c in st.mining.cards.iter_mut() {
if let Some(p) = crate::hotplug::pref_for(&prefs, c) {
crate::hotplug::apply_pref(c, p);
}
}
let supported: Vec<String> = st.mining.cards.iter().filter(|c| c.sweep_supported && c.enabled).map(|c| c.key.clone()).collect();
let unsupported: Vec<String> = st.mining.cards.iter().filter(|c| !c.sweep_supported).map(|c| format!("{}: {}", c.name, c.sweep_note)).collect();
let line = crate::hotplug::cards_line(&st.mining.cards);
drop(st);
self.shared.log(&line);
self.last_cards_line = Instant::now();
for u in &unsupported {
self.shared.log(&format!("efficiency sweep {u}"));
}
if self.shared.runtime.sweep_only {
if supported.is_empty() {
self.sweep_say("SWEEP none reason=no_supported_card");
self.shared.send(Cmd::Quit);
} else {
self.sweep_queue = supported;
}
}
if self.running {
self.plan_miners();
}
}
/// A later enumeration (src/hotplug.rs): new cards start, lost cards stop, faulty cards are marked; a list
/// that changed nothing touches nothing (no running worker ever restarts because of a re-detection).
fn merge_detection(&mut self, d: crate::detect::Detection) {
let now = crate::platform::unix_now_f();
let old = self.st().mining.cards.clone();
let diff = crate::hotplug::diff(&old, &d.cards, &|c| d.listed(c));
if diff.is_quiet() {
return;
}
let prefs = self.shared.settings.lock().unwrap().cards.clone();
let mut new_nvidia = false;
for i in diff.removed.iter().copied() {
let name = old[i].name.clone();
self.drop_worker(i, "the card was removed");
if let Some(c) = self.st().mining.cards.get_mut(i) {
crate::hotplug::mark_removed(c, now);
}
self.shared.event("warn", &format!("Card removed: {name}; its worker stopped"));
}
for (i, problem) in diff.errored.iter() {
let name = old[*i].name.clone();
self.drop_worker(*i, &format!("the card reports {problem}"));
if let Some(c) = self.st().mining.cards.get_mut(*i) {
crate::detect::mark_unusable(c, problem);
c.added_at = now;
}
self.shared.event("warn", &format!("{name}: not usable ({problem}); its worker stopped. {}", crate::detect::PROBLEM_HINT));
}
for (i, fresh) in diff.moved.iter() {
if let Some(c) = self.st().mining.cards.get_mut(*i) {
self.shared.log(&format!("{}: device {} is now {} (the running worker keeps its device; the next start uses the new one)", c.name, c.device, fresh.device));
c.device = fresh.device.clone();
c.key = fresh.key.clone();
c.bus = fresh.bus.clone();
if fresh.vram_mb > 0 {
c.vram_mb = fresh.vram_mb;
}
if !fresh.detail.is_empty() {
c.detail = fresh.detail.clone();
}
}
}
let mut placed: Vec<(usize, CardState)> = Vec::new();
for (i, fresh) in diff.recovered.into_iter().chain(diff.revived.into_iter()) {
placed.push((i, fresh));
}
let mut next = self.st().mining.cards.len();
for fresh in diff.added.into_iter() {
placed.push((next, fresh));
next += 1;
}
for (i, mut fresh) in placed {
let pref = crate::hotplug::pref_for(&prefs, &fresh).cloned();
crate::hotplug::settle_new(&mut fresh, i, pref.as_ref(), now);
let (kind, text) = crate::hotplug::added_words(&fresh);
new_nvidia |= fresh.vendor == "nvidia" && fresh.enabled;
let mut st = self.st();
if i < st.mining.cards.len() {
st.mining.cards[i] = fresh;
} else {
st.mining.cards.push(fresh);
}
drop(st);
self.shared.event(kind, &text);
}
let line = crate::hotplug::cards_line(&self.st().mining.cards);
self.shared.log(&line);
self.last_cards_line = Instant::now();
if self.running {
self.plan_miners();
if new_nvidia {
self.apply_power_limits("new card");
}
}
}
/// Stops the worker on card `idx` (quit, then up to 8 s) and drops its slot; the sweep on it, if any, is aborted.
fn drop_worker(&mut self, idx: usize, why: &str) {
if self.sweep.as_ref().map(|r| r.card == idx).unwrap_or(false) {
self.sweep_abort(why);
}
let Some(pos) = self.miners.iter().position(|m| m.card == idx) else { return };
let label = self.miners[pos].label.clone();
if let Some(mut p) = self.miners[pos].proc.take() {
self.shared.log(&format!("stopping miner {label} ({why})"));
p.write_stdin("quit\n");
p.stop(8);
}
self.miners.remove(pos);
self.stability.remove(&idx);
}
fn start(&mut self) {
if self.running {
return;
@ -1143,7 +1259,7 @@ impl Engine {
let cards = self.st().mining.cards.clone();
let id8 = self.shared.runtime.id8();
for c in cards.iter() {
if !c.enabled || self.miners.iter().any(|m| m.card == c.index) {
if !c.enabled || !c.present() || self.miners.iter().any(|m| m.card == c.index) {
continue;
}
let prefix = match c.vendor.as_str() {
@ -1307,7 +1423,7 @@ impl Engine {
let mut st = self.st();
let paused = st.mining.paused;
for c in st.mining.cards.iter_mut() {
if c.enabled && c.state != "faulted" {
if c.enabled && c.present() && c.state != "faulted" {
c.state = if paused { "off".into() } else { "waiting".into() };
c.hash_now = 0.0;
c.pid = 0;
@ -1348,6 +1464,9 @@ impl Engine {
let mut st = self.st();
for ch in &choices {
let Some(c) = st.mining.cards.iter_mut().find(|c| c.key == ch.key) else { continue };
if !c.present() {
continue; // a removed or faulty card has no switch; its saved choice waits for it
}
let identities = ch.identities.clamp(1, 64);
let enabled = ch.enabled && c.kind != "unknown";
let power_pct = ch.power_pct.map(|p| p.clamp(crate::sweep::MIN_PCT, 100)).unwrap_or(c.power_pct);
@ -1366,7 +1485,7 @@ impl Engine {
if c.enabled != enabled || c.identities != identities {
c.enabled = enabled;
c.identities = identities;
c.reason = String::new();
c.reason = if !enabled && c.kind == "integrated" { crate::detect::INTEGRATED_REASON.into() } else { String::new() };
if !enabled {
c.state = "off".into();
c.hash_now = 0.0;
@ -1422,7 +1541,7 @@ impl Engine {
let mut what = Vec::new();
{
let mut st = self.st();
for c in st.mining.cards.iter_mut().filter(|c| c.vendor == "nvidia" && c.enabled && c.power_default_w > 0.0) {
for c in st.mining.cards.iter_mut().filter(|c| c.vendor == "nvidia" && c.enabled && c.present() && c.power_default_w > 0.0) {
let pct = if c.power_pct == 0 { 80 } else { c.power_pct.clamp(crate::sweep::MIN_PCT, 100) };
c.power_pct = pct;
let watts = requested_watts(c);
@ -1442,7 +1561,7 @@ impl Engine {
self.shared.log(&format!("power cap ({why}): {}", cmds.join(" & ")));
let line = cmds.join(" & ");
let what = what.join(", ");
let want: std::collections::HashMap<String, f64> = self.st().mining.cards.iter().filter(|c| c.vendor == "nvidia" && c.enabled && c.power_default_w > 0.0).map(|c| (c.device.clone(), requested_watts(c))).collect();
let want: std::collections::HashMap<String, f64> = self.st().mining.cards.iter().filter(|c| c.vendor == "nvidia" && c.enabled && c.present() && c.power_default_w > 0.0).map(|c| (c.device.clone(), requested_watts(c))).collect();
if self.wrapper && cfg!(windows) {
// the window host has a UI context: it shows the administrator prompt and reports back on stdin
self.power_via_host = Some((line.clone(), what.clone(), want, Instant::now()));
@ -1479,7 +1598,7 @@ impl Engine {
.mining
.cards
.iter()
.filter(|c| c.vendor == "nvidia" && c.power_applied && c.power_before_w > 0.0)
.filter(|c| c.vendor == "nvidia" && c.present() && c.power_applied && c.power_before_w > 0.0)
.map(|c| format!("\"{}\" -i {} -pl {}", crate::platform::tool("nvidia-smi").display(), c.device, c.power_before_w.round() as u64))
.collect();
drop(st);
@ -2019,6 +2138,16 @@ impl Engine {
self.last_settings_save = now;
self.shared.save_settings();
}
// hot-plug (src/hotplug.rs): enumerate again every POLL_S, and keep the card list in the log for the console
if self.detected && !self.detect_busy && !self.quitting && now >= self.detect_next {
self.detect_next = now + Duration::from_secs(crate::hotplug::POLL_S);
self.shared.send(Cmd::Detect);
}
if self.detected && now.duration_since(self.last_cards_line) >= Duration::from_secs(crate::hotplug::CARDS_LINE_S) {
self.last_cards_line = now;
let line = crate::hotplug::cards_line(&self.st().mining.cards);
self.shared.log(&line);
}
}
/// The over-the-air updater (src/ota.rs): checks, downloads and stages on its own threads; this tick hands it what
@ -2444,6 +2573,7 @@ impl Engine {
c.eff_mhw = if c.state == "mining" && c.power_w > 1.0 && c.hash_now > 0.0 && unix - c.telemetry_at < 60.0 { c.hash_now / c.power_w } else { 0.0 };
}
st.mining.hash_total = total;
crate::hotplug::age(&mut st.mining.cards, unix);
let cut = unix - 3600.0;
st.mining.found.retain(|t| *t > cut);
if st.node.daa > 0 {

View file

@ -0,0 +1,464 @@
//! Hot-plug: what changed between two enumerations of the cards (src/detect.rs runs one at start and one every
//! minute; the Windows host also asks for one on WM_DEVICECHANGE). Pure, so the rules are unit-tested here; the
//! engine applies the result (start a worker, stop one, mark a row). Born 5 October 2026, when an RX 9070 XT went
//! into PC 1 through an eGPU box while the app ran and nothing noticed.
//!
//! Rules: a card is the same card when its key (vendor:device:name) matches, or, failing that, when vendor and
//! name match and that pair is unique on both sides (a device index that moved because another card arrived). A
//! card missing from a list is removed only when the tool that lists its vendor answered. Removed and faulty cards
//! stay in the engine's list (the other cards' indices are the miner slots), marked, and the dashboard hides a
//! removed row after five minutes.
use crate::config::CardPref;
use crate::state::CardState;
/// How long a removed card's row says "removed" before it hides.
pub const REMOVED_SHOWN_S: f64 = 300.0;
/// How often the engine enumerates again, seconds (macOS has no GPU hot-plug on Apple silicon: slower there).
pub const POLL_S: u64 = if cfg!(target_os = "macos") { 300 } else { 60 };
/// How often the app log carries the full card list, seconds (the console reads it from the log tail).
pub const CARDS_LINE_S: u64 = 600;
#[derive(Default, Debug)]
pub struct Diff {
/// new cards (usable or with a problem), to be appended
pub added: Vec<CardState>,
/// cards that were marked removed earlier and are listed again: (slot, the fresh entry)
pub revived: Vec<(usize, CardState)>,
/// slots whose card is gone
pub removed: Vec<usize>,
/// slots whose card now reports a problem: (slot, "Code 43")
pub errored: Vec<(usize, String)>,
/// slots whose card had a problem and is now driven by a tool again: (slot, the fresh entry)
pub recovered: Vec<(usize, CardState)>,
/// slots whose card is the same but its device index (or memory, bus) changed: (slot, the fresh entry)
pub moved: Vec<(usize, CardState)>,
pub unchanged: Vec<usize>,
}
impl Diff {
/// Nothing to do: every present card is where it was.
pub fn is_quiet(&self) -> bool {
self.added.is_empty() && self.revived.is_empty() && self.removed.is_empty() && self.errored.is_empty() && self.recovered.is_empty() && self.moved.is_empty()
}
}
fn same_identity(a: &CardState, b: &CardState) -> bool {
a.vendor == b.vendor && a.name.trim().eq_ignore_ascii_case(b.name.trim()) && (a.bus.is_empty() || b.bus.is_empty() || a.bus == b.bus)
}
/// Compares the engine's list with a fresh enumeration. `listed(card)` says whether this enumeration's tools could
/// have seen that card (Detection::listed); a card its tool did not answer for is kept, not removed.
pub fn diff(old: &[CardState], fresh: &[CardState], listed: &dyn Fn(&CardState) -> bool) -> Diff {
let mut out = Diff::default();
let mut used = vec![false; fresh.len()];
let mut pair: Vec<Option<usize>> = vec![None; old.len()];
// exact keys first
for (i, o) in old.iter().enumerate() {
if let Some(j) = fresh.iter().enumerate().position(|(j, f)| !used[j] && f.key == o.key) {
used[j] = true;
pair[i] = Some(j);
}
}
// then vendor + name, when that pair is unique among what is still unmatched on both sides
for (i, o) in old.iter().enumerate() {
if pair[i].is_some() {
continue;
}
let cands: Vec<usize> = fresh.iter().enumerate().filter(|(j, f)| !used[*j] && same_identity(o, f)).map(|(j, _)| j).collect();
let twins = old.iter().enumerate().filter(|(k, x)| pair[*k].is_none() && *k != i && same_identity(o, x)).count();
if cands.len() == 1 && twins == 0 {
used[cands[0]] = true;
pair[i] = Some(cands[0]);
}
}
for (i, o) in old.iter().enumerate() {
match pair[i] {
Some(j) => {
let f = &fresh[j];
if o.removed_at > 0.0 {
out.revived.push((i, f.clone()));
} else if o.problem.is_empty() && !f.problem.is_empty() {
out.errored.push((i, f.problem.clone()));
} else if !o.problem.is_empty() && f.problem.is_empty() {
out.recovered.push((i, f.clone()));
} else if !o.problem.is_empty() {
// still faulty: the same problem or a new code, nothing to start or stop
if o.problem != f.problem {
out.errored.push((i, f.problem.clone()));
} else {
out.unchanged.push(i);
}
} else if o.device != f.device || o.key != f.key {
out.moved.push((i, f.clone()));
} else {
out.unchanged.push(i);
}
}
None => {
if o.removed_at > 0.0 {
// already removed: stays hidden or shown as removed
out.unchanged.push(i);
} else if listed(o) {
out.removed.push(i);
} else {
out.unchanged.push(i);
}
}
}
}
for (j, f) in fresh.iter().enumerate() {
if !used[j] {
out.added.push(f.clone());
}
}
out
}
/// The saved choice for a card: by its key, else by vendor and name (the index moved since the choice was saved).
pub fn pref_for<'a>(prefs: &'a std::collections::HashMap<String, CardPref>, c: &CardState) -> Option<&'a CardPref> {
if let Some(p) = prefs.get(&c.key) {
return Some(p);
}
let head = format!("{}:", c.vendor);
let tail = format!(":{}", c.name);
let mut found: Vec<&CardPref> = prefs.iter().filter(|(k, _)| k.starts_with(&head) && k.ends_with(&tail)).map(|(_, p)| p).collect();
if found.len() == 1 { found.pop() } else { None }
}
/// Applies a saved choice to a freshly detected card (the first detection and every later one use this).
pub fn apply_pref(c: &mut CardState, p: &CardPref) {
if !c.problem.is_empty() {
return;
}
c.enabled = p.enabled && c.kind != "unknown";
c.identities = p.identities.clamp(1, 64);
if c.enabled || c.kind != "integrated" {
c.reason = String::new();
}
if c.vendor == "nvidia" && p.power_pct > 0 {
c.power_pct = p.power_pct.clamp(crate::sweep::MIN_PCT, 100);
}
c.pinned = p.pinned;
c.sweep_pct = p.sweep_pct;
c.sweep_eff = p.sweep_eff;
c.sweep_watts = p.sweep_watts;
c.sweep_mhs = p.sweep_mhs;
c.sweep_at = p.sweep_at as f64;
}
/// A card a re-detection added (or brought back): the saved choice if there is one, else the detect defaults it
/// came with; its slot and the time it appeared.
pub fn settle_new(c: &mut CardState, index: usize, pref: Option<&CardPref>, now: f64) {
c.index = index;
c.added_at = now;
c.removed_at = 0.0;
c.gone = false;
if let Some(p) = pref {
apply_pref(c, p);
}
if c.problem.is_empty() {
c.state = if c.enabled { "waiting".into() } else { "off".into() };
}
}
/// Marks a card unplugged: no worker, the row says removed, the saved choice is untouched.
pub fn mark_removed(c: &mut CardState, now: f64) {
c.removed_at = now;
c.gone = false;
c.state = "removed".into();
c.message = "removed".into();
c.hash_now = 0.0;
c.pid = 0;
c.restart_in_s = 0;
c.ready = false;
c.prepared = false;
}
/// A removed row hides after REMOVED_SHOWN_S; returns true when something changed.
pub fn age(cards: &mut [CardState], now: f64) -> bool {
let mut changed = false;
for c in cards.iter_mut() {
let gone = c.removed_at > 0.0 && now - c.removed_at >= REMOVED_SHOWN_S;
if gone != c.gone {
c.gone = gone;
changed = true;
}
}
changed
}
/// The event line for a card that appeared: "New card: <name>, mining" and its kind (ok | info | warn).
pub fn added_words(c: &CardState) -> (&'static str, String) {
if !c.problem.is_empty() {
("warn", format!("New card: {}, not usable ({}); {}", c.name, c.problem, crate::detect::PROBLEM_HINT))
} else if c.enabled {
("ok", format!("New card: {}, mining", c.name))
} else if c.kind == "integrated" {
("info", format!("New card: {}, off ({})", c.name, crate::detect::INTEGRATED_REASON))
} else {
("info", format!("New card: {}, off (switched off in settings)", c.name))
}
}
/// One word for a card's state on the log line and the console: mining, waiting, off, removed, not usable (Code 43).
pub fn state_word(c: &CardState) -> String {
if c.removed_at > 0.0 {
"removed".into()
} else if !c.problem.is_empty() {
format!("not usable ({})", c.problem)
} else if !c.enabled {
"off".into()
} else {
c.state.clone()
}
}
/// The app-log line the console reads (relay/lib/parse.mjs): `cards: <name> [<kind>, <state>] | ...`, hidden rows
/// left out, `cards: none` when nothing is listed.
pub fn cards_line(cards: &[CardState]) -> String {
let parts: Vec<String> = cards.iter().filter(|c| !c.gone).map(|c| format!("{} [{}, {}]", c.name.replace('|', "/").replace('[', "(").replace(']', ")"), c.kind, state_word(c))).collect();
if parts.is_empty() { "cards: none".into() } else { format!("cards: {}", parts.join(" | ")) }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::detect::{classify_kind, mark_unusable, INTEGRATED_REASON};
fn card(name: &str, vendor: &str, device: &str) -> CardState {
let mut c = CardState { index: 0, key: format!("{vendor}:{device}:{name}"), name: name.into(), vendor: vendor.into(), worker: if vendor == "nvidia" { "CUDA".into() } else { "OpenCL".into() }, device: device.into(), enabled: true, state: "off".into(), ..Default::default() };
c.kind = classify_kind(name, None).into();
crate::detect::apply_defaults(&mut c);
c
}
// PC 1 at start on 5 October 2026: the 5090 on nvidia-smi index 0, the Ryzen iGPU as OpenCL device 0 (gfx1036)
fn pc1_start() -> Vec<CardState> {
let mut a = card("NVIDIA GeForce RTX 5090", "nvidia", "0");
a.bus = "00000000:01:00.0".into();
a.state = "mining".into();
let mut b = card("gfx1036", "amd", "0");
b.index = 1;
vec![a, b]
}
fn all_listed(_: &CardState) -> bool {
true
}
fn fresh_pc1_with_egpu() -> Vec<CardState> {
let mut v = pc1_start();
v[0].state = "off".into();
let mut e = card("gfx1201", "amd", "1");
e.index = 2;
v.push(e);
v
}
#[test]
fn unchanged_list_is_quiet() {
let old = pc1_start();
let mut fresh = pc1_start();
fresh[0].state = "off".into(); // runtime state in the fresh list means nothing
let d = diff(&old, &fresh, &all_listed);
assert!(d.is_quiet(), "{d:?}");
assert_eq!(d.unchanged, vec![0, 1]);
}
#[test]
fn a_new_usable_card_is_added_and_nothing_else_moves() {
let old = pc1_start();
let d = diff(&old, &fresh_pc1_with_egpu(), &all_listed);
assert_eq!(d.added.len(), 1);
assert_eq!(d.added[0].name, "gfx1201");
assert_eq!(d.added[0].kind, "discrete");
assert!(d.added[0].enabled);
assert_eq!(d.unchanged, vec![0, 1]);
assert!(d.removed.is_empty() && d.moved.is_empty() && d.errored.is_empty());
}
#[test]
fn a_new_card_with_a_problem_is_added_as_unusable() {
let old = pc1_start();
let mut fresh = pc1_start();
let mut bad = card("AMD Radeon RX 9070 XT", "amd", "");
mark_unusable(&mut bad, "Code 43");
fresh.push(bad);
let d = diff(&old, &fresh, &all_listed);
assert_eq!(d.added.len(), 1);
assert_eq!(d.added[0].problem, "Code 43");
assert!(!d.added[0].enabled);
let (kind, text) = added_words(&d.added[0]);
assert_eq!(kind, "warn");
assert!(text.starts_with("New card: AMD Radeon RX 9070 XT, not usable (Code 43); reboot with the card attached"), "{text}");
assert_eq!(state_word(&d.added[0]), "not usable (Code 43)");
}
#[test]
fn an_unplugged_card_is_removed_only_when_its_tool_answered() {
let old = fresh_pc1_with_egpu();
let fresh = pc1_start();
let d = diff(&old, &fresh, &all_listed);
assert_eq!(d.removed, vec![2]);
assert_eq!(d.unchanged, vec![0, 1]);
// the OpenCL worker did not answer this round: nothing is called removed
let opencl_dead = |c: &CardState| c.vendor == "nvidia";
let d2 = diff(&old, &pc1_start().into_iter().filter(|c| c.vendor == "nvidia").collect::<Vec<_>>(), &opencl_dead);
assert!(d2.removed.is_empty(), "{d2:?}");
assert!(d2.is_quiet());
}
#[test]
fn a_card_whose_index_moved_keeps_its_slot() {
// the eGPU landed before the iGPU in the OpenCL list: the iGPU is device 1 now, the eGPU device 0
let old = pc1_start();
let mut fresh = pc1_start();
fresh[1].device = "1".into();
fresh[1].key = "amd:1:gfx1036".into();
let mut e = card("gfx1201", "amd", "0");
e.index = 2;
fresh.push(e);
let d = diff(&old, &fresh, &all_listed);
assert_eq!(d.moved.len(), 1);
assert_eq!(d.moved[0].0, 1);
assert_eq!(d.moved[0].1.device, "1");
assert_eq!(d.added.len(), 1);
assert!(d.removed.is_empty());
}
#[test]
fn two_identical_cards_are_told_apart_by_key_and_never_swapped() {
let mut a = card("NVIDIA GeForce RTX 5090", "nvidia", "0");
let mut b = card("NVIDIA GeForce RTX 5090", "nvidia", "1");
b.index = 1;
a.bus = "00000000:01:00.0".into();
b.bus = "00000000:02:00.0".into();
let old = vec![a.clone(), b.clone()];
// one of the twins leaves: the other keeps its slot by key; the missing one is removed, not "moved"
let d = diff(&old, &[a.clone()], &all_listed);
assert_eq!(d.unchanged, vec![0]);
assert_eq!(d.removed, vec![1]);
// both twins present, both unmatched by key (indices swapped): the vendor+name rule refuses to guess
let mut a2 = a.clone();
a2.device = "1".into();
a2.key = "nvidia:1:NVIDIA GeForce RTX 5090".into();
a2.bus = "00000000:03:00.0".into();
let mut b2 = b.clone();
b2.device = "0".into();
b2.key = "nvidia:0:NVIDIA GeForce RTX 5090".into();
b2.bus = "00000000:04:00.0".into();
let d2 = diff(&old, &[a2, b2], &all_listed);
// the keys are the identity for twins: slot 0 is nvidia index 0 whichever bus it sits on; nothing restarts
assert!(d2.is_quiet(), "{d2:?}");
assert_eq!(d2.unchanged, vec![0, 1]);
}
#[test]
fn a_driven_card_that_turns_faulty_is_errored_and_recovers_later() {
let old = fresh_pc1_with_egpu();
// the eGPU is still listed by Windows, now with Code 43, and no longer by OpenCL
let mut fresh = pc1_start();
let mut bad = card("gfx1201", "amd", "");
mark_unusable(&mut bad, "Code 43");
fresh.push(bad);
let d = diff(&old, &fresh, &all_listed);
assert_eq!(d.errored, vec![(2, "Code 43".to_string())]);
assert!(d.added.is_empty() && d.removed.is_empty());
// after a reboot with the card attached it is driven again: recovered, same slot
let mut faulty = old.clone();
mark_unusable(&mut faulty[2], "Code 43");
faulty[2].device = String::new();
faulty[2].key = "amd::gfx1201".into();
let d2 = diff(&faulty, &fresh_pc1_with_egpu(), &all_listed);
assert_eq!(d2.recovered.len(), 1);
assert_eq!(d2.recovered[0].0, 2);
assert!(d2.recovered[0].1.problem.is_empty());
// the same problem again next minute: quiet
let d3 = diff(&faulty, &fresh, &all_listed);
assert!(d3.is_quiet(), "{d3:?}");
}
#[test]
fn a_removed_card_that_comes_back_is_revived_in_its_slot() {
let mut old = fresh_pc1_with_egpu();
mark_removed(&mut old[2], 1000.0);
assert_eq!(state_word(&old[2]), "removed");
// still absent: quiet (and hidden after five minutes)
let d = diff(&old, &pc1_start(), &all_listed);
assert!(d.is_quiet(), "{d:?}");
assert!(age(&mut old, 1000.0 + REMOVED_SHOWN_S));
assert!(old[2].gone);
assert!(!age(&mut old, 1000.0 + REMOVED_SHOWN_S + 1.0));
// back: revived in slot 2
let d2 = diff(&old, &fresh_pc1_with_egpu(), &all_listed);
assert_eq!(d2.revived.len(), 1);
assert_eq!(d2.revived[0].0, 2);
assert!(d2.added.is_empty());
let mut back = d2.revived[0].1.clone();
settle_new(&mut back, 2, None, 2000.0);
assert_eq!(back.index, 2);
assert_eq!(back.removed_at, 0.0);
assert!(!back.gone);
assert_eq!(back.added_at, 2000.0);
assert_eq!(back.state, "waiting");
}
#[test]
fn the_users_choice_is_kept_on_a_new_card_and_an_integrated_one_is_off_by_default() {
let mut prefs = std::collections::HashMap::new();
// the user switched the iGPU on earlier and set 2 identities; the setting was saved under OpenCL index 0
prefs.insert("amd:0:gfx1036".to_string(), CardPref { enabled: true, identities: 2, ..Default::default() });
// the iGPU comes back as device 1 (the eGPU took index 0): the choice is found by vendor and name
let mut igpu = card("gfx1036", "amd", "1");
assert_eq!(igpu.kind, "integrated");
assert!(!igpu.enabled);
assert_eq!(igpu.reason, INTEGRATED_REASON);
let p = pref_for(&prefs, &igpu).cloned();
assert!(p.is_some());
settle_new(&mut igpu, 1, p.as_ref(), 5.0);
assert!(igpu.enabled);
assert_eq!(igpu.identities, 2);
assert_eq!(igpu.reason, "");
assert_eq!(igpu.state, "waiting");
// the user switched it off: the row keeps the integrated words
prefs.insert("amd:1:gfx1036".to_string(), CardPref { enabled: false, identities: 1, ..Default::default() });
let mut igpu2 = card("gfx1036", "amd", "1");
let p2 = pref_for(&prefs, &igpu2).cloned();
settle_new(&mut igpu2, 1, p2.as_ref(), 6.0);
assert!(!igpu2.enabled);
assert_eq!(igpu2.reason, INTEGRATED_REASON);
assert_eq!(igpu2.state, "off");
let (kind, text) = added_words(&igpu2);
assert_eq!(kind, "info");
assert_eq!(text, format!("New card: gfx1036, off ({INTEGRATED_REASON})"));
// no saved choice: the detect default (integrated off, discrete on)
let mut egpu = card("gfx1201", "amd", "0");
settle_new(&mut egpu, 2, None, 7.0);
assert!(egpu.enabled);
assert_eq!(added_words(&egpu), ("ok", "New card: gfx1201, mining".to_string()));
// a pref never switches on a card with a problem
let mut bad = card("AMD Radeon RX 9070 XT", "amd", "");
mark_unusable(&mut bad, "Code 43");
settle_new(&mut bad, 3, Some(&CardPref { enabled: true, identities: 8, ..Default::default() }), 8.0);
assert!(!bad.enabled);
assert_eq!(bad.state, "unusable");
// two saved choices with the same vendor and name but different indices: ambiguous, so none is used
prefs.insert("amd:0:gfx1036".to_string(), CardPref { enabled: true, identities: 3, ..Default::default() });
let other = card("gfx1036", "amd", "7");
assert!(pref_for(&prefs, &other).is_none());
}
#[test]
fn the_console_line_lists_every_shown_card_with_kind_and_state() {
let mut cards = fresh_pc1_with_egpu();
cards[0].state = "mining".into();
cards[2].state = "starting".into();
let mut bad = card("AMD Radeon RX 9070 XT", "amd", "");
mark_unusable(&mut bad, "Code 43");
cards.push(bad);
assert_eq!(cards_line(&cards), "cards: NVIDIA GeForce RTX 5090 [discrete, mining] | gfx1036 [integrated, off] | gfx1201 [discrete, starting] | AMD Radeon RX 9070 XT [discrete, not usable (Code 43)]");
mark_removed(&mut cards[2], 10.0);
assert!(cards_line(&cards).contains("gfx1201 [discrete, removed]"));
age(&mut cards, 10.0 + REMOVED_SHOWN_S);
assert!(!cards_line(&cards).contains("gfx1201"));
assert_eq!(cards_line(&[]), "cards: none");
}
}

View file

@ -16,6 +16,7 @@
mod config;
mod detect;
mod engine;
mod hotplug;
mod keys;
mod platform;
mod procs;
@ -135,6 +136,8 @@ fn main() {
"quit" => shared.send(engine::Cmd::Quit),
"pause" => shared.send(engine::Cmd::Pause),
"resume" => shared.send(engine::Cmd::Resume),
// the window host saw WM_DEVICECHANGE (a card plugged in or out): enumerate now, not at the next minute
"detect" => shared.send(engine::Cmd::Detect),
"elevated ok" => shared.send(engine::Cmd::ElevatedDone(Ok(()))),
_ if t.starts_with("elevated fail") => shared.send(engine::Cmd::ElevatedDone(Err(t.trim_start_matches("elevated fail").trim_start_matches(':').trim().to_string()))),
_ => {}

View file

@ -26,7 +26,7 @@ pub struct NodeState {
pub override_restart_wait: String,
}
#[derive(Clone, Serialize, Default)]
#[derive(Clone, Serialize, Default, Debug)]
pub struct CardState {
pub index: usize,
pub key: String, // stable id for the saved preference: vendor:device:name
@ -40,7 +40,7 @@ pub struct CardState {
pub detail: String, // memory, cores
pub device: String, // the worker's --device value (Windows)
pub enabled: bool,
pub state: String, // off | waiting | starting | ready | mining | restarting | failed | faulted (the watchdog gave up on it)
pub state: String, // off | waiting | starting | ready | mining | restarting | failed | faulted (the watchdog gave up on it) | unusable (the OS reports a problem) | removed (unplugged)
pub hash_now: f64, // MH/s, the last interval
pub hash_avg: f64, // MH/s since the start
pub accepted: u64,
@ -57,6 +57,15 @@ pub struct CardState {
pub pid: u32,
pub last_status_age_s: f64,
pub message: String,
/// the device's own problem as the OS reports it ("Code 43" on Windows); set = listed but no worker can drive it
pub problem: String,
/// PCI bus id where the tool gives one (nvidia-smi pci.bus_id); a second way to recognise a card whose index moved
pub bus: String,
/// hot-plug (src/hotplug.rs): unix s when a re-detection added this card (0 = found at start), when it lost it
/// (0 = present), and `gone` once a removed card has been shown as removed for five minutes (the row hides)
pub added_at: f64,
pub removed_at: f64,
pub gone: bool,
/// `WORKER FAULT` lines seen (the miner killed and restarted its worker) and the miner's own `faults=` count
pub faults: u64,
/// shares that failed the miner's CPU re-check this run (`mismatched=` on the STATUS line)
@ -92,6 +101,14 @@ pub struct CardState {
pub race_variants: u32,
}
impl CardState {
/// Listed, driver fine, not unplugged: a worker can run on it (hot-plug keeps removed and faulty cards in the
/// list so the other cards' indices stay put).
pub fn present(&self) -> bool {
self.removed_at == 0.0 && self.problem.is_empty()
}
}
#[derive(Clone, Serialize, Default)]
pub struct MiningState {
pub state: String, // idle | waiting | mining | paused | stopped

View file

@ -175,6 +175,10 @@ body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-card
.gpu-row .meta b{color:var(--ink-2);font-weight:500}
.gpu-row .reason{font-size:var(--t-sm);color:var(--ash)}
.gpu-row .msg{font-size:var(--t-sm);color:var(--ember)}
.gpu-row.removed{opacity:.5}
.gpu-row.removed .ids,.gpu-row.unusable .ids{opacity:.3;pointer-events:none}
.gpu-row.unusable .name{color:var(--ember)}
.gpu-tile .st.removed{color:var(--ash)}
.ids{display:flex;align-items:center;gap:6px;flex:0 0 auto}
.ids .k{font-family:var(--mono);font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash);margin-right:var(--s-1)}
.ids button{width:30px;height:30px;border-radius:8px;border:1px solid var(--line-2);background:transparent;color:var(--bone);cursor:pointer;font-size:16px;line-height:1}
@ -296,6 +300,7 @@ td .sub{display:block;font-family:var(--mono);font-size:var(--t-xs);color:var(--
.feed .t{color:var(--ash);flex:0 0 auto;font-size:var(--t-xs)}
.feed .k{color:var(--molten);margin-right:6px}
.feed .k.error{color:var(--ember)}
.feed .k.warn{color:var(--ember)}
.feed .k.block{color:var(--ember)}
.feed .k.build{color:var(--ink-2)}

View file

@ -9,11 +9,12 @@
4 update available, downloading, ready, waiting for permission, manual 5 job done 6 updated
Rules: a done job goes after 5 minutes; a failed job stays until closed; "updated" goes 60 s after the new
version started; the clock notice is for the setup screens (the node card carries it on the dashboard); the job
notice is for the dashboard. */
notice is for the dashboard. A card that appeared or went (hot-plug, src/hotplug.rs) is a level 5 notice for
5 minutes on every screen: "New card: <name>, mining", "New card: <name>, not usable (Code 43)", "Card removed". */
var Notices = (function () {
'use strict';
var LEVEL = { 'update-installing': 0, 'update-urgent': 0, 'update-failed': 0, 'job-failed': 1, clock: 2, 'job-running': 3, 'update-available': 4, 'job-done': 5, 'update-installed': 6 };
var JOB_DONE_S = 300, INSTALLED_S = 60;
var LEVEL = { 'update-installing': 0, 'update-urgent': 0, 'update-failed': 0, 'job-failed': 1, clock: 2, 'job-running': 3, 'update-available': 4, 'job-done': 5, 'card-added': 5, 'card-removed': 5, 'update-installed': 6 };
var JOB_DONE_S = 300, INSTALLED_S = 60, CARD_S = 300;
function cap(t) { return t ? t.charAt(0).toUpperCase() + t.slice(1) : ''; }
function firstLine(t, max) { t = String(t || '').split('\n')[0].trim(); max = max || 160; return t.length > max ? t.slice(0, max - 1).trim() + '\u2026' : t; }
function endDot(t) { t = (t || '').trim(); return !t || /[.!?]$/.test(t) ? t : t + '.'; }
@ -91,6 +92,26 @@ var Notices = (function () {
return notice('clock', 'clock:' + c.severity, c.message || '', { tone: c.severity === 'block' ? 'bad' : 'warn', detail: c.hint || '', actions: [{ act: 'sync', label: c.syncing ? 'Syncing' : 'Sync clock', primary: true, disabled: !!c.syncing }] });
}
// cards = state.mining.cards; now = state.now: every card a re-detection added or lost in the last 5 minutes
// (added_at / removed_at are 0 for the cards found at start). The newest first, so pick() shows it.
function kindWord(k) { return k === 'apple' ? 'Apple silicon' : k === 'discrete' ? 'Discrete' : k === 'integrated' ? 'Integrated' : k === 'external' ? 'External' : 'Unknown'; }
function cardNotices(cards, now) {
var out = [];
(cards || []).forEach(function (c) {
if (c.removed_at > 0) {
if (now - c.removed_at <= CARD_S) out.push(notice('card-removed', 'card:removed:' + c.key + ':' + Math.floor(c.removed_at), 'Card removed: ' + c.name + '. Its worker stopped.', { tone: 'warn', at: c.removed_at }));
return;
}
if (!(c.added_at > 0) || now - c.added_at > CARD_S) return;
// a new card with a problem, or a card that was mining and now reports one (both carry added_at): the row's words
if (c.problem) out.push(notice('card-added', 'card:added:' + c.key + ':' + Math.floor(c.added_at), c.name + ': not usable (' + c.problem + '). No worker runs on it.', { tone: 'warn', detail: c.reason || '', at: c.added_at }));
else if (c.enabled) out.push(notice('card-added', 'card:added:' + c.key + ':' + Math.floor(c.added_at), 'New card: ' + c.name + ', mining.', { at: c.added_at }));
else out.push(notice('card-added', 'card:added:' + c.key + ':' + Math.floor(c.added_at), 'New card: ' + c.name + ', off (' + kindWord(c.kind).toLowerCase() + '). Settings switches it on.', { at: c.added_at }));
});
out.sort(function (a, b) { return b.at - a.at; });
return out;
}
// every notice the state carries for this screen; pick() chooses the one to show
function gather(s, where) {
where = where || {};
@ -98,6 +119,7 @@ var Notices = (function () {
if (u) out.push(u);
if (where.dashboard) { var j = jobNotice(s.jobs, s.now || 0); if (j) out.push(j); }
else { var c = clockNotice(s.clock); if (c) out.push(c); }
if (s.mining && s.mining.cards) out = out.concat(cardNotices(s.mining.cards, s.now || 0));
return out;
}
// the lowest level wins; a dismissed key is skipped, so the next one shows in its place
@ -110,7 +132,7 @@ var Notices = (function () {
}
return best;
}
return { LEVEL: LEVEL, JOB_DONE_S: JOB_DONE_S, INSTALLED_S: INSTALLED_S, updateNotice: updateNotice, jobNotice: jobNotice, clockNotice: clockNotice, gather: gather, pick: pick, jobTitle: jobTitle, cap: cap, endDot: endDot };
return { LEVEL: LEVEL, JOB_DONE_S: JOB_DONE_S, INSTALLED_S: INSTALLED_S, CARD_S: CARD_S, updateNotice: updateNotice, jobNotice: jobNotice, clockNotice: clockNotice, cardNotices: cardNotices, gather: gather, pick: pick, jobTitle: jobTitle, cap: cap, endDot: endDot, kindWord: kindWord };
})();
/* ---------- the update card (pure; update-card.test.mjs loads this block) ----------
@ -328,9 +350,11 @@ if (typeof document !== 'undefined') (function () {
$('btn-settings').addEventListener('click', function () { fillSettings(); $('settings').hidden = false; });
$('btn-settings-close').addEventListener('click', function () { $('settings').hidden = true; $('s-key-box').hidden = true; $('s-key').textContent = ''; });
$('settings').addEventListener('click', function (e) { if (e.target === $('settings')) $('btn-settings-close').click(); });
var settingsCardsSig = '';
function fillSettings() {
if (!state) return;
$('s-address').textContent = state.address.display || 'not set';
settingsCardsSig = shownCards(state.mining.cards).map(function (c) { return c.key + ':' + (c.problem || '') + ':' + (c.removed_at > 0 ? 'r' : ''); }).join('|');
renderCardRows($('s-cards'), state.mining.cards);
state.mining.cards.forEach(function (cd) {
if (cd.vendor !== 'nvidia' || !(cd.power_default_w > 0)) return;
@ -679,27 +703,31 @@ if (typeof document !== 'undefined') (function () {
if (cd.vendor === 'apple') return '<svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor" aria-hidden="true"><path d="M16.4 12.6c0-2.5 2-3.6 2.1-3.7-1.2-1.7-3-1.9-3.6-2-1.5-.2-3 .9-3.8.9-.8 0-2-.9-3.3-.8-1.7 0-3.2 1-4.1 2.5-1.8 3-.5 7.6 1.3 10.1.9 1.2 1.9 2.6 3.2 2.5 1.3 0 1.8-.8 3.3-.8 1.6 0 2 .8 3.3.8 1.4 0 2.3-1.2 3.1-2.5 1-1.4 1.4-2.8 1.4-2.9 0 0-2.7-1-2.9-4.1zM13.9 5.3c.7-.8 1.2-2 1-3.2-1 0-2.2.7-2.9 1.5-.6.7-1.2 1.9-1 3 1.1.1 2.2-.5 2.9-1.3z"/></svg>';
return cd.vendor === 'nvidia' ? 'NV' : cd.vendor === 'amd' ? 'AMD' : 'GPU';
}
function kindWord(k) { return k === 'apple' ? 'Apple silicon' : k === 'discrete' ? 'Discrete' : k === 'integrated' ? 'Integrated' : k === 'external' ? 'External' : 'Unknown'; }
var kindWord = Notices.kindWord;
// hot-plug (src/hotplug.rs): a removed card's row hides after five minutes (gone); a faulty one has no switch
function shownCards(cards) { return (cards || []).filter(function (c) { return !c.gone; }); }
function vramText(cd) { if (!cd.vram_mb) return ''; var gb = cd.vram_mb / 1024; return (gb >= 10 ? Math.round(gb) : gb.toFixed(gb < 4 ? 1 : 0)) + ' GB' + (cd.kind === 'apple' ? ' unified' : ''); }
function renderCardRows(container, cards) {
container.querySelectorAll('.gpu-row').forEach(function (n) { n.remove(); });
cards.forEach(function (cd) {
shownCards(cards).forEach(function (cd) {
var d = document.createElement('div');
d.className = 'gpu-row' + (cd.enabled ? '' : ' off');
var removed = cd.removed_at > 0, unusable = !!cd.problem;
d.className = 'gpu-row' + (cd.enabled && !removed && !unusable ? '' : ' off') + (removed ? ' removed' : '') + (unusable ? ' unusable' : '');
d.dataset.key = cd.key;
var meta = '<span>worker <b>' + esc(cd.worker) + '</b></span>' + (vramText(cd) ? '<span>' + esc(vramText(cd)) + '</span>' : '') + (cd.detail && cd.kind !== 'apple' ? '<span>' + esc(cd.detail) + '</span>' : '') + (cd.detail && cd.kind === 'apple' ? '<span>' + esc(cd.detail.split(',')[0]) + '</span>' : '') + (cd.device ? '<span>device <b>' + esc(cd.device) + '</b></span>' : '');
d.innerHTML = '<div class="badge ' + esc(cd.vendor) + '">' + badgeHtml(cd) + '</div>' +
'<div class="info"><div class="name">' + esc(cd.name) + ' <span class="kind ' + esc(cd.kind) + '">' + kindWord(cd.kind) + '</span></div><div class="meta">' + meta + '</div>' +
(cd.reason ? '<div class="reason">' + esc(cd.reason) + '</div>' : '') + (cd.message ? '<div class="msg">' + esc(cd.message) + '</div>' : '') + '</div>' +
'<div class="info"><div class="name">' + esc(cd.name) + (unusable ? ': ' + esc(cd.message || 'not usable (' + cd.problem + ')') : removed ? ': removed' : '') + ' <span class="kind ' + esc(cd.kind) + '">' + kindWord(cd.kind) + '</span></div><div class="meta">' + meta + '</div>' +
(cd.reason ? '<div class="reason">' + esc(cd.reason) + '</div>' : '') + (cd.message && !unusable && !removed ? '<div class="msg">' + esc(cd.message) + '</div>' : '') + '</div>' +
(cd.vendor === 'nvidia' && cd.power_default_w > 0 ? '<div class="power"><span class="k">power cap</span><input type="range" min="50" max="100" step="5" value="' + (cd.power_pct || 80) + '"><span class="pv mono">' + (cd.power_pct || 80) + '% · ' + Math.round(cd.power_default_w * (cd.power_pct || 80) / 100) + ' W</span>' + (cd.pinned ? '<span class="pin" title="set by hand; the sweep records but does not change it">pinned</span>' : cd.sweep_pct ? '<span class="pin" title="chosen by the efficiency sweep">sweep ' + cd.sweep_pct + '%</span>' : '') + '</div>' : '') +
'<div class="ids"><span class="k">identities</span><button type="button" data-d="-1" aria-label="fewer">&minus;</button><input type="number" min="1" max="64" value="' + cd.identities + '"><button type="button" data-d="1" aria-label="more">+</button></div>' +
'<label class="switch" title="' + (cd.enabled ? 'mining on this card' : 'not mining on this card') + '"><input type="checkbox"' + (cd.enabled ? ' checked' : '') + (cd.kind === 'unknown' ? ' disabled' : '') + '><span class="track"></span></label>';
'<label class="switch" title="' + (unusable ? 'no worker can drive this card' : removed ? 'the card was removed' : cd.enabled ? 'mining on this card' : 'not mining on this card') + '"><input type="checkbox"' + (cd.enabled && !removed && !unusable ? ' checked' : '') + (cd.kind === 'unknown' || unusable || removed ? ' disabled' : '') + '><span class="track"></span></label>';
container.appendChild(d);
});
}
function readCardRows(container) {
var out = [];
container.querySelectorAll('.gpu-row').forEach(function (r) {
if (r.classList.contains('removed') || r.classList.contains('unusable')) return;
var n = parseInt(r.querySelector('.ids input').value, 10); if (!(n >= 1)) n = 1; if (n > 64) n = 64;
var pr = r.querySelector('.power input'), item = { key: r.dataset.key, enabled: r.querySelector('.switch input').checked, identities: n };
if (pr) item.power_pct = parseInt(pr.value, 10);
@ -726,8 +754,8 @@ if (typeof document !== 'undefined') (function () {
var list = $('cards-list'), det = $('cards-detecting');
if (s.detecting) { det.hidden = false; $('detect-text').textContent = 'asking the graphics cards to report in'; $('btn-cards-next').disabled = true; $('cards-sub').textContent = 'Asking the graphics cards to report in.'; cardsRendered = ''; return; }
det.hidden = true;
var cards = s.mining.cards;
var sig = cards.map(function (c) { return c.key; }).join('|');
var cards = shownCards(s.mining.cards);
var sig = cards.map(function (c) { return c.key + ':' + (c.problem || '') + ':' + (c.removed_at > 0 ? 'r' : '') + ':' + (c.enabled ? 'on' : 'off'); }).join('|');
if (sig !== cardsRendered) { cardsRendered = sig; renderCardRows(list, cards); }
if (!cards.length) {
$('cards-sub').textContent = 'No GPU this app can drive was found.';
@ -798,8 +826,9 @@ if (typeof document !== 'undefined') (function () {
function renderDashboard(s) {
var m = s.mining, n = s.node, p = s.program, f = s.finality;
$('d-hash').textContent = m.hash_total >= 100 ? m.hash_total.toFixed(0) : m.hash_total.toFixed(1);
var mining = m.cards.filter(function (c) { return c.state === 'mining'; }).length;
$('d-hash-sub').textContent = m.state === 'paused' ? 'paused' : mining ? (mining + ' of ' + m.cards.length + ' card' + (m.cards.length === 1 ? '' : 's') + ' mining') : m.state === 'waiting' ? (n.synced ? 'worker starting' : 'waiting for the node') : m.state === 'idle' ? 'no GPU worker' : m.state;
var shown = shownCards(m.cards), present = shown.filter(function (c) { return !(c.removed_at > 0) && !c.problem; });
var mining = present.filter(function (c) { return c.state === 'mining'; }).length;
$('d-hash-sub').textContent = m.state === 'paused' ? 'paused' : mining ? (mining + ' of ' + present.length + ' card' + (present.length === 1 ? '' : 's') + ' mining') : m.state === 'waiting' ? (n.synced ? 'worker starting' : 'waiting for the node') : m.state === 'idle' ? 'no GPU worker' : m.state;
$('d-blocks').textContent = withCommas(m.accepted_total);
$('d-blocks-sub').textContent = m.accepted_session + ' this run' + (m.found.length ? ' · ' + m.found.length + ' last hour' : '') + (m.rejected_session ? ' · ' + m.rejected_session + ' rejected' : '');
var nodeWord = n.state === 'synced' ? 'synced' : n.state === 'syncing' ? 'syncing' : n.state;
@ -822,22 +851,24 @@ if (typeof document !== 'undefined') (function () {
$('d-fee').title = (m.fee_session || 0) + ' this run; 1 block in 100 pays the miner software\'s dev fee';
$('d-chain-blocks').textContent = compact(n.blocks);
// per-card tiles (enabled cards), the rest in one muted line
var on = m.cards.filter(function (c) { return c.enabled; }), off = m.cards.filter(function (c) { return !c.enabled; });
$('d-cards-eyebrow').textContent = on.length + ' worker' + (on.length === 1 ? '' : 's') + (m.cards.length > on.length ? ' of ' + m.cards.length + ' GPU' + (m.cards.length === 1 ? '' : 's') : '');
// a card that was removed keeps its tile saying "removed" for five minutes (then the row goes); a faulty one is in the off line
var on = shown.filter(function (c) { return c.enabled && !c.problem; }), off = shown.filter(function (c) { return !c.enabled || c.problem; });
var workers = on.filter(function (c) { return !(c.removed_at > 0); }).length;
$('d-cards-eyebrow').textContent = workers + ' worker' + (workers === 1 ? '' : 's') + (present.length > workers ? ' of ' + present.length + ' GPU' + (present.length === 1 ? '' : 's') : '');
var tb = $('d-cards');
if (!on.length) tb.innerHTML = '<div class="empty">' + (m.cards.length ? 'No card switched on. Open settings to pick one.' : 'No GPU worker. The node runs on its own.') + '</div>';
if (!on.length) tb.innerHTML = '<div class="empty">' + (shown.length ? 'No card switched on. Open settings to pick one.' : 'No GPU worker. The node runs on its own.') + '</div>';
else tb.innerHTML = on.map(function (cd) {
var cls = cd.state === 'mining' ? 'mining' : (cd.state === 'failed' || cd.state === 'restarting' || cd.state === 'faulted') ? 'bad' : '';
var cls = cd.state === 'mining' ? 'mining' : (cd.state === 'failed' || cd.state === 'restarting' || cd.state === 'faulted') ? 'bad' : cd.state === 'removed' ? 'removed' : '';
var word = cd.state === 'restarting' ? ('restart in ' + cd.restart_in_s + ' s') : cd.state;
var extra = cd.ids.length ? '<span>id <b>' + esc(cd.ids.join(' ')) + '</b></span>' : '';
return '<div class="gpu-tile' + (cd.state === 'mining' ? '' : ' idle') + '"><div class="n">' + esc(cd.name) + ' <span class="kind ' + esc(cd.kind) + '">' + kindWord(cd.kind) + '</span></div>' +
'<div class="h">' + cd.hash_now.toFixed(1) + '<span class="unit">MH/s</span></div>' +
'<div class="m"><span>blocks <b>' + cd.accepted + '</b>' + (cd.rejected ? ' <span class="dim">/ ' + cd.rejected + ' rejected</span>' : '') + '</span><span>avg <b>' + cd.hash_avg.toFixed(1) + '</b></span><span>identities <b>' + cd.identities + '</b></span>' + extra + (cd.faults ? '<span class="dim">worker faults <b>' + cd.faults + '</b></span>' : '') + (cd.mismatched ? '<span class="dim">re-check misses <b>' + cd.mismatched + '</b></span>' : '') + '</div>' +
telemetryHtml(cd) +
'<span class="st ' + cls + '"><i></i>' + esc(word) + (cd.prepared ? ' · next program ready' : '') + '</span>' + (cd.message ? '<div class="msg">' + esc(cd.message) + '</div>' : '') + '</div>';
'<span class="st ' + cls + '"><i></i>' + esc(word) + (cd.prepared ? ' · next program ready' : '') + '</span>' + (cd.message && cd.state !== 'removed' ? '<div class="msg">' + esc(cd.message) + '</div>' : '') + '</div>';
}).join('');
$('d-cards-off').hidden = !off.length;
$('d-cards-off').textContent = off.length ? 'off: ' + off.map(function (c) { return c.name + ' (' + kindWord(c.kind).toLowerCase() + ')'; }).join(', ') + '. Settings switches them on.' : '';
$('d-cards-off').textContent = off.length ? 'off: ' + off.map(function (c) { return c.problem ? c.name + ': not usable (' + c.problem + ')' : c.name + ' (' + kindWord(c.kind).toLowerCase() + ')'; }).join(', ') + '. ' + (off.some(function (c) { return !c.problem; }) ? 'Settings switches them on.' : 'Reboot with the card attached; if it persists, reinstall the driver with the card attached.') : '';
// node card
$('d-node-net').textContent = s.chain + (n.version ? ' · ' + n.version.replace(/^igneumd\s*/, '') : '');
$('n-blocks').textContent = withCommas(n.blocks); $('n-headers').textContent = withCommas(n.headers); $('n-peers').textContent = n.peers;

View file

@ -11,7 +11,7 @@ const src = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'app.js')
const mod = { exports: {} };
new Function('module', src)(mod);
const N = mod.exports;
const { updateNotice, jobNotice, clockNotice, gather, pick } = N;
const { updateNotice, jobNotice, clockNotice, cardNotices, gather, pick } = N;
const NOW = 1_800_000_000;
const upd = (over) => ({ available: true, version: '0.3.6', notes: '', checked_at: NOW - 60, error: '', status: 'ready', downloaded: true, ready: true, applying: false, progress: 1, size: 20_588_331, auto: true, wait: 'installs at the next safe moment', urgent: false, urgent_text: '', activation_height: 0, unsupported: false, min_supported: '', channel: 'devnet', published_at: '', file: '', updated_from: '', rolled_back: '', ...over });
@ -139,3 +139,36 @@ test('clock: the engine words, Sync clock, the hint; gather() keeps it off the d
assert.equal(pick(gather(st, { dashboard: false }), {}).kind, 'clock');
assert.deepEqual(gather({}, { dashboard: true }), []);
});
// hot-plug (src/hotplug.rs): the strip says what appeared or went, for 5 minutes, on every screen
const cardOf = (over) => ({ index: 0, key: 'nvidia:0:NVIDIA GeForce RTX 5090', kind: 'discrete', name: 'NVIDIA GeForce RTX 5090', vendor: 'nvidia', enabled: true, state: 'mining', problem: '', reason: '', message: '', added_at: 0, removed_at: 0, gone: false, ...over });
test('card notices: new card mining, new card not usable with the hint, removed card, nothing for the cards found at start', () => {
const start = cardOf();
assert.deepEqual(cardNotices([start], NOW), []);
const added = cardOf({ key: 'amd:1:gfx1201', name: 'gfx1201', vendor: 'amd', added_at: NOW - 30, state: 'starting' });
const [a] = cardNotices([start, added], NOW);
assert.equal(a.kind, 'card-added'); assert.equal(a.level, N.LEVEL['job-done']); assert.equal(a.text, 'New card: gfx1201, mining.'); assert.equal(a.key, 'card:added:amd:1:gfx1201:' + (NOW - 30));
const bad = cardOf({ key: 'amd::AMD Radeon RX 9070 XT', name: 'AMD Radeon RX 9070 XT', vendor: 'amd', enabled: false, state: 'unusable', problem: 'Code 43', message: 'not usable (Code 43)', reason: 'reboot with the card attached; if it persists, reinstall the driver with the card attached', added_at: NOW - 10 });
const [b] = cardNotices([bad], NOW);
assert.equal(b.text, 'AMD Radeon RX 9070 XT: not usable (Code 43). No worker runs on it.'); assert.equal(b.tone, 'warn'); assert.match(b.detail, /^reboot with the card attached/);
const igpu = cardOf({ key: 'amd:0:gfx1036', name: 'gfx1036', vendor: 'amd', kind: 'integrated', enabled: false, state: 'off', added_at: NOW - 5 });
assert.equal(cardNotices([igpu], NOW)[0].text, 'New card: gfx1036, off (integrated). Settings switches it on.');
const removed = cardOf({ key: 'amd:1:gfx1201', name: 'gfx1201', vendor: 'amd', state: 'removed', removed_at: NOW - 60 });
const [r] = cardNotices([removed], NOW);
assert.equal(r.kind, 'card-removed'); assert.equal(r.text, 'Card removed: gfx1201. Its worker stopped.'); assert.equal(r.tone, 'warn');
// the newest first; both go after CARD_S
const two = cardNotices([added, removed], NOW);
assert.equal(two[0].kind, 'card-added');
assert.deepEqual(cardNotices([added, removed, bad], NOW + N.CARD_S + 1), []);
});
test('card notices sit under a running job and show on the setup screens too', () => {
const added = cardOf({ key: 'amd:1:gfx1201', name: 'gfx1201', vendor: 'amd', added_at: NOW - 30 });
const withJob = s({ jobs: run(), mining: { cards: [added] } });
assert.equal(pick(gather(withJob, { dashboard: true }), {}).kind, 'job-running');
const quiet = s({ mining: { cards: [added] } });
assert.equal(pick(gather(quiet, { dashboard: true }), {}).kind, 'card-added');
assert.equal(pick(gather(quiet, {}), {}).kind, 'card-added');
// closed: stays closed for that key; a later event on the same card has a new key
assert.equal(pick(gather(quiet, { dashboard: true }), { ['card:added:amd:1:gfx1201:' + (NOW - 30)]: true }), null);
});

View file

@ -16,6 +16,7 @@
#endif
#include <windows.h>
#include <shellapi.h>
#include <dbt.h>
#include <wrl.h>
#include <string>
#include <vector>
@ -324,6 +325,11 @@ static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
}
}
return 0;
case WM_DEVICECHANGE:
// a device arrived or left (an eGPU through a USB4 box, a driver coming up or crashing): the engine enumerates
// the cards now instead of at its next minute poll (src/hotplug.rs); DBT_DEVNODES_CHANGED needs no registration
if (wp == DBT_DEVNODES_CHANGED || wp == DBT_DEVICEARRIVAL || wp == DBT_DEVICEREMOVECOMPLETE) sendEngine("detect");
return TRUE;
case WM_TRAY:
if (lp == WM_LBUTTONUP || lp == WM_LBUTTONDBLCLK) { ShowWindow(hwnd, SW_SHOW); SetForegroundWindow(hwnd); }
else if (lp == WM_RBUTTONUP || lp == WM_CONTEXTMENU) showTrayMenu();

View file

@ -136,6 +136,7 @@ async function machines(sql) {
accepted_lifetime: g.app && g.app.status ? g.app.status.accepted_total ?? null : null,
faults: cards.reduce((a, c) => a + (c.faults || 0), 0),
telemetry: g.app ? g.app.telemetry : [], events: g.app ? g.app.events : [],
gpus: g.app && g.app.gpus ? g.app.gpus : [], gpus_at: g.app && g.app.gpus_at ? g.app.gpus_at : null, hotplug: g.app && g.app.hotplug ? g.app.hotplug : [],
labels: g.labels.sort((a, b) => (b.at > a.at ? 1 : -1)), runs: [...g.runs],
};
});
@ -145,7 +146,7 @@ async function machines(sql) {
// several Mac installs (test runs): the newest is "Mac", the others keep their id
let macSeen = false;
for (const m of out) if (m.platform === 'mac' && m.name === 'Mac') { if (macSeen) m.name = `Mac ${m.id8}`; macSeen = true; }
for (const n of ['Mac', 'PC 1', 'PC 2', "Sam's Mac"]) if (!out.some(m => m.name === n)) out.push({ id: n, id8: Object.keys(NAMES).find(k => NAMES[k] === n) || null, platform: n.endsWith('Mac') ? 'mac' : 'win', name: n, host: null, legacy: false, last_seen: null, silent_s: null, silent: true, app_version: null, app: null, node: {}, cards: [], hash_total: 0, accepted_total: 0, faults: 0, telemetry: [], events: [], labels: [], runs: [], never: true });
for (const n of ['Mac', 'PC 1', 'PC 2', "Sam's Mac"]) if (!out.some(m => m.name === n)) out.push({ id: n, id8: Object.keys(NAMES).find(k => NAMES[k] === n) || null, platform: n.endsWith('Mac') ? 'mac' : 'win', name: n, host: null, legacy: false, last_seen: null, silent_s: null, silent: true, app_version: null, app: null, node: {}, cards: [], hash_total: 0, accepted_total: 0, faults: 0, telemetry: [], events: [], gpus: [], gpus_at: null, hotplug: [], labels: [], runs: [], never: true });
out.sort((a, b) => order(a) - order(b) || (b.last_seen || '').localeCompare(a.last_seen || ''));
return { machines: out.filter(m => NAMED.has(m.name) || (m.silent_s !== null && m.silent_s < HIDE_AFTER_S)), silent_after_s: SILENT_S, hide_after_s: HIDE_AFTER_S };
}

View file

@ -70,6 +70,12 @@ export function parseHeader(text) {
const m = HEADER.exec(text || '');
return m ? { version: m[1], id8: m[2], platform: m[3], node: m[4].replace(/^igneumd[_\/]/, '') } : null;
}
/// "NVIDIA GeForce RTX 5090 [discrete, mining] | gfx1036 [integrated, off] | AMD Radeon RX 9070 XT [discrete, not usable (Code 43)]"
export function parseCardsLine(text) {
if (!text || text.trim() === 'none') return [];
return text.split(' | ').map(p => { const m = /^(.*) \[([^,\]]+), ([^\]]+)\]$/.exec(p.trim()); return m ? { name: m[1], kind: m[2], state: m[3] } : { name: p.trim(), kind: 'unknown', state: '' }; });
}
export function parseAppTail(tail) {
const lines = tail.split('\n');
const a = { telemetry: [], events: [], jobs: [] };
@ -113,6 +119,11 @@ export function parseAppTail(tail) {
if (e && e[2] !== 'block') a.events.push({ at: new Date(Number(e[1]) * 1000).toISOString(), kind: e[2], text: e[3].slice(0, 200) });
}
a.events = a.events.slice(-6);
// the card list (app/igneum-app/src/hotplug.rs cards_line): the newest "cards: <name> [<kind>, <state>] | ..." line,
// written at start, on every hot-plug change and every 10 minutes
const cl = lastMatch(lines, /^(\d+)(?:\.\d+)? cards: (.+)$/);
if (cl) { a.gpus = parseCardsLine(cl[2]); a.gpus_at = new Date(Number(cl[1]) * 1000).toISOString(); }
a.hotplug = lines.filter(l => /^\d+(?:\.\d+)? \[(ok|warn|info)\] (New card: |Card removed: |.+: not usable \()/.test(l)).slice(-4).map(l => ({ at: stampOf(l), text: strip(l).replace(/^\[\w+\] /, '').slice(0, 200) }));
// A clean stop: the app logs "quit: ..." (Quit, Stop, or an update: "[info] installing Igneum Miner ...") and uploads
// once more before it exits; when no status line follows, the machine was stopped on purpose, not lost (4 October
// 2026: a Mac stopped at 14:47 UTC read "silent 4h" for the whole afternoon, the same as a crash or a lost network).

View file

@ -1,7 +1,7 @@
// node --test relay/test/parse.test.mjs relay/test/auth.test.mjs (no dependencies; CI runs both in the site job)
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { parseLabel, parseMinerTail, markStale, STALE_S } from '../lib/parse.mjs';
import { parseLabel, parseMinerTail, parseAppTail, parseCardsLine, markStale, STALE_S } from '../lib/parse.mjs';
test('labels: every vendor the app names, the app log, the node log, the legacy launchers', () => {
assert.deepEqual(parseLabel('miner-nvidia-ae432dc7-1'), { id: 'ae432dc7', platform: 'win', stream: 'miner', vendor: 'nvidia', card: 1 });
@ -84,3 +84,23 @@ test('app tail: the newest update line decides the OTA state: staged beats an ol
const inst = parseAppTail(mac + '\n1791146700 [info] installing Igneum Miner 0.3.4: the miners stop, then the node, then the app opens again');
assert.deepEqual([inst.ota.state, inst.ota.version], ['installing', '0.3.4']);
});
test('app tail: the cards line gives kind and state per card, and the hot-plug events', () => {
assert.deepEqual(parseCardsLine('NVIDIA GeForce RTX 5090 [discrete, mining] | gfx1036 [integrated, off] | AMD Radeon RX 9070 XT [discrete, not usable (Code 43)]'),
[{ name: 'NVIDIA GeForce RTX 5090', kind: 'discrete', state: 'mining' }, { name: 'gfx1036', kind: 'integrated', state: 'off' }, { name: 'AMD Radeon RX 9070 XT', kind: 'discrete', state: 'not usable (Code 43)' }]);
assert.deepEqual(parseCardsLine('none'), []);
const tail = [
'1791140500 cards: NVIDIA GeForce RTX 5090 [discrete, mining] | gfx1036 [integrated, off]',
'1791140560 [ok] New card: gfx1201, mining',
'1791140561 cards: NVIDIA GeForce RTX 5090 [discrete, mining] | gfx1036 [integrated, off] | gfx1201 [discrete, waiting]',
'1791140800 [warn] Card removed: gfx1201; its worker stopped',
'1791140801 cards: NVIDIA GeForce RTX 5090 [discrete, mining] | gfx1036 [integrated, off] | gfx1201 [discrete, removed]',
'1791140830 status: accepted 5 blocks (1 this run, dev fee 0), 120.00 MH/s, mining | node 100 blocks, 3 peers, synced | up 1h',
].join('\n');
const a = parseAppTail(tail);
assert.equal(a.gpus.length, 3);
assert.equal(a.gpus[2].state, 'removed');
assert.equal(a.gpus_at, new Date(1791140801000).toISOString());
assert.deepEqual(a.hotplug.map(h => h.text), ['New card: gfx1201, mining', 'Card removed: gfx1201; its worker stopped']);
assert.equal(parseAppTail('1791140830 status: x | node y | up 1h').gpus, undefined);
});

View file

@ -287,7 +287,7 @@ input[type=text]{width:100%}
['checkpoint', n.checkpoint != null ? nf(n.checkpoint) : '?'],
['run', `<small>${esc((n.run_id || (mc.runs && mc.runs[0]) || '').replace(/^.*-(\d{8}-\d{6})$/, '$1'))}</small>`],
])}
${cardTable(mc.cards)}${tele}
${cardTable(mc.cards)}${mc.gpus && mc.gpus.length ? `<div class="note" style="margin-top:8px">GPUs ${mc.gpus_at ? ago(mc.gpus_at) + ' ago' : ''}: ${mc.gpus.map(g => `${esc(g.name)} <span style="color:var(--ash)">[${esc(g.kind)}, ${/not usable|removed/.test(g.state) ? `<span style="color:var(--red)">${esc(g.state)}</span>` : esc(g.state)}]</span>`).join(' | ')}</div>` : ''}${(mc.hotplug || []).map(h => `<div class="note">hot-plug ${ago(h.at)} ago: ${esc(h.text)}</div>`).join('')}${tele}
${mc.app && (mc.app.ota || mc.app.power_cap || mc.app.jobs_file || (mc.app.jobs && mc.app.jobs.length) || mc.app.status) ? `<div style="margin-top:8px">
${mc.app.status ? `<div class="ev"><span class="t">${when(mc.app.status.at)}</span><span class="k">status</span><span>${esc(mc.app.status.miner)} | node ${esc(mc.app.status.node)} | up ${esc(mc.app.status.up)}</span></div>` : ''}
${mc.app.ota ? `<div class="ev"><span class="t">${when(mc.app.ota.at)}</span><span class="k ${(mc.app.ota.state === 'updated' || mc.app.ota.state === 'current') ? 'ok' : ''}">ota</span><span>${esc(mc.app.ota.text)}</span></div>` : ''}

View file

@ -113,6 +113,9 @@ try {
console.log(`${m.name.padEnd(8)} ${m.id8 || '-'} ${m.stopped ? `STOPPED (${m.stopped.reason}) ${ago(m.stopped.at)} ago,` : m.silent ? 'SILENT' : 'live'} seen ${ago(m.last_seen)} ago | app ${m.app_version || '?'} node ${m.node.version || '?'} daa ${m.node.daa ?? '?'} peers ${m.node.peers ?? '?'} ${m.node.synced ? 'synced' : 'not synced'} | ${m.hash_total.toFixed(1)} MH/s, ${m.accepted_total} accepted, ${m.faults} faults`);
for (const c of m.cards) console.log(` ${c.label.padEnd(22)} ${c.stale ? 'last ' + c.hash_now.toFixed(1) + ' MH/s (stale, not in the total)' : c.hash_now.toFixed(1) + ' MH/s now'}, ${c.accepted} accepted, ${c.rejected} rejected, ${c.mismatched} mismatched, ${c.restarts} restarts, status ${ago(c.status_at)} ago${c.fault ? ' | ' + c.fault : ''}`);
for (const t of m.telemetry) console.log(` ${t.name}: p95 ${t.p95_w} W, max ${t.max_w} W (cap ${t.cap_w} W), GPU ${t.max_gpu_c} C, memory ${t.max_mem_c} C`);
// the engine's own card list (kind and state per card, hot-plug included: app/igneum-app/src/hotplug.rs), as of its last "cards:" line
if (m.gpus && m.gpus.length) console.log(` GPUs (${ago(m.gpus_at)} ago): ${m.gpus.map(g => `${g.name} [${g.kind}, ${g.state}]`).join(' | ')}`);
for (const h of m.hotplug || []) console.log(` hot-plug ${ago(h.at)} ago: ${h.text}`);
}
}
else if (cmd === 'chain') {