From 78030bf0c40c7554e5964ede12a5a745844ce0a9 Mon Sep 17 00:00:00 2001 From: Reckless Apotheosis <125509978+recklessnode@users.noreply.github.com> Date: Wed, 10 Jun 2026 17:46:03 -0700 Subject: [PATCH] =?UTF-8?q?=EF=BB=BFfeat(board):=20per-board=20command=20c?= =?UTF-8?q?hannel,=20power-rail=20primitives,=20thread=20telemetry=20types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Infrastructure groundwork usable by any board, no new dependencies: - Wire the per-board command channel end to end: BackplaneConnector and api::registry::BoardRegistration gain an optional mpsc::Sender; the backplane forwards it at start_board. BoardCommand::SetFanTarget and SetFanTargetRequest existed but nothing wired them - PATCH /api/v0/boards/{name}/fans/{fan} now drives them (boards opt in by populating command_tx; all current boards answer "accepts no commands" until they grow a command loop). - BoardRegistry::board(name) + command_tx(name) accessors with lazy disconnect pruning; get_board refactored onto the former. - board/power.rs: PowerRail trait (Tps546PowerRail, FilePowerRail file adapters, FileGpioPin) + GpioResetLine (AsicEnable impl) + VoltageStackBringupPlan for ordered multi-rail bring-up with settle delays and reverse-order shutdown. - hash_thread: HashThreadTemperatureReading/PowerReading/ TelemetryUpdate + HashThreadEvent::TelemetryUpdate (typed path for thread-sourced sensor data; bitaxe's monitor TODO wants this) and HashThreadError as a shared thread error vocabulary. - Backplane::attach_configured_board: attach an env-configured virtual board without synthesizing a transport event. - BoardTelemetry.asics (serde-default) + AsicState/EngineCoordinate: generic per-ASIC topology/diagnostics state for multi-ASIC boards. - transport/serial: Clone derives on the Arc-backed reader/writer/ control halves. - .gitattributes for LF normalization. --- .gitattributes | 13 + mujina-miner/src/api/registry.rs | 72 ++++- mujina-miner/src/api/server.rs | 112 ++++++- mujina-miner/src/api/v0.rs | 70 ++++- mujina-miner/src/api_client/types.rs | 24 ++ mujina-miner/src/asic/hash_thread.rs | 53 ++++ mujina-miner/src/backplane.rs | 41 ++- mujina-miner/src/board/bitaxe.rs | 2 + mujina-miner/src/board/cpu.rs | 1 + mujina-miner/src/board/emberone00.rs | 1 + mujina-miner/src/board/mod.rs | 10 +- mujina-miner/src/board/power.rs | 425 +++++++++++++++++++++++++++ mujina-miner/src/scheduler.rs | 4 + mujina-miner/src/transport/serial.rs | 3 + 14 files changed, 818 insertions(+), 13 deletions(-) create mode 100644 .gitattributes create mode 100644 mujina-miner/src/board/power.rs diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..340ce963 --- /dev/null +++ b/.gitattributes @@ -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 \ No newline at end of file diff --git a/mujina-miner/src/api/registry.rs b/mujina-miner/src/api/registry.rs index c5325878..66b565ad 100644 --- a/mujina-miner/src/api/registry.rs +++ b/mujina-miner/src/api/registry.rs @@ -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. /// @@ -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 { - 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 { + 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> { + 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, + /// Sender for board commands. `None` if the board accepts no commands. + pub command_tx: Option>, } #[cfg(test)] @@ -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] @@ -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::(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()); + } } diff --git a/mujina-miner/src/api/server.rs b/mujina-miner/src/api/server.rs index e7e0a16c..9554e067 100644 --- a/mujina-miner/src/api/server.rs +++ b/mujina-miner/src/api/server.rs @@ -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); } @@ -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( + 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::(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::(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); + } } diff --git a/mujina-miner/src/api/v0.rs b/mujina-miner/src/api/v0.rs index 5778fe5d..d3d3dc59 100644 --- a/mujina-miner/src/api/v0.rs +++ b/mujina-miner/src/api/v0.rs @@ -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. @@ -26,6 +26,7 @@ pub fn routes() -> OpenApiRouter { .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)) } @@ -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, + Path((name, fan)): Path<(String, String)>, + Json(req): Json, +) -> Result, 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) } diff --git a/mujina-miner/src/api_client/types.rs b/mujina-miner/src/api_client/types.rs index 6042ba0b..bb888b57 100644 --- a/mujina-miner/src/api_client/types.rs +++ b/mujina-miner/src/api_client/types.rs @@ -33,6 +33,9 @@ pub struct BoardTelemetry { pub temperatures: Vec, pub powers: Vec, pub threads: Vec, + /// Per-ASIC topology/diagnostics state (multi-ASIC boards only). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub asics: Vec, } /// Fan status. @@ -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, + #[serde(skip_serializing_if = "Option::is_none")] + pub serial_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub discovered_engine_count: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub missing_engines: Vec, +} + +/// 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 diff --git a/mujina-miner/src/asic/hash_thread.rs b/mujina-miner/src/asic/hash_thread.rs index bf4f2636..6be56026 100644 --- a/mujina-miner/src/asic/hash_thread.rs +++ b/mujina-miner/src/asic/hash_thread.rs @@ -76,6 +76,29 @@ pub struct HashThreadStatus { pub is_active: bool, } +/// Temperature reading reported by a hash thread. +#[derive(Debug, Clone, PartialEq)] +pub struct HashThreadTemperatureReading { + pub name: String, + pub temperature_c: Option, +} + +/// Voltage/current/power reading reported by a hash thread. +#[derive(Debug, Clone, PartialEq)] +pub struct HashThreadPowerReading { + pub name: String, + pub voltage_v: Option, + pub current_a: Option, + pub power_w: Option, +} + +/// Telemetry update reported by a hash thread. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct HashThreadTelemetryUpdate { + pub temperatures: Vec, + pub powers: Vec, +} + /// Events emitted by HashThreads back to the scheduler. /// /// When a thread shuts down (USB unplug, fault, user request, etc.), it closes @@ -106,8 +129,38 @@ pub enum HashThreadEvent { /// /// Emitted after `configure()` and whenever the expectation changes. ExpectedHashRate(HashRate), + + /// Additional telemetry update + TelemetryUpdate(HashThreadTelemetryUpdate), } +/// Error types for HashThread operations. +#[derive(Debug, thiserror::Error)] +pub enum HashThreadError { + #[error("Thread has been shut down")] + ThreadOffline, + + #[error("Channel closed: {0}")] + ChannelClosed(String), + + #[error("Work assignment failed: {0}")] + WorkAssignmentFailed(String), + + #[error("Preemption failed: {0}")] + PreemptionFailed(String), + + #[error("Telemetry query failed: {0}")] + TelemetryQueryFailed(String), + + #[error("Diagnostics failed: {0}")] + DiagnosticsFailed(String), + + #[error("Shutdown timeout")] + ShutdownTimeout, + + #[error("Chip initialization failed: {0}")] + InitializationFailed(String), +} // --------------------------------------------------------------------------- // Hardware abstraction traits for hash threads // --------------------------------------------------------------------------- diff --git a/mujina-miner/src/backplane.rs b/mujina-miner/src/backplane.rs index 44c30612..26e91ffa 100644 --- a/mujina-miner/src/backplane.rs +++ b/mujina-miner/src/backplane.rs @@ -139,10 +139,14 @@ impl Backplane { info, threads, telemetry_rx, + command_tx, shutdown, } = conn; - let registration = BoardRegistration { telemetry_rx }; + let registration = BoardRegistration { + telemetry_rx, + command_tx, + }; if let Err(e) = self.board_reg_tx.send(registration).await { error!( board = %info.model, @@ -290,6 +294,41 @@ impl Backplane { } } + Ok(()) + } + /// Attach a configured board directly without going through a synthetic transport. + pub async fn attach_configured_board( + &mut self, + device_type: &str, + device_id: String, + ) -> Result<()> { + let Some(descriptor) = self.virtual_registry.find(device_type) else { + error!(device_type = %device_type, "No configured board descriptor found"); + return Ok(()); + }; + + info!( + board = descriptor.name, + device_type = %device_type, + device_id = %device_id, + "Configured board attached." + ); + + let conn = match (descriptor.create_fn)().await { + Ok(conn) => conn, + Err(e) => { + error!( + board = descriptor.name, + device_type = %device_type, + error = %e, + "Failed to create configured board" + ); + return Ok(()); + } + }; + + self.start_board(device_id, conn).await; + Ok(()) } } diff --git a/mujina-miner/src/board/bitaxe.rs b/mujina-miner/src/board/bitaxe.rs index 2108e665..03b3769b 100644 --- a/mujina-miner/src/board/bitaxe.rs +++ b/mujina-miner/src/board/bitaxe.rs @@ -228,6 +228,7 @@ async fn create_from_usb(device: UsbDeviceInfo) -> Result { info, threads, telemetry_rx, + command_tx: None, shutdown: Some(shutdown), }) } @@ -419,6 +420,7 @@ impl Bitaxe { }, ], threads: Vec::new(), // TODO: populate from hash thread telemetry + ..Default::default() }); // Periodic log diff --git a/mujina-miner/src/board/cpu.rs b/mujina-miner/src/board/cpu.rs index 41e30a1f..850a9255 100644 --- a/mujina-miner/src/board/cpu.rs +++ b/mujina-miner/src/board/cpu.rs @@ -55,6 +55,7 @@ async fn create_cpu_board() -> Result { info, threads, telemetry_rx, + command_tx: None, shutdown: None, }) } diff --git a/mujina-miner/src/board/emberone00.rs b/mujina-miner/src/board/emberone00.rs index 60c8e56b..c17cb67c 100644 --- a/mujina-miner/src/board/emberone00.rs +++ b/mujina-miner/src/board/emberone00.rs @@ -171,6 +171,7 @@ async fn create_from_usb(device: UsbDeviceInfo) -> Result { info, threads: Vec::new(), telemetry_rx, + command_tx: None, shutdown: Some(shutdown), }) } diff --git a/mujina-miner/src/board/mod.rs b/mujina-miner/src/board/mod.rs index 3b9e161b..f5eec8e6 100644 --- a/mujina-miner/src/board/mod.rs +++ b/mujina-miner/src/board/mod.rs @@ -2,13 +2,15 @@ pub(crate) mod bitaxe; pub(crate) mod cpu; pub(crate) mod emberone00; pub mod pattern; +pub mod power; use anyhow::Result; use futures::future::BoxFuture; -use tokio::sync::watch; +use tokio::sync::{mpsc, watch}; use crate::{ - api_client::types::BoardTelemetry, asic::hash_thread::HashThread, transport::UsbDeviceInfo, + api::commands::BoardCommand, api_client::types::BoardTelemetry, asic::hash_thread::HashThread, + transport::UsbDeviceInfo, }; /// Returned by board factory functions with everything the backplane @@ -23,6 +25,10 @@ pub struct BackplaneConnector { /// Watch receiver for the board's telemetry stream. pub telemetry_rx: watch::Receiver, + /// Sender for board commands (diagnostics, fan control). `None` if + /// the board accepts no commands. + pub command_tx: Option>, + /// Shuts down the board when awaited. `None` if the board has /// no shutdown work to do. pub shutdown: Option>, diff --git a/mujina-miner/src/board/power.rs b/mujina-miner/src/board/power.rs new file mode 100644 index 00000000..1a2ead14 --- /dev/null +++ b/mujina-miner/src/board/power.rs @@ -0,0 +1,425 @@ +use std::time::Duration; + +use anyhow::Result; +use async_trait::async_trait; +use tokio::fs; +use tokio::time::sleep; + +use crate::{ + asic::hash_thread::{AsicEnable, VoltageRegulator}, + hw_trait::{ + gpio::{GpioPin, PinValue}, + i2c::I2c, + }, + peripheral::tps546::Tps546, +}; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct PowerRailTelemetry { + pub vin_volts: f32, + pub vout_volts: f32, + pub current_amps: f32, + pub temperature_c: f32, + pub power_watts: f32, +} + +#[async_trait] +pub trait PowerRail: Send + Sync { + async fn initialize(&mut self) -> Result<()>; + async fn set_voltage(&mut self, volts: f32) -> Result<()>; + async fn telemetry(&mut self) -> Result; +} + +pub struct Tps546PowerRail { + inner: Tps546, +} + +impl Tps546PowerRail { + pub fn new(inner: Tps546) -> Self { + Self { inner } + } + + pub fn into_inner(self) -> Tps546 { + self.inner + } +} + +#[async_trait] +impl PowerRail for Tps546PowerRail { + async fn initialize(&mut self) -> Result<()> { + self.inner.init().await + } + + async fn set_voltage(&mut self, volts: f32) -> Result<()> { + self.inner.set_vout(volts).await + } + + async fn telemetry(&mut self) -> Result { + Ok(PowerRailTelemetry { + vin_volts: self.inner.get_vin().await? as f32 / 1000.0, + vout_volts: self.inner.get_vout().await? as f32 / 1000.0, + current_amps: self.inner.get_iout().await? as f32 / 1000.0, + temperature_c: self.inner.get_temperature().await? as f32, + power_watts: self.inner.get_power().await? as f32 / 1000.0, + }) + } +} + +#[async_trait] +impl VoltageRegulator for Tps546PowerRail { + async fn set_voltage(&mut self, volts: f32) -> Result<()> { + PowerRail::set_voltage(self, volts).await + } +} + +pub struct GpioResetLine { + pin: PIN, + active_low: bool, +} + +impl GpioResetLine { + pub fn new(pin: PIN, active_low: bool) -> Self { + Self { pin, active_low } + } + + pub fn into_inner(self) -> PIN { + self.pin + } + + async fn drive(&mut self, asserted: bool) -> Result<()> + where + PIN: GpioPin, + { + let value = if asserted == self.active_low { + PinValue::Low + } else { + PinValue::High + }; + self.pin.write(value).await?; + Ok(()) + } + + pub async fn pulse(&mut self, assert_for: Duration, settle_for: Duration) -> Result<()> + where + PIN: GpioPin, + { + self.drive(true).await?; + sleep(assert_for).await; + self.drive(false).await?; + sleep(settle_for).await; + Ok(()) + } +} + +#[derive(Debug, Clone)] +pub struct FileGpioPin { + path: String, + high_value: String, + low_value: String, +} + +impl FileGpioPin { + pub fn new( + path: impl Into, + high_value: impl Into, + low_value: impl Into, + ) -> Self { + Self { + path: path.into(), + high_value: high_value.into(), + low_value: low_value.into(), + } + } +} + +#[async_trait] +impl GpioPin for FileGpioPin { + async fn set_mode( + &mut self, + _mode: crate::hw_trait::gpio::PinMode, + ) -> crate::hw_trait::Result<()> { + Ok(()) + } + + async fn write(&mut self, value: PinValue) -> crate::hw_trait::Result<()> { + let raw = match value { + PinValue::Low => &self.low_value, + PinValue::High => &self.high_value, + }; + fs::write(&self.path, raw).await?; + Ok(()) + } + + async fn read(&mut self) -> crate::hw_trait::Result { + let raw = fs::read_to_string(&self.path).await?; + if raw.trim() == self.high_value.trim() { + Ok(PinValue::High) + } else { + Ok(PinValue::Low) + } + } +} + +#[async_trait] +impl AsicEnable for GpioResetLine { + async fn enable(&mut self) -> Result<()> { + self.drive(false).await + } + + async fn disable(&mut self) -> Result<()> { + self.drive(true).await + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct VoltageStackStep { + pub rail_index: usize, + pub voltage: f32, + pub settle_for: Duration, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct VoltageStackBringupPlan { + pub assert_reset_before_power: bool, + pub pre_power_delay: Duration, + pub post_power_delay: Duration, + pub release_reset_delay: Duration, + pub steps: Vec, +} + +#[derive(Debug, Clone)] +pub struct FilePowerRail { + set_path: String, + write_scale: f32, + enable_path: Option, + enable_value: Option, +} + +impl FilePowerRail { + pub fn new(path: impl Into, write_scale: f32) -> Self { + Self { + set_path: path.into(), + write_scale, + enable_path: None, + enable_value: None, + } + } + + pub fn with_enable( + mut self, + enable_path: impl Into, + enable_value: impl Into, + ) -> Self { + self.enable_path = Some(enable_path.into()); + self.enable_value = Some(enable_value.into()); + self + } + + fn encode_voltage(&self, volts: f32) -> String { + if (self.write_scale - 1.0).abs() < f32::EPSILON { + format!("{volts:.6}") + } else { + format!("{}", (volts * self.write_scale).round() as i64) + } + } +} + +#[async_trait] +impl PowerRail for FilePowerRail { + async fn initialize(&mut self) -> Result<()> { + if let (Some(path), Some(value)) = (&self.enable_path, &self.enable_value) { + fs::write(path, value).await?; + } + Ok(()) + } + + async fn set_voltage(&mut self, volts: f32) -> Result<()> { + fs::write(&self.set_path, self.encode_voltage(volts)).await?; + Ok(()) + } + + async fn telemetry(&mut self) -> Result { + Ok(PowerRailTelemetry { + vin_volts: 0.0, + vout_volts: 0.0, + current_amps: 0.0, + temperature_c: 0.0, + power_watts: 0.0, + }) + } +} + +impl Default for VoltageStackBringupPlan { + fn default() -> Self { + Self { + assert_reset_before_power: true, + pre_power_delay: Duration::from_millis(10), + post_power_delay: Duration::from_millis(25), + release_reset_delay: Duration::from_millis(25), + steps: Vec::new(), + } + } +} + +impl VoltageStackBringupPlan { + pub async fn apply( + &self, + rails: &mut [R], + mut reset_line: Option<&mut GpioResetLine>, + ) -> Result<()> + where + R: PowerRail, + PIN: GpioPin, + { + if self.assert_reset_before_power { + if let Some(reset_line) = reset_line.as_deref_mut() { + reset_line.disable().await?; + } + sleep(self.pre_power_delay).await; + } + + for rail in rails.iter_mut() { + rail.initialize().await?; + } + + for step in &self.steps { + let rail = rails + .get_mut(step.rail_index) + .ok_or_else(|| anyhow::anyhow!("rail index {} out of range", step.rail_index))?; + rail.set_voltage(step.voltage).await?; + sleep(step.settle_for).await; + } + + sleep(self.post_power_delay).await; + + if let Some(reset_line) = reset_line { + reset_line.enable().await?; + sleep(self.release_reset_delay).await; + } + + Ok(()) + } + + pub async fn shutdown( + &self, + rails: &mut [R], + reset_line: Option<&mut GpioResetLine>, + ) -> Result<()> + where + R: PowerRail, + PIN: GpioPin, + { + if let Some(reset_line) = reset_line { + reset_line.disable().await?; + } + + for rail in rails.iter_mut().rev() { + rail.set_voltage(0.0).await?; + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hw_trait::{Result as HwResult, gpio::PinMode}; + + #[derive(Default)] + struct MockPin { + writes: Vec, + } + + #[async_trait] + impl GpioPin for MockPin { + async fn set_mode(&mut self, _mode: PinMode) -> HwResult<()> { + Ok(()) + } + + async fn write(&mut self, value: PinValue) -> HwResult<()> { + self.writes.push(value); + Ok(()) + } + + async fn read(&mut self) -> HwResult { + Ok(self.writes.last().copied().unwrap_or(PinValue::Low)) + } + } + + #[derive(Default)] + struct MockRail { + initialized: bool, + voltages: Vec, + } + + #[async_trait] + impl PowerRail for MockRail { + async fn initialize(&mut self) -> Result<()> { + self.initialized = true; + Ok(()) + } + + async fn set_voltage(&mut self, volts: f32) -> Result<()> { + self.voltages.push(volts); + Ok(()) + } + + async fn telemetry(&mut self) -> Result { + Ok(PowerRailTelemetry { + vin_volts: 12.0, + vout_volts: self.voltages.last().copied().unwrap_or_default(), + current_amps: 1.0, + temperature_c: 42.0, + power_watts: 12.0, + }) + } + } + + #[tokio::test] + async fn bringup_plan_sequences_rails_then_releases_reset() { + let mut rails = vec![MockRail::default(), MockRail::default()]; + let mut reset = GpioResetLine::new(MockPin::default(), true); + let plan = VoltageStackBringupPlan { + pre_power_delay: Duration::from_millis(0), + post_power_delay: Duration::from_millis(0), + release_reset_delay: Duration::from_millis(0), + steps: vec![ + VoltageStackStep { + rail_index: 0, + voltage: 0.82, + settle_for: Duration::from_millis(0), + }, + VoltageStackStep { + rail_index: 1, + voltage: 0.79, + settle_for: Duration::from_millis(0), + }, + ], + ..Default::default() + }; + + plan.apply(&mut rails, Some(&mut reset)).await.unwrap(); + + assert!(rails[0].initialized); + assert!(rails[1].initialized); + assert_eq!(rails[0].voltages, vec![0.82]); + assert_eq!(rails[1].voltages, vec![0.79]); + let pin = reset.into_inner(); + assert_eq!(pin.writes, vec![PinValue::Low, PinValue::High]); + } + + #[tokio::test] + async fn shutdown_plan_asserts_reset_then_powers_off_rails() { + let mut rails = vec![MockRail::default(), MockRail::default()]; + let mut reset = GpioResetLine::new(MockPin::default(), true); + let plan = VoltageStackBringupPlan::default(); + + plan.shutdown(&mut rails, Some(&mut reset)).await.unwrap(); + + assert_eq!(rails[0].voltages, vec![0.0]); + assert_eq!(rails[1].voltages, vec![0.0]); + let pin = reset.into_inner(); + assert_eq!(pin.writes, vec![PinValue::Low]); + } +} diff --git a/mujina-miner/src/scheduler.rs b/mujina-miner/src/scheduler.rs index e9f44bfa..6a9fa90f 100644 --- a/mujina-miner/src/scheduler.rs +++ b/mujina-miner/src/scheduler.rs @@ -634,6 +634,10 @@ impl Scheduler { ); } + HashThreadEvent::TelemetryUpdate(_) => { + trace!(thread = %thread_name, "Thread telemetry update"); + } + HashThreadEvent::ExpectedHashRate(rate) => { let Some(entry) = self.threads.get_mut(thread_id) else { return; diff --git a/mujina-miner/src/transport/serial.rs b/mujina-miner/src/transport/serial.rs index e04ad65a..44da0f6d 100644 --- a/mujina-miner/src/transport/serial.rs +++ b/mujina-miner/src/transport/serial.rs @@ -120,16 +120,19 @@ struct SerialInner { } /// Reader half of a split serial stream. +#[derive(Clone)] pub struct SerialReader { inner: Arc, } /// Writer half of a split serial stream. +#[derive(Clone)] pub struct SerialWriter { inner: Arc, } /// Control handle for a split serial stream. +#[derive(Clone)] pub struct SerialControl { inner: Arc, }