Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
* text=auto eol=lf

*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.webp binary
*.ico binary
*.pdf binary
*.woff binary
*.woff2 binary
*.ttf binary
*.otf binary
69 changes: 69 additions & 0 deletions mujina-miner/src/api/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
use anyhow::Result;
use tokio::sync::oneshot;

use crate::api_client::types::{Bzm2ChainSummaryResponse, Bzm2ClockReportResponse};

/// Commands from the API to the scheduler.
pub enum SchedulerCommand {
/// Pause job distribution to all threads.
Expand All @@ -25,4 +27,71 @@ pub enum BoardCommand {
percent: Option<u8>,
reply: oneshot::Sender<Result<()>>,
},

/// Trigger a DTS/VS (temperature/voltage sensor) query on a BZM2
/// ASIC; results are published into the board's telemetry stream.
QueryBzm2DtsVs {
thread_index: usize,
asic: u8,
reply: oneshot::Sender<Result<()>>,
},

/// Send a NOOP to a BZM2 ASIC and return the 3-byte payload
/// (expected `b"BZ2"`).
QueryBzm2Noop {
thread_index: usize,
asic: u8,
reply: oneshot::Sender<Result<[u8; 3]>>,
},

/// Report the board's bus/ASIC layout and tuning status.
QueryBzm2ChainSummary {
reply: oneshot::Sender<Result<Bzm2ChainSummaryResponse>>,
},

/// Read PLL/DLL clock status registers from a BZM2 ASIC.
QueryBzm2ClockReport {
thread_index: usize,
asic: u8,
reply: oneshot::Sender<Result<Bzm2ClockReportResponse>>,
},

/// Echo a payload through a BZM2 ASIC's loopback path.
QueryBzm2Loopback {
thread_index: usize,
asic: u8,
payload: Vec<u8>,
reply: oneshot::Sender<Result<Vec<u8>>>,
},

/// Read raw register bytes from a BZM2 engine address.
ReadBzm2Register {
thread_index: usize,
asic: u8,
engine_address: u16,
offset: u8,
count: u8,
reply: oneshot::Sender<Result<Vec<u8>>>,
},

/// Write raw register bytes to a BZM2 engine address.
WriteBzm2Register {
thread_index: usize,
asic: u8,
engine_address: u16,
offset: u8,
value: Vec<u8>,
reply: oneshot::Sender<Result<()>>,
},

/// Run TDM engine-map discovery on a BZM2 ASIC (idle threads
/// only); results are published into the board's telemetry stream.
DiscoverBzm2Engines {
thread_index: usize,
asic: u8,
tdm_prediv_raw: u32,
tdm_counter: u8,
timeout_ms: Option<u32>,
reply: oneshot::Sender<Result<()>>,
},
}
72 changes: 68 additions & 4 deletions mujina-miner/src/api/registry.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
//! Dynamic board registration tracking.

use tokio::sync::{mpsc, watch};

use crate::api::commands::BoardCommand;
use crate::api_client::types::BoardTelemetry;
use tokio::sync::watch;

/// Dynamic collection of board registrations.
///
Expand All @@ -23,23 +25,49 @@ impl BoardRegistry {
self.boards.push(reg);
}

/// Remove boards whose sender has been dropped (board disconnected).
fn prune_disconnected(&mut self) {
self.boards
.retain(|reg| reg.telemetry_rx.has_changed().is_ok());
}

/// Snapshot all connected boards.
///
/// Removes boards whose sender has been dropped (board disconnected)
/// and returns the current state of each.
pub fn boards(&mut self) -> Vec<BoardTelemetry> {
self.boards
.retain(|reg| reg.telemetry_rx.has_changed().is_ok());
self.prune_disconnected();
self.boards
.iter()
.map(|reg| reg.telemetry_rx.borrow().clone())
.collect()
}

/// Snapshot a single connected board by name.
pub fn board(&mut self, name: &str) -> Option<BoardTelemetry> {
self.prune_disconnected();
self.boards
.iter()
.find(|reg| reg.telemetry_rx.borrow().name == name)
.map(|reg| reg.telemetry_rx.borrow().clone())
}

/// Look up the command sender for a board by name. `None` if the
/// board is unknown or accepts no commands.
pub fn command_tx(&mut self, name: &str) -> Option<mpsc::Sender<BoardCommand>> {
self.prune_disconnected();
self.boards
.iter()
.find(|reg| reg.telemetry_rx.borrow().name == name)
.and_then(|reg| reg.command_tx.clone())
}
}

/// A board's registration with the API server.
pub struct BoardRegistration {
pub telemetry_rx: watch::Receiver<BoardTelemetry>,
/// Sender for board commands. `None` if the board accepts no commands.
pub command_tx: Option<mpsc::Sender<BoardCommand>>,
}

#[cfg(test)]
Expand All @@ -57,7 +85,13 @@ mod tests {
..Default::default()
};
let (tx, rx) = watch::channel(telemetry);
(tx, BoardRegistration { telemetry_rx: rx })
(
tx,
BoardRegistration {
telemetry_rx: rx,
command_tx: None,
},
)
}

