Files
jackpot-miner/src/control.rs
T
jackpotincorporated 0002e90451 TUI: scale CPU core-toggle granularity by core count
Cap the cores-per-row group size (and the dashboard 'g' cycle) by total core
count so the toggleable-row count stays sensible and small machines get finer
control: ≤4 cores toggle individually (size 1), 5-8 cores in groups of up to 2,
and more than 8 in groups of up to 4. The cap is also the default — the prior
fixed default of 4 now clamps to the tier (1/2/4), and an explicit
--cpu-group-size is clamped to the cap too.

Add max_group_size() in cpu_groups; update the help text and the cpu_groups /
control tests (the cycle test now uses 16 cores so it can exercise sizes 4/2/1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:34:10 -04:00

211 lines
8.0 KiB
Rust

//! Optional local control server (`--control-port`): a `127.0.0.1` JSON-line
//! service that lets the GUI config tool retrieve and adjust a running miner's
//! *live* controls on the fly — per-device enable + clock/power, and the CPU
//! group size + per-row enable. Bound to localhost only (no auth); intended for
//! a tool running on the same machine.
//!
//! Protocol: one JSON request object per line, one JSON reply per line.
//! {"op":"get"} -> snapshot
//! {"op":"set_device","index":0,"enabled":true,"power_w":250,
//! "core_off":150,"mem_off":0} -> {"ok":true}
//! {"op":"set_cpu_group_size","value":4} -> {"ok":true}
//! {"op":"set_cpu_row","index":0,"enabled":true} -> {"ok":true}
use std::io::{BufRead, BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use serde_json::{json, Value};
use crate::controls::Controls;
use crate::cpu_groups::CpuMining;
use crate::miner::Stats;
/// Serve until `running` is cleared. Non-blocking accept so shutdown is prompt.
pub fn serve(port: u16, controls: Arc<Controls>, cpu_mining: Arc<CpuMining>, stats: Arc<Stats>, running: Arc<AtomicBool>) {
let listener = match TcpListener::bind(("127.0.0.1", port)) {
Ok(l) => l,
Err(e) => {
log::warn!("control server: cannot bind 127.0.0.1:{port}: {e}");
return;
}
};
if listener.set_nonblocking(true).is_err() {
log::warn!("control server: set_nonblocking failed; control disabled");
return;
}
log::info!("control server on 127.0.0.1:{port} (live retrieve/adjust)");
while running.load(Ordering::Relaxed) {
match listener.accept() {
Ok((stream, _)) => handle(stream, &controls, &cpu_mining, &stats),
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(Duration::from_millis(150));
}
Err(e) => {
log::debug!("control accept error: {e}");
std::thread::sleep(Duration::from_millis(150));
}
}
}
}
/// Serve one connection: read request lines (with a read timeout so a stalled
/// client can't pin the thread past shutdown) and reply to each.
fn handle(stream: TcpStream, controls: &Controls, cpu_mining: &CpuMining, stats: &Stats) {
let _ = stream.set_nonblocking(false);
let _ = stream.set_read_timeout(Some(Duration::from_secs(5)));
let mut writer = match stream.try_clone() {
Ok(s) => s,
Err(_) => return,
};
let mut reader = BufReader::new(stream);
let mut line = String::new();
loop {
line.clear();
match reader.read_line(&mut line) {
Ok(0) => return,
Ok(_) => {}
Err(_) => return,
}
if line.trim().is_empty() {
continue;
}
let resp = process(line.trim(), controls, cpu_mining, stats);
if writer.write_all(resp.to_string().as_bytes()).is_err()
|| writer.write_all(b"\n").is_err()
|| writer.flush().is_err()
{
return;
}
}
}
fn process(line: &str, controls: &Controls, cpu_mining: &CpuMining, stats: &Stats) -> Value {
let req: Value = match serde_json::from_str(line) {
Ok(v) => v,
Err(e) => return json!({"ok": false, "error": format!("bad json: {e}")}),
};
match req.get("op").and_then(Value::as_str).unwrap_or("") {
"get" => snapshot(controls, cpu_mining, stats),
"set_device" => set_device(&req, controls),
"set_cpu_group_size" => {
if let Some(n) = req.get("value").and_then(Value::as_u64) {
cpu_mining.set_group_size(n as usize);
}
json!({"ok": true})
}
"set_cpu_row" => {
let i = req.get("index").and_then(Value::as_u64).unwrap_or(u64::MAX) as usize;
if let Some(e) = req.get("enabled").and_then(Value::as_bool) {
let groups = cpu_mining.groups();
if i < groups.len() && groups.group(i).enabled() != e {
cpu_mining.toggle_group(i);
}
}
json!({"ok": true})
}
other => json!({"ok": false, "error": format!("unknown op '{other}'")}),
}
}
fn set_device(req: &Value, controls: &Controls) -> Value {
let i = req.get("index").and_then(Value::as_u64).unwrap_or(u64::MAX) as usize;
if i >= controls.device_count() {
return json!({"ok": false, "error": "device index out of range"});
}
let d = controls.device(i);
if let Some(e) = req.get("enabled").and_then(Value::as_bool) {
d.set_enabled(e);
}
if let Some(p) = req.get("power_w").and_then(Value::as_u64) {
d.set_power_w(p as u32);
}
if let Some(c) = req.get("core_off").and_then(Value::as_i64) {
d.set_core_off(c as i32);
}
if let Some(m) = req.get("mem_off").and_then(Value::as_i64) {
d.set_mem_off(m as i32);
}
json!({"ok": true})
}
fn snapshot(controls: &Controls, cpu_mining: &CpuMining, stats: &Stats) -> Value {
let cards = stats.cards();
let devices: Vec<Value> = cards
.iter()
.enumerate()
.map(|(i, c)| {
let d = controls.device(i);
json!({
"index": i,
"label": c.label,
"name": c.name,
"enabled": d.enabled(),
"power_w": d.power_w(),
"core_off": d.core_off(),
"mem_off": d.mem_off(),
"watts": c.power_mw as f64 / 1000.0,
"temp_c": c.temp_c,
"shares": c.shares,
"solutions": c.solutions,
})
})
.collect();
let groups = cpu_mining.groups();
let rows: Vec<Value> = groups
.iter()
.enumerate()
.map(|(i, g)| json!({"index": i, "label": g.label(), "enabled": g.enabled(), "shares": g.shares()}))
.collect();
json!({
"ok": true,
"unlocked": controls.unlocked(),
"devices": devices,
"cpu": {"group_size": cpu_mining.group_size(), "rows": rows},
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::miner::Stats;
#[test]
fn protocol_get_and_set() {
let controls = Controls::new(1, 0, 0, 0, 0, true);
let cpu = CpuMining::new((0..16).collect(), 4, false); // 16 cores -> tier cap 4, sizes [1,2,4]
let stats = Stats::for_test(vec!["GPU 0".into()]);
// get: one device, cpu rows reflect the grouping (16 cores / 4 = 4 rows).
let v = process("{\"op\":\"get\"}", &controls, &cpu, &stats);
assert_eq!(v["ok"], true);
assert_eq!(v["devices"].as_array().unwrap().len(), 1);
assert_eq!(v["cpu"]["group_size"], 4);
assert_eq!(v["cpu"]["rows"].as_array().unwrap().len(), 4);
// set device 0 disabled.
assert!(controls.device(0).enabled());
process("{\"op\":\"set_device\",\"index\":0,\"enabled\":false,\"power_w\":250}", &controls, &cpu, &stats);
assert!(!controls.device(0).enabled());
// out-of-range device index is rejected.
let e = process("{\"op\":\"set_device\",\"index\":9}", &controls, &cpu, &stats);
assert_eq!(e["ok"], false);
// set CPU group size to the nearest available (2).
process("{\"op\":\"set_cpu_group_size\",\"value\":2}", &controls, &cpu, &stats);
assert_eq!(cpu.group_size(), 2);
// toggle a CPU row on.
assert!(!cpu.groups().group(0).enabled());
process("{\"op\":\"set_cpu_row\",\"index\":0,\"enabled\":true}", &controls, &cpu, &stats);
assert!(cpu.groups().group(0).enabled());
// garbage and unknown ops are reported, not panicked.
assert_eq!(process("not json", &controls, &cpu, &stats)["ok"], false);
assert_eq!(process("{\"op\":\"nope\"}", &controls, &cpu, &stats)["ok"], false);
}
}