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
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);
}
}
70 changes: 65 additions & 5 deletions mujina-miner/src/api/v0.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ use std::time::Duration;
use tokio::sync::oneshot;
use utoipa_axum::{router::OpenApiRouter, routes};

use super::commands::SchedulerCommand;
use super::commands::{BoardCommand, SchedulerCommand};
use super::server::SharedState;
use crate::api_client::types::{
BoardTelemetry, MinerPatchRequest, MinerTelemetry, SourceTelemetry,
BoardTelemetry, MinerPatchRequest, MinerTelemetry, SetFanTargetRequest, SourceTelemetry,
};

/// Build the v0 API routes with OpenAPI metadata.
Expand All @@ -26,6 +26,7 @@ pub fn routes() -> OpenApiRouter<SharedState> {
.routes(routes!(get_miner, patch_miner))
.routes(routes!(get_boards))
.routes(routes!(get_board))
.routes(routes!(set_fan_target))
.routes(routes!(get_sources))
.routes(routes!(get_source))
}
Expand Down Expand Up @@ -132,9 +133,68 @@ async fn get_board(
.board_registry
.lock()
.unwrap_or_else(|e| e.into_inner())
.boards()
.into_iter()
.find(|b| b.name == name)
.board(&name)
.map(Json)
.ok_or(StatusCode::NOT_FOUND)
}

/// Set a fan's target duty cycle on a board, or return it to automatic
/// control.
#[utoipa::path(
patch,
path = "/boards/{name}/fans/{fan}",
tag = "boards",
params(
("name" = String, Path, description = "Board name"),
("fan" = String, Path, description = "Fan name"),
),
request_body = SetFanTargetRequest,
responses(
(status = OK, description = "Updated board telemetry", body = BoardTelemetry),
(status = NOT_FOUND, description = "Board not found"),
(status = BAD_REQUEST, description = "Board accepts no commands"),
(status = INTERNAL_SERVER_ERROR, description = "Command channel error"),
),
)]
async fn set_fan_target(
State(state): State<SharedState>,
Path((name, fan)): Path<(String, String)>,
Json(req): Json<SetFanTargetRequest>,
) -> Result<Json<BoardTelemetry>, StatusCode> {
let (board_exists, command_tx) = {
let mut registry = state
.board_registry
.lock()
.unwrap_or_else(|e| e.into_inner());
(registry.board(&name).is_some(), registry.command_tx(&name))
};
if !board_exists {
return Err(StatusCode::NOT_FOUND);
}
let Some(command_tx) = command_tx else {
return Err(StatusCode::BAD_REQUEST);
};

let (tx, rx) = oneshot::channel();
command_tx
.send(BoardCommand::SetFanTarget {
board: name.clone(),
fan,
percent: req.target_percent,
reply: tx,
})
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
// Result layers: timeout / channel-closed / command-error.
let Ok(Ok(Ok(()))) = tokio::time::timeout(Duration::from_secs(5), rx).await else {
return Err(StatusCode::INTERNAL_SERVER_ERROR);
};

state
.board_registry
.lock()
.unwrap_or_else(|e| e.into_inner())
.board(&name)
.map(Json)
.ok_or(StatusCode::NOT_FOUND)
}
Expand Down
24 changes: 24 additions & 0 deletions mujina-miner/src/api_client/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ pub struct BoardTelemetry {
pub temperatures: Vec<TemperatureSensor>,
pub powers: Vec<PowerMeasurement>,
pub threads: Vec<ThreadTelemetry>,
/// Per-ASIC topology/diagnostics state (multi-ASIC boards only).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub asics: Vec<AsicState>,
}

/// Fan status.
Expand Down Expand Up @@ -74,6 +77,27 @@ pub struct ThreadTelemetry {
pub is_active: bool,
}

/// Per-ASIC runtime topology or diagnostics state.
#[derive(Clone, Debug, Default, Deserialize, Serialize, ToSchema)]
pub struct AsicState {
pub id: u8,
#[serde(skip_serializing_if = "Option::is_none")]
pub thread_index: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub serial_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub discovered_engine_count: Option<u16>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub missing_engines: Vec<EngineCoordinate>,
}

/// Physical engine coordinate on one ASIC.
#[derive(Clone, Debug, Deserialize, Serialize, ToSchema, PartialEq, Eq)]
pub struct EngineCoordinate {
pub row: u8,
pub col: u8,
}

/// Writable fields for `PATCH /api/v0/miner`.
///
/// All fields are optional; only those present in the request body are
Expand Down
Loading