#[test]
Expand Down Expand Up @@ -109,4 +143,34 @@ mod tests {
tx.send_modify(|s| s.model = "Updated".into());
assert_eq!(registry.boards()[0].model, "Updated");
}

#[test]
fn returns_single_board_by_name() {
let mut registry = BoardRegistry::new();

let (_keep, reg) = make_board("board-a");
registry.push(reg);

assert_eq!(registry.board("board-a").unwrap().name, "board-a");
assert!(registry.board("missing").is_none());
}

#[test]
fn returns_command_sender_for_named_board() {
use tokio::sync::mpsc;

let mut registry = BoardRegistry::new();

let (_keep, mut reg) = make_board("board-a");
let (cmd_tx, _cmd_rx) = mpsc::channel::<BoardCommand>(1);
reg.command_tx = Some(cmd_tx);
registry.push(reg);

assert!(registry.command_tx("board-a").is_some());
assert!(registry.command_tx("missing").is_none());

let (_keep_b, reg_b) = make_board("no-commands");
registry.push(reg_b);
assert!(registry.command_tx("no-commands").is_none());
}
}
112 changes: 111 additions & 1 deletion mujina-miner/src/api/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,10 @@ mod tests {
let mut board_senders = Vec::new();
for state in board_states {
let (tx, rx) = watch::channel(state);
registry.push(BoardRegistration { telemetry_rx: rx });
registry.push(BoardRegistration {
telemetry_rx: rx,
command_tx: None,
});
board_senders.push(tx);
}

Expand Down Expand Up @@ -362,4 +365,111 @@ mod tests {
let (status, _body) = get(fixtures.router.clone(), "/api/v0/nope").await;
assert_eq!(status, 404);
}

async fn post_json<T: serde::Serialize>(
app: Router,
method: &str,
uri: &str,
body: &T,
) -> (http::StatusCode, String) {
let req = Request::builder()
.method(method)
.uri(uri)
.header("content-type", "application/json")
.body(axum::body::Body::from(serde_json::to_vec(body).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
let status = resp.status();
let body = resp.into_body().collect().await.unwrap().to_bytes();
(status, String::from_utf8(body.to_vec()).unwrap())
}

#[tokio::test]
async fn set_fan_target_round_trips_board_command() {
use crate::api::commands::BoardCommand;
use crate::api_client::types::{Fan, SetFanTargetRequest};

let (miner_tx, miner_rx) = watch::channel(MinerTelemetry::default());
let (cmd_tx, _cmd_rx) = mpsc::channel::<SchedulerCommand>(16);
let mut registry = BoardRegistry::new();
let (telemetry_tx, telemetry_rx) = watch::channel(BoardTelemetry {
name: "fan-board".into(),
model: "Test".into(),
..Default::default()
});
let (board_cmd_tx, mut board_cmd_rx) = mpsc::channel(1);
registry.push(BoardRegistration {
telemetry_rx,
command_tx: Some(board_cmd_tx),
});
let router = build_router(miner_rx, Arc::new(Mutex::new(registry)), cmd_tx);

// Clone for the task; the original must stay alive or the registry
// prunes the board before the handler's post-command re-read.
let telemetry_tx_for_command = telemetry_tx.clone();
tokio::spawn(async move {
if let Some(BoardCommand::SetFanTarget {
board,
fan,
percent,
reply,
}) = board_cmd_rx.recv().await
{
assert_eq!(board, "fan-board");
assert_eq!(fan, "fan0");
assert_eq!(percent, Some(75));
telemetry_tx_for_command.send_modify(|t| {
t.fans.push(Fan {
name: "fan0".into(),
rpm: None,
percent: None,
target_percent: percent,
});
});
let _ = reply.send(Ok(()));
}
});

let (status, body) = post_json(
router.clone(),
"PATCH",
"/api/v0/boards/fan-board/fans/fan0",
&SetFanTargetRequest {
target_percent: Some(75),
},
)
.await;
assert_eq!(status, 200);
let board: BoardTelemetry = serde_json::from_str(&body).unwrap();
assert_eq!(board.fans[0].target_percent, Some(75));

// A board with no command channel answers 400.
let (_keep, no_cmd_rx) = watch::channel(BoardTelemetry {
name: "no-commands".into(),
model: "Test".into(),
..Default::default()
});
let (miner_tx2, miner_rx2) = watch::channel(MinerTelemetry::default());
let (cmd_tx2, _cmd_rx2) = mpsc::channel::<SchedulerCommand>(16);
let mut registry2 = BoardRegistry::new();
registry2.push(BoardRegistration {
telemetry_rx: no_cmd_rx,
command_tx: None,
});
let router2 = build_router(miner_rx2, Arc::new(Mutex::new(registry2)), cmd_tx2);
let (status, _body) = post_json(
router2,
"PATCH",
"/api/v0/boards/no-commands/fans/fan0",
&SetFanTargetRequest {
target_percent: Some(50),
},
)
.await;
assert_eq!(status, 400);

drop(miner_tx);
drop(miner_tx2);
drop(telemetry_tx);
}
}
Loading