From af09c1d8e19e8542355490f75880d2eeba772655 Mon Sep 17 00:00:00 2001 From: Reckless Apotheosis <125509978+recklessnode@users.noreply.github.com> Date: Fri, 6 Mar 2026 15:52:00 -0800 Subject: [PATCH 01/24] feat(bzm2): add initial BZM2 board integration Add the first upstream BZM2 integration slice: - introduce the new ASIC module with protocol and mining thread support - add a virtual BZM2 board and transport wiring for serial-backed devices - register the board with the backplane and daemon startup path This commit intentionally lands the core integration surface first. Telemetry, tuning, diagnostics, and broader API support follow in later commits. --- mujina-miner/src/asic/bzm2/mod.rs | 4 + mujina-miner/src/asic/bzm2/protocol.rs | 232 ++++++ mujina-miner/src/asic/bzm2/thread.rs | 752 +++++++++++++++++++ mujina-miner/src/asic/mod.rs | 1 + mujina-miner/src/backplane.rs | 100 +++ mujina-miner/src/board/bzm2.rs | 213 ++++++ mujina-miner/src/board/mod.rs | 1 + mujina-miner/src/daemon.rs | 22 +- mujina-miner/src/transport/mod.rs | 5 + mujina-miner/src/transport/virtual_device.rs | 23 + 10 files changed, 1352 insertions(+), 1 deletion(-) create mode 100644 mujina-miner/src/asic/bzm2/mod.rs create mode 100644 mujina-miner/src/asic/bzm2/protocol.rs create mode 100644 mujina-miner/src/asic/bzm2/thread.rs create mode 100644 mujina-miner/src/board/bzm2.rs create mode 100644 mujina-miner/src/transport/virtual_device.rs diff --git a/mujina-miner/src/asic/bzm2/mod.rs b/mujina-miner/src/asic/bzm2/mod.rs new file mode 100644 index 00000000..be7a9e60 --- /dev/null +++ b/mujina-miner/src/asic/bzm2/mod.rs @@ -0,0 +1,4 @@ +pub mod protocol; +pub mod thread; + +pub use thread::{Bzm2Thread, Bzm2ThreadConfig, Bzm2ThreadHandle}; diff --git a/mujina-miner/src/asic/bzm2/protocol.rs b/mujina-miner/src/asic/bzm2/protocol.rs new file mode 100644 index 00000000..99109f93 --- /dev/null +++ b/mujina-miner/src/asic/bzm2/protocol.rs @@ -0,0 +1,232 @@ +use std::collections::HashSet; + +pub const OPCODE_UART_READRESULT: u8 = 0x1; +pub const OPCODE_UART_WRITEREG: u8 = 0x2; +pub const OPCODE_UART_WRITEJOB: u8 = 0x0; + +pub const BROADCAST_ASIC: u8 = 0xff; + +pub const ENGINE_REG_TARGET: u8 = 0x44; +pub const ENGINE_REG_TIMESTAMP_COUNT: u8 = 0x48; +pub const ENGINE_REG_ZEROS_TO_FIND: u8 = 0x49; + +pub const DEFAULT_TIMESTAMP_COUNT: u8 = 60; +pub const DEFAULT_NONCE_GAP: u32 = 0x28; +pub const LOGICAL_ENGINE_ROWS: u8 = 20; +pub const LOGICAL_ENGINE_COLS: u8 = 12; + +#[derive(Debug, Clone, Copy)] +pub struct TdmResultFrame { + pub asic: u8, + pub engine_address: u16, + pub status: u8, + pub nonce: u32, + pub sequence_id: u8, + pub reported_time: u8, +} + +impl TdmResultFrame { + pub fn row(self) -> u8 { + (self.engine_address & 0x3f) as u8 + } + + pub fn col(self) -> u8 { + (self.engine_address >> 6) as u8 + } + + pub fn logical_engine_id(self) -> Option { + logical_engine_id(self.row(), self.col()) + } + + pub fn nonce_valid(self) -> bool { + (self.status & 0x8) != 0 + } +} + +#[derive(Default)] +pub struct TdmResultParser { + buffer: Vec, +} + +impl TdmResultParser { + pub fn push(&mut self, bytes: &[u8]) -> Vec { + self.buffer.extend_from_slice(bytes); + + let mut frames = Vec::new(); + let mut cursor = 0usize; + + while self.buffer.len().saturating_sub(cursor) >= 10 { + let asic = self.buffer[cursor]; + let opcode = self.buffer[cursor + 1]; + + if asic >= 100 || opcode != OPCODE_UART_READRESULT { + cursor += 1; + continue; + } + + let payload = &self.buffer[cursor + 2..cursor + 10]; + let header = u16::from_be_bytes([payload[0], payload[1]]); + let engine_address = header & 0x0fff; + let status = (header >> 12) as u8; + let nonce = u32::from_le_bytes(payload[2..6].try_into().unwrap()); + let sequence_id = payload[6]; + let reported_time = payload[7]; + + frames.push(TdmResultFrame { + asic, + engine_address, + status, + nonce, + sequence_id, + reported_time, + }); + cursor += 10; + } + + if cursor > 0 { + self.buffer.drain(..cursor); + } + + frames + } +} + +pub fn encode_write_register(asic: u8, engine_address: u16, offset: u8, value: &[u8]) -> Vec { + let mut bytes = Vec::with_capacity(7 + value.len()); + let header = ((asic as u32) << 24) + | ((OPCODE_UART_WRITEREG as u32) << 20) + | ((engine_address as u32) << 8) + | offset as u32; + + bytes.extend_from_slice(&((7 + value.len()) as u16).to_le_bytes()); + bytes.extend_from_slice(&header.to_be_bytes()); + bytes.push((value.len() as u8).saturating_sub(1)); + bytes.extend_from_slice(value); + bytes +} + +pub fn encode_write_job( + asic: u8, + engine_address: u16, + midstate: &[u8; 32], + merkle_root_residue: u32, + ntime: u32, + sequence_id: u8, + job_control: u8, +) -> Vec { + let mut bytes = Vec::with_capacity(48); + let header = ((asic as u32) << 24) + | ((OPCODE_UART_WRITEJOB as u32) << 20) + | ((engine_address as u32) << 8) + | 41u32; + + bytes.extend_from_slice(&(48u16).to_le_bytes()); + bytes.extend_from_slice(&header.to_be_bytes()); + bytes.extend_from_slice(midstate); + bytes.extend_from_slice(&merkle_root_residue.to_le_bytes()); + bytes.extend_from_slice(&ntime.to_le_bytes()); + bytes.push(sequence_id); + bytes.push(job_control); + bytes +} + +pub fn logical_engine_address(row: u8, col: u8) -> u16 { + ((col as u16) << 6) | row as u16 +} + +pub fn logical_engine_id(row: u8, col: u8) -> Option { + if row >= LOGICAL_ENGINE_ROWS || col >= LOGICAL_ENGINE_COLS { + return None; + } + if default_excluded_engines().contains(&(row, col)) { + return None; + } + + let excluded = default_excluded_engines(); + let mut id = 0u16; + for c in 0..LOGICAL_ENGINE_COLS { + for r in 0..LOGICAL_ENGINE_ROWS { + if excluded.contains(&(r, c)) { + continue; + } + if r == row && c == col { + return Some(id); + } + id += 1; + } + } + + None +} + +pub fn default_excluded_engines() -> HashSet<(u8, u8)> { + HashSet::from([(0, 4), (0, 5), (19, 5), (19, 11)]) +} + +pub fn default_engine_coordinates() -> Vec<(u8, u8)> { + let excluded = default_excluded_engines(); + let mut coords = Vec::new(); + for col in 0..LOGICAL_ENGINE_COLS { + for row in 0..LOGICAL_ENGINE_ROWS { + if excluded.contains(&(row, col)) { + continue; + } + coords.push((row, col)); + } + } + coords +} + +pub fn leading_zero_threshold(target: bitcoin::pow::Target) -> u8 { + let bytes = target.to_be_bytes(); + let mut zeros = 0u8; + + 'outer: for byte in bytes { + if byte == 0 { + zeros = zeros.saturating_add(8); + continue; + } + + for bit in (0..8).rev() { + if (byte & (1 << bit)) == 0 { + zeros = zeros.saturating_add(1); + } else { + break 'outer; + } + } + break; + } + + zeros.clamp(32, 64) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parser_decodes_tdm_result() { + let mut parser = TdmResultParser::default(); + let frame = [ + 0x02, + OPCODE_UART_READRESULT, + 0x41, + 0x23, + 0x78, + 0x56, + 0x34, + 0x12, + 0x05, + 0x09, + ]; + + let parsed = parser.push(&frame); + assert_eq!(parsed.len(), 1); + assert_eq!(parsed[0].asic, 0x02); + assert_eq!(parsed[0].status, 0x4); + assert_eq!(parsed[0].engine_address, 0x0123); + assert_eq!(parsed[0].nonce, 0x1234_5678); + assert_eq!(parsed[0].sequence_id, 0x05); + assert_eq!(parsed[0].reported_time, 0x09); + } +} diff --git a/mujina-miner/src/asic/bzm2/thread.rs b/mujina-miner/src/asic/bzm2/thread.rs new file mode 100644 index 00000000..fed82527 --- /dev/null +++ b/mujina-miner/src/asic/bzm2/thread.rs @@ -0,0 +1,752 @@ +use std::collections::HashMap; +use std::sync::{Arc, RwLock}; +use std::time::Duration; + +use async_trait::async_trait; +use bitcoin::block::Header as BlockHeader; +use bitcoin::consensus::serialize; +use bitcoin::hashes::{HashEngine, sha256}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::sync::{mpsc, oneshot}; + +use crate::asic::hash_thread::{ + HashTask, HashThread, HashThreadCapabilities, HashThreadError, HashThreadEvent, + HashThreadStatus, Share, +}; +use crate::job_source::{GeneralPurposeBits, MerkleRootKind}; +use crate::tracing::prelude::*; +use crate::transport::serial::{SerialControl, SerialReader, SerialWriter}; +use crate::types::{Difficulty, HashRate}; + +use super::protocol::{ + self, BROADCAST_ASIC, DEFAULT_NONCE_GAP, DEFAULT_TIMESTAMP_COUNT, ENGINE_REG_TARGET, + ENGINE_REG_TIMESTAMP_COUNT, ENGINE_REG_ZEROS_TO_FIND, TdmResultParser, + default_engine_coordinates, encode_write_job, encode_write_register, leading_zero_threshold, + logical_engine_address, +}; + +#[derive(Debug, Clone)] +pub struct Bzm2ThreadConfig { + pub serial_path: String, + pub baud_rate: u32, + pub timestamp_count: u8, + pub nonce_gap: u32, + pub dispatch_interval: Duration, + pub nominal_hashrate_ths: f64, +} + +impl Bzm2ThreadConfig { + pub fn new(serial_path: String, baud_rate: u32) -> Self { + Self { + serial_path, + baud_rate, + timestamp_count: DEFAULT_TIMESTAMP_COUNT, + nonce_gap: DEFAULT_NONCE_GAP, + dispatch_interval: Duration::from_millis(500), + nominal_hashrate_ths: 40.0, + } + } +} + +#[derive(Clone)] +pub struct Bzm2ThreadHandle { + command_tx: mpsc::Sender, +} + +impl Bzm2ThreadHandle { + pub fn shutdown(&self) { + let _ = self.command_tx.try_send(ThreadCommand::Shutdown); + } +} + +#[derive(Debug)] +enum ThreadCommand { + UpdateTask { + new_task: HashTask, + response_tx: oneshot::Sender, HashThreadError>>, + }, + ReplaceTask { + new_task: HashTask, + response_tx: oneshot::Sender, HashThreadError>>, + }, + GoIdle { + response_tx: oneshot::Sender, HashThreadError>>, + }, + Shutdown, +} + +#[derive(Clone)] +struct EngineDispatch { + task: HashTask, + merkle_root: bitcoin::TxMerkleNode, + versions: [bitcoin::block::Version; 4], + base_sequence: u8, +} + +pub struct Bzm2Thread { + name: String, + command_tx: mpsc::Sender, + event_rx: Option>, + capabilities: HashThreadCapabilities, + status: Arc>, +} + +impl Bzm2Thread { + pub fn new( + name: String, + reader: SerialReader, + writer: SerialWriter, + control: SerialControl, + config: Bzm2ThreadConfig, + ) -> Self { + let (command_tx, command_rx) = mpsc::channel(16); + let (event_tx, event_rx) = mpsc::channel(64); + let status = Arc::new(RwLock::new(HashThreadStatus::default())); + let status_clone = Arc::clone(&status); + let nominal_hashrate_ths = config.nominal_hashrate_ths; + + tokio::spawn(async move { + bzm2_thread_actor(command_rx, event_tx, status_clone, reader, writer, control, config) + .await; + }); + + Self { + name, + command_tx, + event_rx: Some(event_rx), + capabilities: HashThreadCapabilities { + hashrate_estimate: HashRate::from_terahashes(nominal_hashrate_ths), + }, + status, + } + } + + pub fn shutdown_handle(&self) -> Bzm2ThreadHandle { + Bzm2ThreadHandle { + command_tx: self.command_tx.clone(), + } + } +} + +#[async_trait] +impl HashThread for Bzm2Thread { + fn name(&self) -> &str { + &self.name + } + + fn capabilities(&self) -> &HashThreadCapabilities { + &self.capabilities + } + + async fn update_task( + &mut self, + new_task: HashTask, + ) -> Result, HashThreadError> { + let (response_tx, response_rx) = oneshot::channel(); + self.command_tx + .send(ThreadCommand::UpdateTask { + new_task, + response_tx, + }) + .await + .map_err(|_| HashThreadError::ChannelClosed("command channel closed".into()))?; + response_rx + .await + .map_err(|_| HashThreadError::WorkAssignmentFailed("thread dropped response".into()))? + } + + async fn replace_task( + &mut self, + new_task: HashTask, + ) -> Result, HashThreadError> { + let (response_tx, response_rx) = oneshot::channel(); + self.command_tx + .send(ThreadCommand::ReplaceTask { + new_task, + response_tx, + }) + .await + .map_err(|_| HashThreadError::ChannelClosed("command channel closed".into()))?; + response_rx + .await + .map_err(|_| HashThreadError::WorkAssignmentFailed("thread dropped response".into()))? + } + + async fn go_idle(&mut self) -> Result, HashThreadError> { + let (response_tx, response_rx) = oneshot::channel(); + self.command_tx + .send(ThreadCommand::GoIdle { response_tx }) + .await + .map_err(|_| HashThreadError::ChannelClosed("command channel closed".into()))?; + response_rx + .await + .map_err(|_| HashThreadError::WorkAssignmentFailed("thread dropped response".into()))? + } + + fn take_event_receiver(&mut self) -> Option> { + self.event_rx.take() + } + + fn status(&self) -> HashThreadStatus { + self.status.read().unwrap().clone() + } +} + +async fn bzm2_thread_actor( + mut command_rx: mpsc::Receiver, + event_tx: mpsc::Sender, + status: Arc>, + mut reader: SerialReader, + mut writer: SerialWriter, + control: SerialControl, + config: Bzm2ThreadConfig, +) { + if let Err(err) = control.set_baud_rate(config.baud_rate) { + warn!(path = %config.serial_path, error = %err, "Failed to set BZM2 baud rate"); + } + + let _ = event_tx + .send(HashThreadEvent::StatusUpdate(snapshot_status(&status))) + .await; + + let engine_coords = default_engine_coordinates(); + let mut parser = TdmResultParser::default(); + let mut current_task: Option = None; + let mut engine_dispatches: HashMap = HashMap::new(); + let mut base_sequence: u8 = 0; + let mut dispatch_tick = tokio::time::interval(config.dispatch_interval); + dispatch_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + let mut ntime_tick = tokio::time::interval(Duration::from_secs(1)); + ntime_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + let mut status_tick = tokio::time::interval(Duration::from_secs(5)); + status_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + let mut read_buf = [0u8; 4096]; + + loop { + tokio::select! { + Some(command) = command_rx.recv() => { + match command { + ThreadCommand::UpdateTask { new_task, response_tx } => { + let old = current_task.replace(new_task); + if let Some(ref task) = current_task { + if let Err(err) = dispatch_task_to_board( + &mut writer, + task, + base_sequence, + &engine_coords, + &mut engine_dispatches, + &config, + ).await { + let _ = response_tx.send(Err(err)); + continue; + } + base_sequence = base_sequence.wrapping_add(1); + set_active(&status, true); + let _ = event_tx.send(HashThreadEvent::StatusUpdate(snapshot_status(&status))).await; + } + let _ = response_tx.send(Ok(old)); + } + ThreadCommand::ReplaceTask { new_task, response_tx } => { + engine_dispatches.clear(); + let old = current_task.replace(new_task); + if let Some(ref task) = current_task { + if let Err(err) = dispatch_task_to_board( + &mut writer, + task, + base_sequence, + &engine_coords, + &mut engine_dispatches, + &config, + ).await { + let _ = response_tx.send(Err(err)); + continue; + } + base_sequence = base_sequence.wrapping_add(1); + set_active(&status, true); + let _ = event_tx.send(HashThreadEvent::StatusUpdate(snapshot_status(&status))).await; + } + let _ = response_tx.send(Ok(old)); + } + ThreadCommand::GoIdle { response_tx } => { + engine_dispatches.clear(); + let old = current_task.take(); + set_active(&status, false); + let _ = event_tx.send(HashThreadEvent::StatusUpdate(snapshot_status(&status))).await; + let _ = response_tx.send(Ok(old)); + } + ThreadCommand::Shutdown => break, + } + } + read_result = reader.read(&mut read_buf) => { + match read_result { + Ok(0) => break, + Ok(n) => { + for frame in parser.push(&read_buf[..n]) { + handle_result_frame( + &frame, + &engine_dispatches, + &config, + &status, + &event_tx, + ) + .await; + } + } + Err(err) => { + error!(path = %config.serial_path, error = %err, "BZM2 serial read failed"); + break; + } + } + } + _ = dispatch_tick.tick(), if current_task.is_some() => { + if let Some(ref task) = current_task { + match dispatch_task_to_board( + &mut writer, + task, + base_sequence, + &engine_coords, + &mut engine_dispatches, + &config, + ).await { + Ok(()) => { + base_sequence = base_sequence.wrapping_add(1); + } + Err(err) => { + error!(path = %config.serial_path, error = %err, "BZM2 dispatch failed"); + } + } + } + } + _ = ntime_tick.tick(), if current_task.is_some() => { + if let Some(ref mut task) = current_task { + task.ntime = task.ntime.wrapping_add(1); + } + } + _ = status_tick.tick() => { + let _ = event_tx.send(HashThreadEvent::StatusUpdate(snapshot_status(&status))).await; + } + } + } +} + +async fn dispatch_task_to_board( + writer: &mut SerialWriter, + task: &HashTask, + base_sequence: u8, + engine_coords: &[(u8, u8)], + engine_dispatches: &mut HashMap, + config: &Bzm2ThreadConfig, +) -> Result<(), HashThreadError> { + let merkle_root = match &task.template.merkle_root { + MerkleRootKind::Fixed(root) => *root, + MerkleRootKind::Computed(_) => { + let en2 = task.en2.as_ref().ok_or_else(|| { + HashThreadError::WorkAssignmentFailed( + "BZM2 requires extranonce2 for computed merkle root".into(), + ) + })?; + task.template.compute_merkle_root(en2).map_err(|err| { + HashThreadError::WorkAssignmentFailed(format!( + "BZM2 merkle root computation failed: {err}" + )) + })? + } + }; + + let versions = compute_micro_versions(task); + let midstates = versions.map(|version| compute_midstate(task, merkle_root, version)); + let header_bytes = serialize(&BlockHeader { + version: versions[0], + prev_blockhash: task.template.prev_blockhash, + merkle_root, + time: task.ntime, + bits: task.template.bits, + nonce: 0, + }); + let merkle_root_residue = u32::from_le_bytes(header_bytes[64..68].try_into().unwrap()); + let lead_zeros = leading_zero_threshold(task.share_target).saturating_sub(32); + let timestamp_count = config.timestamp_count; + let bits = task.template.bits.to_consensus(); + + for &(row, col) in engine_coords { + let engine_address = logical_engine_address(row, col); + + writer + .write_all(&encode_write_register( + BROADCAST_ASIC, + engine_address, + ENGINE_REG_ZEROS_TO_FIND, + &[lead_zeros], + )) + .await + .map_err(|err| { + HashThreadError::WorkAssignmentFailed(format!("Failed to write lead zeros: {err}")) + })?; + + writer + .write_all(&encode_write_register( + BROADCAST_ASIC, + engine_address, + ENGINE_REG_TIMESTAMP_COUNT, + &[timestamp_count], + )) + .await + .map_err(|err| { + HashThreadError::WorkAssignmentFailed(format!( + "Failed to write timestamp count: {err}" + )) + })?; + + writer + .write_all(&encode_write_register( + BROADCAST_ASIC, + engine_address, + ENGINE_REG_TARGET, + &bits.to_le_bytes(), + )) + .await + .map_err(|err| { + HashThreadError::WorkAssignmentFailed(format!("Failed to write target bits: {err}")) + })?; + + let seq_start = (base_sequence % 2) * 4; + for (micro_job_id, midstate) in midstates.iter().enumerate() { + let job_control = if micro_job_id == 3 { 3 } else { 0 }; + writer + .write_all(&encode_write_job( + BROADCAST_ASIC, + engine_address, + midstate, + merkle_root_residue, + task.ntime, + seq_start + micro_job_id as u8, + job_control, + )) + .await + .map_err(|err| { + HashThreadError::WorkAssignmentFailed(format!("Failed to write job: {err}")) + })?; + } + + engine_dispatches.insert( + protocol::logical_engine_id(row, col).unwrap(), + EngineDispatch { + task: task.clone(), + merkle_root, + versions, + base_sequence, + }, + ); + } + + Ok(()) +} + +async fn handle_result_frame( + frame: &protocol::TdmResultFrame, + engine_dispatches: &HashMap, + config: &Bzm2ThreadConfig, + status: &Arc>, + event_tx: &mpsc::Sender, +) { + let Some((share, target_diff, engine_id)) = + reconstruct_share_from_result(frame, engine_dispatches, config) + else { + return; + }; + + let share_tx = { + let dispatch = engine_dispatches + .get(&engine_id) + .expect("dispatch must exist for reconstructed share"); + dispatch.task.share_tx.clone() + }; + + if share_tx.send(share.clone()).await.is_ok() { + let snapshot = { + let mut lock = status.write().unwrap(); + lock.chip_shares_found += 1; + lock.clone() + }; + let _ = event_tx.send(HashThreadEvent::StatusUpdate(snapshot)).await; + } + + trace!( + engine_id, + seq = frame.sequence_id, + nonce = format!("{:#010x}", share.nonce), + hash = %share.hash, + hash_diff = %Difficulty::from_hash(&share.hash), + target_diff = %target_diff, + "BZM2 share accepted" + ); +} + +fn reconstruct_share_from_result( + frame: &protocol::TdmResultFrame, + engine_dispatches: &HashMap, + config: &Bzm2ThreadConfig, +) -> Option<(Share, Difficulty, u16)> { + if !frame.nonce_valid() { + return None; + } + + let engine_id = frame.logical_engine_id()?; + let dispatch = engine_dispatches.get(&engine_id)?; + + let hardware_base_sequence = frame.sequence_id / 4; + if (dispatch.base_sequence % 2) != hardware_base_sequence { + return None; + } + + let micro_job_id = (frame.sequence_id % 4) as usize; + let version = dispatch.versions[micro_job_id]; + let ntime_offset = u32::from(config.timestamp_count.saturating_sub(frame.reported_time)); + let ntime = dispatch.task.ntime.wrapping_add(ntime_offset); + let nonce = frame.nonce.wrapping_sub(config.nonce_gap); + + let header = BlockHeader { + version, + prev_blockhash: dispatch.task.template.prev_blockhash, + merkle_root: dispatch.merkle_root, + time: ntime, + bits: dispatch.task.template.bits, + nonce, + }; + let hash = header.block_hash(); + + if !dispatch.task.share_target.is_met_by(hash) { + return None; + } + + Some(( + Share { + nonce, + hash, + version, + ntime, + extranonce2: dispatch.task.en2, + expected_work: dispatch.task.share_target.to_work(), + }, + Difficulty::from_target(dispatch.task.share_target), + engine_id, + )) +} + +fn compute_micro_versions(task: &HashTask) -> [bitcoin::block::Version; 4] { + let candidates = [0u16, 2, 4, 8]; + let mut versions = [task.template.version.base(); 4]; + + for (slot, candidate) in candidates.into_iter().enumerate() { + let gp_bits = GeneralPurposeBits::new(candidate.to_be_bytes()); + versions[slot] = task + .template + .version + .apply_gp_bits(&gp_bits) + .unwrap_or_else(|_| task.template.version.base()); + } + + versions +} + +fn compute_midstate( + task: &HashTask, + merkle_root: bitcoin::TxMerkleNode, + version: bitcoin::block::Version, +) -> [u8; 32] { + let header_bytes = serialize(&BlockHeader { + version, + prev_blockhash: task.template.prev_blockhash, + merkle_root, + time: task.ntime, + bits: task.template.bits, + nonce: 0, + }); + + let mut engine = sha256::HashEngine::default(); + engine.input(&header_bytes[..64]); + engine.midstate().to_byte_array() +} + +fn snapshot_status(status: &Arc>) -> HashThreadStatus { + status.read().unwrap().clone() +} + +fn set_active(status: &Arc>, is_active: bool) { + let mut lock = status.write().unwrap(); + lock.is_active = is_active; +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::job_source::{GeneralPurposeBits, JobTemplate, VersionTemplate}; + use crate::transport::{SerialConfig, SerialStream}; + use bitcoin::hashes::Hash; + use bitcoin::pow::Target; + use nix::pty::openpty; + use std::collections::HashMap as StdHashMap; + use std::os::unix::io::IntoRawFd; + use tokio::sync::mpsc as tokio_mpsc; + + fn test_task() -> HashTask { + let share_target = Target::from_be_bytes([ + 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + ]); + let template = Arc::new(JobTemplate { + id: "bzm2-test".into(), + prev_blockhash: bitcoin::BlockHash::all_zeros(), + version: VersionTemplate::new( + bitcoin::block::Version::from_consensus(0x2000_0000), + GeneralPurposeBits::full(), + ) + .unwrap(), + bits: bitcoin::pow::CompactTarget::from_consensus(0x1d00ffff), + share_target, + time: 1_700_000_000, + merkle_root: MerkleRootKind::Fixed(bitcoin::TxMerkleNode::all_zeros()), + }); + let (share_tx, _share_rx) = tokio_mpsc::channel(4); + HashTask { + template, + en2_range: None, + en2: None, + share_target, + ntime: 1_700_000_000, + share_tx, + } + } + + #[test] + fn midstate_changes_with_micro_job_versions() { + let task = test_task(); + let merkle_root = bitcoin::TxMerkleNode::all_zeros(); + let versions = compute_micro_versions(&task); + let a = compute_midstate(&task, merkle_root, versions[0]); + let b = compute_midstate(&task, merkle_root, versions[1]); + assert_ne!(a, b); + } + + #[tokio::test] + async fn dispatch_writes_expected_packet_fanout() { + let pty = openpty(None, None).unwrap(); + let writer_side = SerialStream::from_fd(pty.master.into_raw_fd(), SerialConfig::default()).unwrap(); + let reader_side = SerialStream::from_fd(pty.slave.into_raw_fd(), SerialConfig::default()).unwrap(); + let (_reader_a, mut writer, _control_a) = writer_side.split(); + let (mut reader, _writer_b, _control_b) = reader_side.split(); + + let task = test_task(); + let mut engine_dispatches = StdHashMap::new(); + let config = Bzm2ThreadConfig::new("/dev/null".into(), 5_000_000); + let engine_coords = vec![(0, 0), (0, 1)]; + + dispatch_task_to_board( + &mut writer, + &task, + 1, + &engine_coords, + &mut engine_dispatches, + &config, + ) + .await + .unwrap(); + + let mut buf = vec![0u8; 512]; + let n = tokio::time::timeout(Duration::from_millis(250), reader.read(&mut buf)) + .await + .unwrap() + .unwrap(); + let bytes = &buf[..n]; + + let expected_bytes_per_engine = 8 + 8 + 11 + (48 * 4); + assert_eq!(bytes.len(), expected_bytes_per_engine * engine_coords.len()); + assert_eq!(engine_dispatches.len(), engine_coords.len()); + + let first_packet_len = u16::from_le_bytes([bytes[0], bytes[1]]) as usize; + assert_eq!(first_packet_len, 8); + + let last_packet_start = bytes.len() - 48; + assert_eq!( + u16::from_le_bytes([bytes[last_packet_start], bytes[last_packet_start + 1]]) as usize, + 48 + ); + assert_eq!(bytes[last_packet_start + 46], 7); + assert_eq!(bytes[last_packet_start + 47], 3); + } + + #[test] + fn reconstructs_share_from_matching_result_frame() { + let mut task = test_task(); + + let merkle_root = bitcoin::TxMerkleNode::all_zeros(); + let versions = compute_micro_versions(&task); + let (row, col) = default_engine_coordinates()[0]; + let engine_id = protocol::logical_engine_id(row, col).unwrap(); + let nonce = 0; + let expected_hash = bitcoin::block::Header { + version: versions[0], + prev_blockhash: task.template.prev_blockhash, + merkle_root, + time: task.ntime, + bits: task.template.bits, + nonce, + } + .block_hash(); + task.share_target = Difficulty::from_hash(&expected_hash).to_target(); + let frame = protocol::TdmResultFrame { + asic: 0, + engine_address: protocol::logical_engine_address(row, col), + status: 0x8, + nonce: nonce + DEFAULT_NONCE_GAP, + sequence_id: 0, + reported_time: DEFAULT_TIMESTAMP_COUNT, + }; + + let mut engine_dispatches = StdHashMap::new(); + engine_dispatches.insert( + engine_id, + EngineDispatch { + task: task.clone(), + merkle_root, + versions, + base_sequence: 0, + }, + ); + + let config = Bzm2ThreadConfig::new("/dev/null".into(), 5_000_000); + let (share, target_diff, reconstructed_engine_id) = + reconstruct_share_from_result(&frame, &engine_dispatches, &config).unwrap(); + + assert_eq!(reconstructed_engine_id, engine_id); + assert_eq!(share.nonce, nonce); + assert_eq!(share.ntime, task.ntime); + assert_eq!(share.version, versions[0]); + assert_eq!( + share.hash, + bitcoin::block::Header { + version: versions[0], + prev_blockhash: task.template.prev_blockhash, + merkle_root, + time: task.ntime, + bits: task.template.bits, + nonce, + } + .block_hash() + ); + assert_eq!(target_diff, Difficulty::from_target(task.share_target)); + } +} + + + + + + + + + + + + diff --git a/mujina-miner/src/asic/mod.rs b/mujina-miner/src/asic/mod.rs index 6d8a347a..02322036 100644 --- a/mujina-miner/src/asic/mod.rs +++ b/mujina-miner/src/asic/mod.rs @@ -1,3 +1,4 @@ +pub mod bzm2; pub mod bm13xx; pub mod hash_thread; diff --git a/mujina-miner/src/backplane.rs b/mujina-miner/src/backplane.rs index 466292db..f6e9c566 100644 --- a/mujina-miner/src/backplane.rs +++ b/mujina-miner/src/backplane.rs @@ -16,6 +16,7 @@ use crate::{ transport::{ TransportEvent, UsbDeviceInfo, cpu::TransportEvent as CpuTransportEvent, usb::TransportEvent as UsbTransportEvent, + virtual_device::TransportEvent as VirtualTransportEvent, }, }; @@ -81,6 +82,9 @@ impl Backplane { TransportEvent::Cpu(cpu_event) => { self.handle_cpu_event(cpu_event).await?; } + TransportEvent::Virtual(virtual_event) => { + self.handle_virtual_event(virtual_event).await?; + } } } @@ -300,4 +304,100 @@ impl Backplane { Ok(()) } + + /// Handle generic virtual device transport events. + async fn handle_virtual_event(&mut self, event: VirtualTransportEvent) -> Result<()> { + match event { + VirtualTransportEvent::VirtualDeviceConnected(device_info) => { + let Some(descriptor) = self.virtual_registry.find(&device_info.device_type) else { + error!( + device_type = %device_info.device_type, + "No virtual board descriptor found" + ); + return Ok(()); + }; + + info!( + board = descriptor.name, + device_type = %device_info.device_type, + device_id = %device_info.device_id, + "Virtual board connected." + ); + + let (mut board, registration) = match (descriptor.create_fn)().await { + Ok(result) => result, + Err(e) => { + error!( + board = descriptor.name, + error = %e, + "Failed to create virtual board" + ); + return Ok(()); + } + }; + + let board_info = board.board_info(); + let board_id = device_info.device_id.clone(); + + if let Err(e) = self.board_reg_tx.send(registration).await { + error!( + board = %board_info.model, + error = %e, + "Failed to register board with API server" + ); + } + + match board.create_hash_threads().await { + Ok(threads) => { + let thread_count = threads.len(); + self.boards.insert(board_id.clone(), board); + + for thread in threads { + if let Err(e) = self.scheduler_tx.send(thread).await { + error!( + board = %board_info.model, + error = %e, + "Failed to send thread to scheduler" + ); + break; + } + } + + info!( + board = %board_info.model, + device_id = %board_id, + threads = thread_count, + "Virtual board started." + ); + } + Err(e) => { + error!( + board = %board_info.model, + device_id = %board_id, + error = %e, + "Virtual board failed to start." + ); + } + } + } + VirtualTransportEvent::VirtualDeviceDisconnected { device_id } => { + if let Some(mut board) = self.boards.remove(&device_id) { + let model = board.board_info().model; + debug!(board = %model, id = %device_id, "Shutting down virtual board"); + + match board.shutdown().await { + Ok(()) => info!(board = %model, id = %device_id, "Virtual board disconnected"), + Err(e) => error!( + board = %model, + id = %device_id, + error = %e, + "Failed to shutdown virtual board" + ), + } + } + } + } + + Ok(()) + } } diff --git a/mujina-miner/src/board/bzm2.rs b/mujina-miner/src/board/bzm2.rs new file mode 100644 index 00000000..7293baa5 --- /dev/null +++ b/mujina-miner/src/board/bzm2.rs @@ -0,0 +1,213 @@ +use std::env; +use std::path::Path; +use std::time::Duration; + +use async_trait::async_trait; +use tokio::sync::watch; + +use super::{Board, BoardError, BoardInfo, VirtualBoardDescriptor}; +use crate::{ + api_client::types::BoardState, + asic::{ + bzm2::{Bzm2Thread, Bzm2ThreadConfig, Bzm2ThreadHandle}, + hash_thread::HashThread, + }, + transport::SerialStream, +}; + +const DEFAULT_BAUD_RATE: u32 = 5_000_000; +const DEFAULT_DISPATCH_INTERVAL_MS: u64 = 500; +const DEFAULT_NOMINAL_HASHRATE_THS: f64 = 40.0; + +#[derive(Debug, Clone)] +pub struct Bzm2VirtualDeviceConfig { + pub serial_paths: Vec, + pub baud_rate: u32, + pub timestamp_count: u8, + pub nonce_gap: u32, + pub dispatch_interval: Duration, + pub nominal_hashrate_ths: f64, +} + +impl Bzm2VirtualDeviceConfig { + pub fn from_env() -> Option { + let raw_paths = env::var("MUJINA_BZM2_SERIAL") + .ok() + .or_else(|| env::var("MUJINA_BZM2_SERIAL_PATHS").ok())?; + + let serial_paths: Vec = raw_paths + .split(',') + .map(str::trim) + .filter(|path| !path.is_empty()) + .map(ToOwned::to_owned) + .collect(); + if serial_paths.is_empty() { + return None; + } + + let baud_rate = env::var("MUJINA_BZM2_BAUD") + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(DEFAULT_BAUD_RATE); + let timestamp_count = env::var("MUJINA_BZM2_TIMESTAMP_COUNT") + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(crate::asic::bzm2::protocol::DEFAULT_TIMESTAMP_COUNT); + let nonce_gap = env::var("MUJINA_BZM2_NONCE_GAP") + .ok() + .and_then(|value| { + let trimmed = value.trim(); + if let Some(hex) = trimmed.strip_prefix("0x") { + u32::from_str_radix(hex, 16).ok() + } else { + trimmed.parse().ok() + } + }) + .unwrap_or(crate::asic::bzm2::protocol::DEFAULT_NONCE_GAP); + let dispatch_interval = Duration::from_millis( + env::var("MUJINA_BZM2_DISPATCH_MS") + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(DEFAULT_DISPATCH_INTERVAL_MS), + ); + let nominal_hashrate_ths = env::var("MUJINA_BZM2_HASHRATE_THS") + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(DEFAULT_NOMINAL_HASHRATE_THS); + + Some(Self { + serial_paths, + baud_rate, + timestamp_count, + nonce_gap, + dispatch_interval, + nominal_hashrate_ths, + }) + } + + pub fn device_id(&self) -> String { + let suffix = self + .serial_paths + .iter() + .map(|path| { + Path::new(path) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(path) + .chars() + .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '_' }) + .collect::() + }) + .collect::>() + .join("-"); + format!("bzm2-{}", suffix) + } +} + +pub struct Bzm2Board { + config: Bzm2VirtualDeviceConfig, + shutdown_handles: Vec, + state_tx: watch::Sender, +} + +impl Bzm2Board { + pub fn new(config: Bzm2VirtualDeviceConfig, state_tx: watch::Sender) -> Self { + Self { + config, + shutdown_handles: Vec::new(), + state_tx, + } + } +} + +#[async_trait] +impl Board for Bzm2Board { + fn board_info(&self) -> BoardInfo { + BoardInfo { + model: "BZM2".into(), + firmware_version: None, + serial_number: Some(self.config.device_id()), + } + } + + async fn shutdown(&mut self) -> Result<(), BoardError> { + for handle in &self.shutdown_handles { + handle.shutdown(); + } + self.shutdown_handles.clear(); + let _ = self.state_tx.send_modify(|state| { + for thread in &mut state.threads { + thread.is_active = false; + thread.hashrate = 0; + } + }); + Ok(()) + } + + async fn create_hash_threads(&mut self) -> Result>, BoardError> { + let mut threads: Vec> = Vec::new(); + let mut thread_states = Vec::new(); + + for (index, serial_path) in self.config.serial_paths.iter().enumerate() { + let stream = SerialStream::new(serial_path, self.config.baud_rate).map_err(|err| { + BoardError::InitializationFailed(format!( + "Failed to open BZM2 serial transport {}: {}", + serial_path, err + )) + })?; + let (reader, writer, control) = stream.split(); + let thread_name = format!("BZM2 UART {}", index); + let mut config = Bzm2ThreadConfig::new(serial_path.clone(), self.config.baud_rate); + config.timestamp_count = self.config.timestamp_count; + config.nonce_gap = self.config.nonce_gap; + config.dispatch_interval = self.config.dispatch_interval; + config.nominal_hashrate_ths = self.config.nominal_hashrate_ths; + + let thread = Bzm2Thread::new(thread_name.clone(), reader, writer, control, config); + self.shutdown_handles.push(thread.shutdown_handle()); + thread_states.push(crate::api_client::types::ThreadState { + name: thread_name, + hashrate: 0, + is_active: false, + }); + threads.push(Box::new(thread)); + } + + let _ = self.state_tx.send_modify(|state| { + state.threads = thread_states.clone(); + }); + + Ok(threads) + } +} + +async fn create_bzm2_board() +-> crate::error::Result<(Box, super::BoardRegistration)> { + let config = Bzm2VirtualDeviceConfig::from_env().ok_or_else(|| { + crate::error::Error::Config("BZM2 not configured (MUJINA_BZM2_SERIAL not set)".into()) + })?; + + let serial = config.device_id(); + let initial_state = BoardState { + name: serial.clone(), + model: "BZM2".into(), + serial: Some(serial), + ..Default::default() + }; + let (state_tx, state_rx) = watch::channel(initial_state); + + let board = Bzm2Board::new(config, state_tx); + let registration = super::BoardRegistration { state_rx }; + Ok((Box::new(board), registration)) +} + +inventory::submit! { + VirtualBoardDescriptor { + device_type: "bzm2", + name: "BZM2", + create_fn: || Box::pin(create_bzm2_board()), + } +} + + + diff --git a/mujina-miner/src/board/mod.rs b/mujina-miner/src/board/mod.rs index b84e27ad..9b4d5140 100644 --- a/mujina-miner/src/board/mod.rs +++ b/mujina-miner/src/board/mod.rs @@ -1,3 +1,4 @@ +pub(crate) mod bzm2; pub(crate) mod bitaxe; pub mod cpu; pub(crate) mod emberone00; diff --git a/mujina-miner/src/daemon.rs b/mujina-miner/src/daemon.rs index 40ba4c1f..60c1237a 100644 --- a/mujina-miner/src/daemon.rs +++ b/mujina-miner/src/daemon.rs @@ -15,6 +15,7 @@ use crate::{ api::{self, ApiConfig, commands::SchedulerCommand}, asic::hash_thread::HashThread, backplane::Backplane, + board::bzm2::Bzm2VirtualDeviceConfig, cpu_miner::CpuMinerConfig, job_source::{ SourceCommand, SourceEvent, @@ -24,7 +25,10 @@ use crate::{ }, scheduler::{self, SourceRegistration}, stratum_v1::{PoolConfig as StratumPoolConfig, TcpConnector}, - transport::{CpuDeviceInfo, TransportEvent, UsbTransport, cpu as cpu_transport}, + transport::{ + CpuDeviceInfo, TransportEvent, UsbTransport, VirtualDeviceInfo, cpu as cpu_transport, + virtual_device, + }, }; /// The main daemon. @@ -78,6 +82,22 @@ impl Daemon { } } + if let Some(config) = Bzm2VirtualDeviceConfig::from_env() { + info!( + serials = config.serial_paths.len(), + baud = config.baud_rate, + "BZM2 virtual board enabled" + ); + let event = TransportEvent::Virtual(virtual_device::TransportEvent::VirtualDeviceConnected( + VirtualDeviceInfo { + device_type: "bzm2".into(), + device_id: config.device_id(), + }, + )); + if let Err(e) = transport_tx.send(event).await { + error!("Failed to send BZM2 virtual board event: {}", e); + } + } // Board registration channel: backplane forwards board // registrations here, the API server collects and serves them. let (board_reg_tx, board_reg_rx) = mpsc::channel(10); diff --git a/mujina-miner/src/transport/mod.rs b/mujina-miner/src/transport/mod.rs index c54019c2..d49cc517 100644 --- a/mujina-miner/src/transport/mod.rs +++ b/mujina-miner/src/transport/mod.rs @@ -10,6 +10,7 @@ use anyhow::Result; pub mod cpu; pub mod serial; pub mod usb; +pub mod virtual_device; // Re-export transport implementations pub use cpu::CpuDeviceInfo; @@ -18,6 +19,7 @@ pub use serial::{ SerialWriter, }; pub use usb::{UsbDeviceInfo, UsbTransport}; +pub use virtual_device::VirtualDeviceInfo; /// Generic transport event that can represent different transport types. #[derive(Debug)] @@ -27,6 +29,9 @@ pub enum TransportEvent { /// CPU miner virtual device event Cpu(cpu::TransportEvent), + + /// Generic virtual device event + Virtual(virtual_device::TransportEvent), } /// Common trait for transport discovery (future enhancement). diff --git a/mujina-miner/src/transport/virtual_device.rs b/mujina-miner/src/transport/virtual_device.rs new file mode 100644 index 00000000..0f2109b7 --- /dev/null +++ b/mujina-miner/src/transport/virtual_device.rs @@ -0,0 +1,23 @@ +//! Generic virtual device transport. +//! +//! Virtual boards are injected by configuration rather than hardware discovery. + +/// Transport events for generic virtual devices. +#[derive(Debug)] +pub enum TransportEvent { + /// A virtual device was connected. + VirtualDeviceConnected(VirtualDeviceInfo), + + /// A virtual device was disconnected. + VirtualDeviceDisconnected { device_id: String }, +} + +/// Information about a generic virtual device. +#[derive(Debug, Clone)] +pub struct VirtualDeviceInfo { + /// Virtual board type identifier. + pub device_type: String, + + /// Unique identifier for this virtual instance. + pub device_id: String, +} From deacddb794c17c0c7e8326e3726e53e19b633411 Mon Sep 17 00:00:00 2001 From: Reckless Apotheosis <125509978+recklessnode@users.noreply.github.com> Date: Fri, 6 Mar 2026 15:52:35 -0800 Subject: [PATCH 02/24] feat(bzm2): add telemetry, control, and protocol coverage Extend the initial BZM2 integration with: - board telemetry and safety-state handling - reusable control-plane abstractions for reset and power sequencing - expanded UART opcode coverage and parser behavior - end-to-end actor tests for dispatch and share reconstruction This keeps the transport and board core from the first commit, then layers in the reusable hardware-control and validation surface. --- mujina-miner/src/asic/bzm2/control.rs | 313 +++++++++++++ mujina-miner/src/asic/bzm2/mod.rs | 1 + mujina-miner/src/asic/bzm2/protocol.rs | 401 +++++++++++++++-- mujina-miner/src/asic/bzm2/thread.rs | 294 ++++++++++-- mujina-miner/src/board/bzm2.rs | 591 ++++++++++++++++++++++++- mujina-miner/src/transport/serial.rs | 3 + 6 files changed, 1535 insertions(+), 68 deletions(-) create mode 100644 mujina-miner/src/asic/bzm2/control.rs diff --git a/mujina-miner/src/asic/bzm2/control.rs b/mujina-miner/src/asic/bzm2/control.rs new file mode 100644 index 00000000..a5dd4f01 --- /dev/null +++ b/mujina-miner/src/asic/bzm2/control.rs @@ -0,0 +1,313 @@ +use std::time::Duration; + +use anyhow::Result; +use async_trait::async_trait; +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 Bzm2PowerRail: 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 Bzm2PowerRail 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<()> { + Bzm2PowerRail::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(()) + } +} + +#[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 Bzm2BringupPlan { + pub assert_reset_before_power: bool, + pub pre_power_delay: Duration, + pub post_power_delay: Duration, + pub release_reset_delay: Duration, + pub steps: Vec, +} + +impl Default for Bzm2BringupPlan { + 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 Bzm2BringupPlan { + pub async fn apply( + &self, + rails: &mut [R], + mut reset_line: Option<&mut GpioResetLine>, + ) -> Result<()> + where + R: Bzm2PowerRail, + 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.as_deref_mut() { + reset_line.enable().await?; + sleep(self.release_reset_delay).await; + } + + Ok(()) + } + + pub async fn shutdown( + &self, + rails: &mut [R], + mut reset_line: Option<&mut GpioResetLine>, + ) -> Result<()> + where + R: Bzm2PowerRail, + PIN: GpioPin, + { + if let Some(reset_line) = reset_line.as_deref_mut() { + 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 Bzm2PowerRail 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 = Bzm2BringupPlan { + 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 = Bzm2BringupPlan::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/asic/bzm2/mod.rs b/mujina-miner/src/asic/bzm2/mod.rs index be7a9e60..642b7499 100644 --- a/mujina-miner/src/asic/bzm2/mod.rs +++ b/mujina-miner/src/asic/bzm2/mod.rs @@ -1,3 +1,4 @@ +pub mod control; pub mod protocol; pub mod thread; diff --git a/mujina-miner/src/asic/bzm2/protocol.rs b/mujina-miner/src/asic/bzm2/protocol.rs index 99109f93..5520cb08 100644 --- a/mujina-miner/src/asic/bzm2/protocol.rs +++ b/mujina-miner/src/asic/bzm2/protocol.rs @@ -1,10 +1,16 @@ -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; +pub const OPCODE_UART_WRITEJOB: u8 = 0x0; pub const OPCODE_UART_READRESULT: u8 = 0x1; pub const OPCODE_UART_WRITEREG: u8 = 0x2; -pub const OPCODE_UART_WRITEJOB: u8 = 0x0; +pub const OPCODE_UART_READREG: u8 = 0x3; +pub const OPCODE_UART_MULTICAST_WRITE: u8 = 0x4; +pub const OPCODE_UART_DTS_VS: u8 = 0x0d; +pub const OPCODE_UART_LOOPBACK: u8 = 0x0e; +pub const OPCODE_UART_NOOP: u8 = 0x0f; pub const BROADCAST_ASIC: u8 = 0xff; +pub const TARGET_BYTE: u8 = 0x08; pub const ENGINE_REG_TARGET: u8 = 0x44; pub const ENGINE_REG_TIMESTAMP_COUNT: u8 = 0x48; @@ -15,7 +21,23 @@ pub const DEFAULT_NONCE_GAP: u32 = 0x28; pub const LOGICAL_ENGINE_ROWS: u8 = 20; pub const LOGICAL_ENGINE_COLS: u8 = 12; -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DtsVsGeneration { + Gen1, + Gen2, +} + +impl DtsVsGeneration { + pub fn from_env_value(raw: &str) -> Option { + match raw.trim() { + "1" | "gen1" | "GEN1" => Some(Self::Gen1), + "2" | "gen2" | "GEN2" => Some(Self::Gen2), + _ => None, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct TdmResultFrame { pub asic: u8, pub engine_address: u16, @@ -43,44 +65,173 @@ impl TdmResultFrame { } } -#[derive(Default)] -pub struct TdmResultParser { +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TdmRegisterFrame { + pub asic: u8, + pub data: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TdmNoopFrame { + pub asic: u8, + pub data: [u8; 3], +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TdmDtsVsGen1Frame { + pub asic: u8, + pub voltage: u16, + pub voltage_enabled: bool, + pub thermal_tune_code: u8, + pub thermal_validity: bool, + pub thermal_enabled: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TdmDtsVsGen2Frame { + pub asic: u8, + pub ch0_voltage: u16, + pub ch1_voltage: u16, + pub ch2_voltage: u16, + pub voltage_shutdown_status: bool, + pub voltage_enabled: bool, + pub thermal_tune_code: u16, + pub thermal_trip_status: bool, + pub thermal_fault: bool, + pub thermal_validity: bool, + pub thermal_enabled: bool, + pub voltage_fault: bool, + pub dll0_lock: bool, + pub dll1_lock: bool, + pub pll_lock: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TdmDtsVsFrame { + Gen1(TdmDtsVsGen1Frame), + Gen2(TdmDtsVsGen2Frame), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TdmFrame { + Result(TdmResultFrame), + Register(TdmRegisterFrame), + DtsVs(TdmDtsVsFrame), + Noop(TdmNoopFrame), +} + +pub struct TdmFrameParser { + dts_vs_generation: DtsVsGeneration, buffer: Vec, + expected_read_lengths: HashMap, } -impl TdmResultParser { - pub fn push(&mut self, bytes: &[u8]) -> Vec { +impl Default for TdmFrameParser { + fn default() -> Self { + Self::new(DtsVsGeneration::Gen2) + } +} + +impl TdmFrameParser { + pub fn new(dts_vs_generation: DtsVsGeneration) -> Self { + Self { + dts_vs_generation, + buffer: Vec::new(), + expected_read_lengths: HashMap::new(), + } + } + + pub fn expect_read_register_bytes(&mut self, asic: u8, count: usize) { + self.expected_read_lengths.insert(asic, count); + } + + pub fn push(&mut self, bytes: &[u8]) -> Vec { self.buffer.extend_from_slice(bytes); let mut frames = Vec::new(); let mut cursor = 0usize; - while self.buffer.len().saturating_sub(cursor) >= 10 { + while self.buffer.len().saturating_sub(cursor) >= 2 { let asic = self.buffer[cursor]; let opcode = self.buffer[cursor + 1]; - if asic >= 100 || opcode != OPCODE_UART_READRESULT { + if asic >= 100 { cursor += 1; continue; } - let payload = &self.buffer[cursor + 2..cursor + 10]; - let header = u16::from_be_bytes([payload[0], payload[1]]); - let engine_address = header & 0x0fff; - let status = (header >> 12) as u8; - let nonce = u32::from_le_bytes(payload[2..6].try_into().unwrap()); - let sequence_id = payload[6]; - let reported_time = payload[7]; - - frames.push(TdmResultFrame { - asic, - engine_address, - status, - nonce, - sequence_id, - reported_time, - }); - cursor += 10; + match opcode { + OPCODE_UART_READRESULT => { + if self.buffer.len().saturating_sub(cursor) < 10 { + break; + } + + let payload = &self.buffer[cursor + 2..cursor + 10]; + let header = u16::from_be_bytes([payload[0], payload[1]]); + let engine_address = header & 0x0fff; + let status = (header >> 12) as u8; + let nonce = u32::from_le_bytes(payload[2..6].try_into().unwrap()); + let sequence_id = payload[6]; + let reported_time = payload[7]; + + frames.push(TdmFrame::Result(TdmResultFrame { + asic, + engine_address, + status, + nonce, + sequence_id, + reported_time, + })); + cursor += 10; + } + OPCODE_UART_READREG => { + let Some(&count) = self.expected_read_lengths.get(&asic) else { + break; + }; + if self.buffer.len().saturating_sub(cursor) < 2 + count { + break; + } + + frames.push(TdmFrame::Register(TdmRegisterFrame { + asic, + data: self.buffer[cursor + 2..cursor + 2 + count].to_vec(), + })); + self.expected_read_lengths.remove(&asic); + cursor += 2 + count; + } + OPCODE_UART_DTS_VS => { + let payload_len = match self.dts_vs_generation { + DtsVsGeneration::Gen1 => 4, + DtsVsGeneration::Gen2 => 8, + }; + if self.buffer.len().saturating_sub(cursor) < 2 + payload_len { + break; + } + + let payload = &self.buffer[cursor + 2..cursor + 2 + payload_len]; + let frame = match self.dts_vs_generation { + DtsVsGeneration::Gen1 => { + TdmDtsVsFrame::Gen1(parse_dts_vs_gen1(asic, payload)) + } + DtsVsGeneration::Gen2 => { + TdmDtsVsFrame::Gen2(parse_dts_vs_gen2(asic, payload)) + } + }; + frames.push(TdmFrame::DtsVs(frame)); + cursor += 2 + payload_len; + } + OPCODE_UART_NOOP => { + if self.buffer.len().saturating_sub(cursor) < 5 { + break; + } + let data = self.buffer[cursor + 2..cursor + 5].try_into().unwrap(); + frames.push(TdmFrame::Noop(TdmNoopFrame { asic, data })); + cursor += 5; + } + _ => { + cursor += 1; + } + } } if cursor > 0 { @@ -91,6 +242,24 @@ impl TdmResultParser { } } +#[derive(Default)] +pub struct TdmResultParser { + inner: TdmFrameParser, +} + +impl TdmResultParser { + pub fn push(&mut self, bytes: &[u8]) -> Vec { + self.inner + .push(bytes) + .into_iter() + .filter_map(|frame| match frame { + TdmFrame::Result(result) => Some(result), + _ => None, + }) + .collect() + } +} + pub fn encode_write_register(asic: u8, engine_address: u16, offset: u8, value: &[u8]) -> Vec { let mut bytes = Vec::with_capacity(7 + value.len()); let header = ((asic as u32) << 24) @@ -105,6 +274,34 @@ pub fn encode_write_register(asic: u8, engine_address: u16, offset: u8, value: & bytes } +pub fn encode_multicast_write(asic: u8, group: u16, offset: u8, value: &[u8]) -> Vec { + let mut bytes = Vec::with_capacity(7 + value.len()); + let header = ((asic as u32) << 24) + | ((OPCODE_UART_MULTICAST_WRITE as u32) << 20) + | ((group as u32) << 8) + | offset as u32; + + bytes.extend_from_slice(&((7 + value.len()) as u16).to_le_bytes()); + bytes.extend_from_slice(&header.to_be_bytes()); + bytes.push((value.len() as u8).saturating_sub(1)); + bytes.extend_from_slice(value); + bytes +} + +pub fn encode_read_register(asic: u8, engine_address: u16, offset: u8, count: u8) -> Vec { + let mut bytes = Vec::with_capacity(8); + let header = ((asic as u32) << 24) + | ((OPCODE_UART_READREG as u32) << 20) + | ((engine_address as u32) << 8) + | offset as u32; + + bytes.extend_from_slice(&8u16.to_le_bytes()); + bytes.extend_from_slice(&header.to_be_bytes()); + bytes.push(count.saturating_sub(1)); + bytes.push(TARGET_BYTE); + bytes +} + pub fn encode_write_job( asic: u8, engine_address: u16, @@ -130,6 +327,32 @@ pub fn encode_write_job( bytes } +pub fn encode_read_result_command(asic: u8) -> Vec { + let mut bytes = Vec::with_capacity(4); + let header = ((asic as u16) << 8) | ((OPCODE_UART_READRESULT as u16) << 4); + bytes.extend_from_slice(&4u16.to_le_bytes()); + bytes.extend_from_slice(&header.to_be_bytes()); + bytes +} + +pub fn encode_noop(asic: u8) -> Vec { + let mut bytes = Vec::with_capacity(4); + let header = ((asic as u16) << 8) | ((OPCODE_UART_NOOP as u16) << 4); + bytes.extend_from_slice(&4u16.to_le_bytes()); + bytes.extend_from_slice(&header.to_be_bytes()); + bytes +} + +pub fn encode_loopback(asic: u8, data: &[u8]) -> Vec { + let mut bytes = Vec::with_capacity(5 + data.len()); + let header = ((asic as u16) << 8) | ((OPCODE_UART_LOOPBACK as u16) << 4); + bytes.extend_from_slice(&((5 + data.len()) as u16).to_le_bytes()); + bytes.extend_from_slice(&header.to_be_bytes()); + bytes.push((data.len() as u8).saturating_sub(1)); + bytes.extend_from_slice(data); + bytes +} + pub fn logical_engine_address(row: u8, col: u8) -> u16 { ((col as u16) << 6) | row as u16 } @@ -200,6 +423,46 @@ pub fn leading_zero_threshold(target: bitcoin::pow::Target) -> u8 { zeros.clamp(32, 64) } +fn parse_dts_vs_gen1(asic: u8, payload: &[u8]) -> TdmDtsVsGen1Frame { + let raw = u32::from_be_bytes(payload.try_into().unwrap()); + let bytes = raw.to_le_bytes(); + let voltage = (((bytes[1] & 0x07) as u16) << 8) | bytes[0] as u16; + + TdmDtsVsGen1Frame { + asic, + voltage, + voltage_enabled: (bytes[1] & 0x80) != 0, + thermal_tune_code: bytes[2], + thermal_validity: (bytes[3] & 0x40) != 0, + thermal_enabled: (bytes[3] & 0x80) != 0, + } +} + +fn parse_dts_vs_gen2(asic: u8, payload: &[u8]) -> TdmDtsVsGen2Frame { + let raw = u64::from_be_bytes(payload.try_into().unwrap()); + let bytes = raw.to_le_bytes(); + + TdmDtsVsGen2Frame { + asic, + ch0_voltage: (((bytes[2] & 0x3f) as u16) << 8) | bytes[3] as u16, + ch1_voltage: ((bytes[4] as u16) << 6) | ((bytes[5] & 0x3f) as u16), + ch2_voltage: (((bytes[7] & 0x0f) as u16) << 10) + | ((bytes[6] as u16) << 2) + | (((bytes[5] >> 6) & 0x03) as u16), + voltage_shutdown_status: (bytes[2] & 0x40) != 0, + voltage_enabled: (bytes[2] & 0x80) != 0, + thermal_tune_code: (((bytes[0] & 0x0f) as u16) << 8) | bytes[1] as u16, + thermal_trip_status: (bytes[0] & 0x10) != 0, + thermal_fault: (bytes[0] & 0x20) != 0, + thermal_validity: (bytes[0] & 0x40) != 0, + thermal_enabled: (bytes[0] & 0x80) != 0, + voltage_fault: (bytes[7] & 0x10) != 0, + dll0_lock: (bytes[7] & 0x20) != 0, + dll1_lock: (bytes[7] & 0x40) != 0, + pll_lock: (bytes[7] & 0x80) != 0, + } +} + #[cfg(test)] mod tests { use super::*; @@ -229,4 +492,90 @@ mod tests { assert_eq!(parsed[0].sequence_id, 0x05); assert_eq!(parsed[0].reported_time, 0x09); } + + #[test] + fn parser_decodes_gen2_dts_vs() { + let mut parser = TdmFrameParser::new(DtsVsGeneration::Gen2); + let raw = [ + 0x02, + OPCODE_UART_DTS_VS, + 0xD5, + 0xAB, + 0x34, + 0x12, + 0x45, + 0x96, + 0xA9, + 0xF7, + ]; + let parsed = parser.push(&raw); + assert_eq!(parsed.len(), 1); + match &parsed[0] { + TdmFrame::DtsVs(TdmDtsVsFrame::Gen2(frame)) => { + assert_eq!(frame.asic, 0x02); + assert_eq!(frame.thermal_tune_code, 0x07A9); + assert!(frame.thermal_trip_status); + assert!(frame.thermal_fault); + assert!(frame.thermal_validity); + assert!(frame.thermal_enabled); + assert_eq!(frame.ch0_voltage, 0x1645); + assert!(!frame.voltage_shutdown_status); + assert!(frame.voltage_enabled); + assert_eq!(frame.ch1_voltage, 0x04B4); + assert_eq!(frame.ch2_voltage, 0x16AC); + assert!(frame.voltage_fault); + assert!(!frame.dll0_lock); + assert!(frame.dll1_lock); + assert!(frame.pll_lock); + } + other => panic!("unexpected frame: {other:?}"), + } + } + + #[test] + fn parser_decodes_readreg_and_noop() { + let mut parser = TdmFrameParser::new(DtsVsGeneration::Gen2); + parser.expect_read_register_bytes(0x03, 4); + let parsed = parser.push(&[ + 0x03, + OPCODE_UART_READREG, + 0x78, + 0x56, + 0x34, + 0x12, + 0x01, + OPCODE_UART_NOOP, + 0xaa, + 0xbb, + 0xcc, + ]); + assert_eq!(parsed.len(), 2); + match &parsed[0] { + TdmFrame::Register(frame) => assert_eq!(frame.data, vec![0x78, 0x56, 0x34, 0x12]), + other => panic!("unexpected frame: {other:?}"), + } + match &parsed[1] { + TdmFrame::Noop(frame) => assert_eq!(frame.data, [0xaa, 0xbb, 0xcc]), + other => panic!("unexpected frame: {other:?}"), + } + } + + #[test] + fn command_encoders_cover_all_uart_opcodes() { + let writereg = encode_write_register(1, 2, 3, &[0x44]); + let writejob = encode_write_job(1, 2, &[0u8; 32], 4, 5, 6, 7); + let readreg = encode_read_register(1, 2, 3, 4); + let multicast = encode_multicast_write(1, 2, 3, &[0x44]); + let readresult = encode_read_result_command(1); + let noop = encode_noop(1); + let loopback = encode_loopback(1, &[0xaa, 0xbb]); + + assert_eq!(writereg[3] >> 4, OPCODE_UART_WRITEREG); + assert_eq!(writejob[3] >> 4, OPCODE_UART_WRITEJOB); + assert_eq!(readreg[3] >> 4, OPCODE_UART_READREG); + assert_eq!(multicast[3] >> 4, OPCODE_UART_MULTICAST_WRITE); + assert_eq!(readresult[3] >> 4, OPCODE_UART_READRESULT); + assert_eq!(noop[3] >> 4, OPCODE_UART_NOOP); + assert_eq!(loopback[3] >> 4, OPCODE_UART_LOOPBACK); + } } diff --git a/mujina-miner/src/asic/bzm2/thread.rs b/mujina-miner/src/asic/bzm2/thread.rs index fed82527..264d6654 100644 --- a/mujina-miner/src/asic/bzm2/thread.rs +++ b/mujina-miner/src/asic/bzm2/thread.rs @@ -19,10 +19,10 @@ use crate::transport::serial::{SerialControl, SerialReader, SerialWriter}; use crate::types::{Difficulty, HashRate}; use super::protocol::{ - self, BROADCAST_ASIC, DEFAULT_NONCE_GAP, DEFAULT_TIMESTAMP_COUNT, ENGINE_REG_TARGET, - ENGINE_REG_TIMESTAMP_COUNT, ENGINE_REG_ZEROS_TO_FIND, TdmResultParser, - default_engine_coordinates, encode_write_job, encode_write_register, leading_zero_threshold, - logical_engine_address, + self, BROADCAST_ASIC, DEFAULT_NONCE_GAP, DEFAULT_TIMESTAMP_COUNT, DtsVsGeneration, + ENGINE_REG_TARGET, ENGINE_REG_TIMESTAMP_COUNT, ENGINE_REG_ZEROS_TO_FIND, TdmDtsVsFrame, + TdmFrame, TdmFrameParser, default_engine_coordinates, encode_write_job, encode_write_register, + leading_zero_threshold, logical_engine_address, }; #[derive(Debug, Clone)] @@ -33,6 +33,7 @@ pub struct Bzm2ThreadConfig { pub nonce_gap: u32, pub dispatch_interval: Duration, pub nominal_hashrate_ths: f64, + pub dts_vs_generation: DtsVsGeneration, } impl Bzm2ThreadConfig { @@ -44,6 +45,7 @@ impl Bzm2ThreadConfig { nonce_gap: DEFAULT_NONCE_GAP, dispatch_interval: Duration::from_millis(500), nominal_hashrate_ths: 40.0, + dts_vs_generation: DtsVsGeneration::Gen2, } } } @@ -106,8 +108,16 @@ impl Bzm2Thread { let nominal_hashrate_ths = config.nominal_hashrate_ths; tokio::spawn(async move { - bzm2_thread_actor(command_rx, event_tx, status_clone, reader, writer, control, config) - .await; + bzm2_thread_actor( + command_rx, + event_tx, + status_clone, + reader, + writer, + control, + config, + ) + .await; }); Self { @@ -210,7 +220,7 @@ async fn bzm2_thread_actor( .await; let engine_coords = default_engine_coordinates(); - let mut parser = TdmResultParser::default(); + let mut parser = TdmFrameParser::new(config.dts_vs_generation); let mut current_task: Option = None; let mut engine_dispatches: HashMap = HashMap::new(); let mut base_sequence: u8 = 0; @@ -241,7 +251,7 @@ async fn bzm2_thread_actor( continue; } base_sequence = base_sequence.wrapping_add(1); - set_active(&status, true); + set_active(&status, true, config.nominal_hashrate_ths); let _ = event_tx.send(HashThreadEvent::StatusUpdate(snapshot_status(&status))).await; } let _ = response_tx.send(Ok(old)); @@ -262,7 +272,7 @@ async fn bzm2_thread_actor( continue; } base_sequence = base_sequence.wrapping_add(1); - set_active(&status, true); + set_active(&status, true, config.nominal_hashrate_ths); let _ = event_tx.send(HashThreadEvent::StatusUpdate(snapshot_status(&status))).await; } let _ = response_tx.send(Ok(old)); @@ -270,7 +280,7 @@ async fn bzm2_thread_actor( ThreadCommand::GoIdle { response_tx } => { engine_dispatches.clear(); let old = current_task.take(); - set_active(&status, false); + set_active(&status, false, config.nominal_hashrate_ths); let _ = event_tx.send(HashThreadEvent::StatusUpdate(snapshot_status(&status))).await; let _ = response_tx.send(Ok(old)); } @@ -281,19 +291,35 @@ async fn bzm2_thread_actor( match read_result { Ok(0) => break, Ok(n) => { + let mut should_shutdown = false; for frame in parser.push(&read_buf[..n]) { - handle_result_frame( - &frame, - &engine_dispatches, - &config, - &status, - &event_tx, - ) - .await; + match frame { + TdmFrame::Result(frame) => { + handle_result_frame( + &frame, + &engine_dispatches, + &config, + &status, + &event_tx, + ) + .await; + } + TdmFrame::DtsVs(frame) => { + should_shutdown = handle_dts_vs_frame(&frame, &config, &status).await; + if should_shutdown { + break; + } + } + TdmFrame::Register(_) | TdmFrame::Noop(_) => {} + } + } + if should_shutdown { + break; } } Err(err) => { error!(path = %config.serial_path, error = %err, "BZM2 serial read failed"); + record_hardware_error(&status); break; } } @@ -313,6 +339,7 @@ async fn bzm2_thread_actor( } Err(err) => { error!(path = %config.serial_path, error = %err, "BZM2 dispatch failed"); + record_hardware_error(&status); } } } @@ -327,6 +354,63 @@ async fn bzm2_thread_actor( } } } + + set_active(&status, false, config.nominal_hashrate_ths); + let _ = event_tx + .send(HashThreadEvent::StatusUpdate(snapshot_status(&status))) + .await; +} + +async fn handle_dts_vs_frame( + frame: &TdmDtsVsFrame, + config: &Bzm2ThreadConfig, + status: &Arc>, +) -> bool { + match frame { + TdmDtsVsFrame::Gen1(frame) => { + trace!( + path = %config.serial_path, + asic = frame.asic, + voltage = frame.voltage, + voltage_enabled = frame.voltage_enabled, + thermal_tune_code = frame.thermal_tune_code, + thermal_validity = frame.thermal_validity, + thermal_enabled = frame.thermal_enabled, + "BZM2 DTS/VS telemetry frame" + ); + false + } + TdmDtsVsFrame::Gen2(frame) => { + trace!( + path = %config.serial_path, + asic = frame.asic, + thermal_trip = frame.thermal_trip_status, + thermal_fault = frame.thermal_fault, + voltage_fault = frame.voltage_fault, + voltage_shutdown = frame.voltage_shutdown_status, + "BZM2 DTS/VS gen2 telemetry frame" + ); + + if frame.thermal_trip_status + || frame.thermal_fault + || frame.voltage_fault + || frame.voltage_shutdown_status + { + warn!( + path = %config.serial_path, + asic = frame.asic, + thermal_trip = frame.thermal_trip_status, + thermal_fault = frame.thermal_fault, + voltage_fault = frame.voltage_fault, + voltage_shutdown = frame.voltage_shutdown_status, + "BZM2 hardware fault reported by DTS/VS frame" + ); + record_hardware_error(status); + return true; + } + false + } + } } async fn dispatch_task_to_board( @@ -572,9 +656,19 @@ fn snapshot_status(status: &Arc>) -> HashThreadStatus { status.read().unwrap().clone() } -fn set_active(status: &Arc>, is_active: bool) { +fn set_active(status: &Arc>, is_active: bool, nominal_hashrate_ths: f64) { let mut lock = status.write().unwrap(); lock.is_active = is_active; + lock.hashrate = if is_active { + HashRate::from_terahashes(nominal_hashrate_ths) + } else { + HashRate::default() + }; +} + +fn record_hardware_error(status: &Arc>) { + let mut lock = status.write().unwrap(); + lock.hardware_errors = lock.hardware_errors.saturating_add(1); } #[cfg(test)] @@ -591,9 +685,9 @@ mod tests { fn test_task() -> HashTask { let share_target = Target::from_be_bytes([ - 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, ]); let template = Arc::new(JobTemplate { id: "bzm2-test".into(), @@ -632,8 +726,10 @@ mod tests { #[tokio::test] async fn dispatch_writes_expected_packet_fanout() { let pty = openpty(None, None).unwrap(); - let writer_side = SerialStream::from_fd(pty.master.into_raw_fd(), SerialConfig::default()).unwrap(); - let reader_side = SerialStream::from_fd(pty.slave.into_raw_fd(), SerialConfig::default()).unwrap(); + let writer_side = + SerialStream::from_fd(pty.master.into_raw_fd(), SerialConfig::default()).unwrap(); + let reader_side = + SerialStream::from_fd(pty.slave.into_raw_fd(), SerialConfig::default()).unwrap(); let (_reader_a, mut writer, _control_a) = writer_side.split(); let (mut reader, _writer_b, _control_b) = reader_side.split(); @@ -676,6 +772,144 @@ mod tests { assert_eq!(bytes[last_packet_start + 47], 3); } + #[tokio::test] + async fn parsed_uart_frame_emits_share_and_status_event() { + let mut task = test_task(); + let merkle_root = bitcoin::TxMerkleNode::all_zeros(); + let versions = compute_micro_versions(&task); + let (row, col) = default_engine_coordinates()[0]; + let engine_id = protocol::logical_engine_id(row, col).unwrap(); + let nonce = 0; + let expected_hash = bitcoin::block::Header { + version: versions[0], + prev_blockhash: task.template.prev_blockhash, + merkle_root, + time: task.ntime, + bits: task.template.bits, + nonce, + } + .block_hash(); + task.share_target = Difficulty::from_hash(&expected_hash).to_target(); + + let mut engine_dispatches = StdHashMap::new(); + engine_dispatches.insert( + engine_id, + EngineDispatch { + task: task.clone(), + merkle_root, + versions, + base_sequence: 0, + }, + ); + + let config = Bzm2ThreadConfig::new("/dev/null".into(), 5_000_000); + let status = Arc::new(RwLock::new(HashThreadStatus { + hashrate: HashRate::from_terahashes(40.0), + is_active: true, + ..Default::default() + })); + let (event_tx, mut event_rx) = tokio_mpsc::channel(4); + let (share_tx, mut share_rx) = tokio_mpsc::channel(4); + engine_dispatches.get_mut(&engine_id).unwrap().task.share_tx = share_tx; + + let engine_address = protocol::logical_engine_address(row, col); + let header = ((0x8u16) << 12) | engine_address; + let mut raw = Vec::with_capacity(10); + raw.push(0); + raw.push(protocol::OPCODE_UART_READRESULT); + raw.extend_from_slice(&header.to_be_bytes()); + raw.extend_from_slice(&(nonce + DEFAULT_NONCE_GAP).to_le_bytes()); + raw.push(0); + raw.push(DEFAULT_TIMESTAMP_COUNT); + + let mut parser = protocol::TdmResultParser::default(); + let frames = parser.push(&raw); + assert_eq!(frames.len(), 1); + assert!(reconstruct_share_from_result(&frames[0], &engine_dispatches, &config).is_some()); + + handle_result_frame(&frames[0], &engine_dispatches, &config, &status, &event_tx).await; + + let share = tokio::time::timeout(Duration::from_millis(250), share_rx.recv()) + .await + .unwrap() + .unwrap(); + assert_eq!(share.nonce, nonce); + assert_eq!(share.ntime, task.ntime); + assert_eq!(share.version, versions[0]); + assert_eq!(share.hash, expected_hash); + + let status_update = tokio::time::timeout(Duration::from_millis(250), event_rx.recv()) + .await + .unwrap() + .unwrap(); + match status_update { + HashThreadEvent::StatusUpdate(snapshot) => { + assert!(snapshot.is_active); + assert_eq!(snapshot.chip_shares_found, 1); + assert_eq!(snapshot.hashrate, HashRate::from_terahashes(40.0)); + } + other => panic!("unexpected event: {:?}", other), + } + } + #[tokio::test] + async fn gen2_dts_vs_fault_shuts_down_live_thread() { + let pty = openpty(None, None).unwrap(); + let thread_side = + SerialStream::from_fd(pty.master.into_raw_fd(), SerialConfig::default()).unwrap(); + let host_side = + SerialStream::from_fd(pty.slave.into_raw_fd(), SerialConfig::default()).unwrap(); + let (reader, writer, control) = thread_side.split(); + let (_host_reader, mut host_writer, _host_control) = host_side.split(); + + let mut config = Bzm2ThreadConfig::new("/dev/null".into(), 5_000_000); + config.dts_vs_generation = protocol::DtsVsGeneration::Gen2; + let mut thread = Bzm2Thread::new("BZM2 test".into(), reader, writer, control, config); + let mut event_rx = thread.take_event_receiver().unwrap(); + + let initial = tokio::time::timeout(Duration::from_millis(250), event_rx.recv()) + .await + .unwrap() + .unwrap(); + assert!(matches!(initial, HashThreadEvent::StatusUpdate(_))); + + host_writer + .write_all(&[ + 0x00, + protocol::OPCODE_UART_DTS_VS, + 0xD5, + 0xAB, + 0x34, + 0x12, + 0x45, + 0x96, + 0xA9, + 0xF7, + ]) + .await + .unwrap(); + + let mut saw_fault_status = false; + let closed = tokio::time::timeout(Duration::from_secs(1), async { + while let Some(event) = event_rx.recv().await { + if let HashThreadEvent::StatusUpdate(status) = event { + if !status.is_active && status.hardware_errors >= 1 { + saw_fault_status = true; + } + } + } + }) + .await; + + assert!( + closed.is_ok(), + "thread should exit after DTS/VS hardware fault" + ); + assert!( + saw_fault_status, + "thread should publish a final faulted status" + ); + } + #[test] fn reconstructs_share_from_matching_result_frame() { let mut task = test_task(); @@ -738,15 +972,3 @@ mod tests { assert_eq!(target_diff, Difficulty::from_target(task.share_target)); } } - - - - - - - - - - - - diff --git a/mujina-miner/src/board/bzm2.rs b/mujina-miner/src/board/bzm2.rs index 7293baa5..821672c9 100644 --- a/mujina-miner/src/board/bzm2.rs +++ b/mujina-miner/src/board/bzm2.rs @@ -1,23 +1,37 @@ use std::env; +use std::fs; use std::path::Path; use std::time::Duration; use async_trait::async_trait; -use tokio::sync::watch; +use tokio::sync::{mpsc, watch}; +use tokio::task::JoinHandle; use super::{Board, BoardError, BoardInfo, VirtualBoardDescriptor}; use crate::{ - api_client::types::BoardState, + api_client::types::{BoardState, Fan, PowerMeasurement, TemperatureSensor, ThreadState}, asic::{ bzm2::{Bzm2Thread, Bzm2ThreadConfig, Bzm2ThreadHandle}, - hash_thread::HashThread, + hash_thread::{ + HashTask, HashThread, HashThreadCapabilities, HashThreadError, HashThreadEvent, + HashThreadStatus, + }, }, - transport::SerialStream, + tracing::prelude::*, + transport::{SerialControl, SerialStream}, }; const DEFAULT_BAUD_RATE: u32 = 5_000_000; const DEFAULT_DISPATCH_INTERVAL_MS: u64 = 500; const DEFAULT_NOMINAL_HASHRATE_THS: f64 = 40.0; +const DEFAULT_TELEMETRY_INTERVAL_SECS: u64 = 5; +const DEFAULT_ASIC_TEMP_SCALE: f32 = 0.001; +const DEFAULT_BOARD_TEMP_SCALE: f32 = 0.001; +const DEFAULT_FAN_RPM_SCALE: f32 = 1.0; +const DEFAULT_FAN_PERCENT_SCALE: f32 = 1.0; +const DEFAULT_VOLTAGE_SCALE: f32 = 0.001; +const DEFAULT_CURRENT_SCALE: f32 = 0.001; +const DEFAULT_POWER_SCALE: f32 = 0.000001; #[derive(Debug, Clone)] pub struct Bzm2VirtualDeviceConfig { @@ -27,6 +41,8 @@ pub struct Bzm2VirtualDeviceConfig { pub nonce_gap: u32, pub dispatch_interval: Duration, pub nominal_hashrate_ths: f64, + pub dts_vs_generation: crate::asic::bzm2::protocol::DtsVsGeneration, + pub telemetry: Bzm2TelemetryConfig, } impl Bzm2VirtualDeviceConfig { @@ -74,6 +90,11 @@ impl Bzm2VirtualDeviceConfig { .ok() .and_then(|value| value.parse().ok()) .unwrap_or(DEFAULT_NOMINAL_HASHRATE_THS); + let dts_vs_generation = env::var("MUJINA_BZM2_DTS_VS_GEN") + .ok() + .as_deref() + .and_then(crate::asic::bzm2::protocol::DtsVsGeneration::from_env_value) + .unwrap_or(crate::asic::bzm2::protocol::DtsVsGeneration::Gen2); Some(Self { serial_paths, @@ -82,6 +103,8 @@ impl Bzm2VirtualDeviceConfig { nonce_gap, dispatch_interval, nominal_hashrate_ths, + dts_vs_generation, + telemetry: Bzm2TelemetryConfig::from_env(), }) } @@ -104,10 +127,232 @@ impl Bzm2VirtualDeviceConfig { } } +#[derive(Debug, Clone, Default)] +pub struct Bzm2TelemetryConfig { + pub poll_interval: Duration, + pub asic_temp: Option, + pub board_temp: Option, + pub fan_rpm: Option, + pub fan_percent: Option, + pub input_voltage: Option, + pub input_current: Option, + pub input_power: Option, + pub max_asic_temp_c: Option, + pub max_board_temp_c: Option, + pub max_input_power_w: Option, +} + +impl Bzm2TelemetryConfig { + fn from_env() -> Self { + Self { + poll_interval: Duration::from_secs( + env::var("MUJINA_BZM2_TELEMETRY_INTERVAL_SECS") + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(DEFAULT_TELEMETRY_INTERVAL_SECS), + ), + asic_temp: SensorSpec::from_env( + "MUJINA_BZM2_ASIC_TEMP_PATH", + "MUJINA_BZM2_ASIC_TEMP_SCALE", + DEFAULT_ASIC_TEMP_SCALE, + ), + board_temp: SensorSpec::from_env( + "MUJINA_BZM2_BOARD_TEMP_PATH", + "MUJINA_BZM2_BOARD_TEMP_SCALE", + DEFAULT_BOARD_TEMP_SCALE, + ), + fan_rpm: SensorSpec::from_env( + "MUJINA_BZM2_FAN_RPM_PATH", + "MUJINA_BZM2_FAN_RPM_SCALE", + DEFAULT_FAN_RPM_SCALE, + ), + fan_percent: SensorSpec::from_env( + "MUJINA_BZM2_FAN_PERCENT_PATH", + "MUJINA_BZM2_FAN_PERCENT_SCALE", + DEFAULT_FAN_PERCENT_SCALE, + ), + input_voltage: SensorSpec::from_env( + "MUJINA_BZM2_INPUT_VOLTAGE_PATH", + "MUJINA_BZM2_INPUT_VOLTAGE_SCALE", + DEFAULT_VOLTAGE_SCALE, + ), + input_current: SensorSpec::from_env( + "MUJINA_BZM2_INPUT_CURRENT_PATH", + "MUJINA_BZM2_INPUT_CURRENT_SCALE", + DEFAULT_CURRENT_SCALE, + ), + input_power: SensorSpec::from_env( + "MUJINA_BZM2_INPUT_POWER_PATH", + "MUJINA_BZM2_INPUT_POWER_SCALE", + DEFAULT_POWER_SCALE, + ), + max_asic_temp_c: env_f32("MUJINA_BZM2_MAX_ASIC_TEMP_C"), + max_board_temp_c: env_f32("MUJINA_BZM2_MAX_BOARD_TEMP_C"), + max_input_power_w: env_f32("MUJINA_BZM2_MAX_INPUT_POWER_W"), + } + } + + fn is_enabled(&self) -> bool { + self.asic_temp.is_some() + || self.board_temp.is_some() + || self.fan_rpm.is_some() + || self.fan_percent.is_some() + || self.input_voltage.is_some() + || self.input_current.is_some() + || self.input_power.is_some() + || self.max_asic_temp_c.is_some() + || self.max_board_temp_c.is_some() + || self.max_input_power_w.is_some() + } + + fn snapshot(&self) -> Bzm2TelemetrySnapshot { + let asic_temp = self.asic_temp.as_ref().and_then(SensorSpec::read); + let board_temp = self.board_temp.as_ref().and_then(SensorSpec::read); + let fan_rpm = self + .fan_rpm + .as_ref() + .and_then(SensorSpec::read) + .map(|v| v.round() as u32); + let fan_percent = self + .fan_percent + .as_ref() + .and_then(SensorSpec::read) + .map(|v| v.round().clamp(0.0, 100.0) as u8); + let voltage_v = self.input_voltage.as_ref().and_then(SensorSpec::read); + let current_a = self.input_current.as_ref().and_then(SensorSpec::read); + let power_w = self + .input_power + .as_ref() + .and_then(SensorSpec::read) + .or_else(|| match (voltage_v, current_a) { + (Some(voltage_v), Some(current_a)) => Some(voltage_v * current_a), + _ => None, + }); + + let fans = if fan_rpm.is_some() || fan_percent.is_some() { + vec![Fan { + name: "fan".into(), + rpm: fan_rpm, + percent: fan_percent, + target_percent: None, + }] + } else { + Vec::new() + }; + + let mut temperatures = Vec::new(); + if self.asic_temp.is_some() || asic_temp.is_some() { + temperatures.push(TemperatureSensor { + name: "asic".into(), + temperature_c: asic_temp, + }); + } + if self.board_temp.is_some() || board_temp.is_some() { + temperatures.push(TemperatureSensor { + name: "board".into(), + temperature_c: board_temp, + }); + } + + let powers = if self.input_voltage.is_some() + || self.input_current.is_some() + || self.input_power.is_some() + || power_w.is_some() + { + vec![PowerMeasurement { + name: "input".into(), + voltage_v, + current_a, + power_w, + }] + } else { + Vec::new() + }; + + let trip_reason = self.trip_reason(asic_temp, board_temp, power_w); + + Bzm2TelemetrySnapshot { + fans, + temperatures, + powers, + trip_reason, + } + } + + fn trip_reason( + &self, + asic_temp: Option, + board_temp: Option, + input_power_w: Option, + ) -> Option { + if let (Some(limit), Some(value)) = (self.max_asic_temp_c, asic_temp) { + if value > limit { + return Some(format!( + "ASIC temperature {:.1}C exceeded limit {:.1}C", + value, limit + )); + } + } + + if let (Some(limit), Some(value)) = (self.max_board_temp_c, board_temp) { + if value > limit { + return Some(format!( + "Board temperature {:.1}C exceeded limit {:.1}C", + value, limit + )); + } + } + + if let (Some(limit), Some(value)) = (self.max_input_power_w, input_power_w) { + if value > limit { + return Some(format!( + "Input power {:.1}W exceeded limit {:.1}W", + value, limit + )); + } + } + + None + } +} + +#[derive(Debug, Clone)] +pub struct SensorSpec { + pub path: String, + pub scale: f32, +} + +impl SensorSpec { + fn from_env(path_var: &str, scale_var: &str, default_scale: f32) -> Option { + let path = env::var(path_var).ok()?; + let scale = env::var(scale_var) + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(default_scale); + Some(Self { path, scale }) + } + + fn read(&self) -> Option { + let raw = fs::read_to_string(&self.path).ok()?; + parse_scaled_sensor_value(&raw, self.scale) + } +} + +#[derive(Debug, Clone, Default)] +struct Bzm2TelemetrySnapshot { + fans: Vec, + temperatures: Vec, + powers: Vec, + trip_reason: Option, +} + pub struct Bzm2Board { config: Bzm2VirtualDeviceConfig, shutdown_handles: Vec, + serial_controls: Vec, state_tx: watch::Sender, + monitor_shutdown: Option>, + monitor_task: Option>, } impl Bzm2Board { @@ -115,9 +360,75 @@ impl Bzm2Board { Self { config, shutdown_handles: Vec::new(), + serial_controls: Vec::new(), state_tx, + monitor_shutdown: None, + monitor_task: None, } } + + fn spawn_monitor(&mut self) { + if !self.config.telemetry.is_enabled() || self.monitor_task.is_some() { + return; + } + + let telemetry = self.config.telemetry.clone(); + let state_tx = self.state_tx.clone(); + let shutdown_handles = self.shutdown_handles.clone(); + let serial_controls = self.serial_controls.clone(); + let board_name = self.config.device_id(); + let (shutdown_tx, mut shutdown_rx) = watch::channel(false); + self.monitor_shutdown = Some(shutdown_tx); + + self.monitor_task = Some(tokio::spawn(async move { + let mut interval = tokio::time::interval(telemetry.poll_interval); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + + loop { + tokio::select! { + _ = interval.tick() => { + let snapshot = telemetry.snapshot(); + let total_stats = serial_controls.iter().fold((0u64, 0u64), |acc, control| { + let stats = control.stats(); + (acc.0 + stats.bytes_read, acc.1 + stats.bytes_written) + }); + + let _ = state_tx.send_modify(|state| { + state.fans = snapshot.fans.clone(); + state.temperatures = snapshot.temperatures.clone(); + state.powers = snapshot.powers.clone(); + }); + + trace!( + board = %board_name, + bytes_read = total_stats.0, + bytes_written = total_stats.1, + "BZM2 board telemetry updated" + ); + + if let Some(reason) = snapshot.trip_reason.clone() { + warn!(board = %board_name, reason = %reason, "BZM2 safety trip triggered"); + for handle in &shutdown_handles { + handle.shutdown(); + } + let _ = state_tx.send_modify(|state| { + for thread in &mut state.threads { + thread.is_active = false; + thread.hashrate = 0; + } + }); + break; + } + } + changed = shutdown_rx.changed() => { + if changed.is_err() || *shutdown_rx.borrow() { + break; + } + } + } + } + })); + } } #[async_trait] @@ -131,10 +442,18 @@ impl Board for Bzm2Board { } async fn shutdown(&mut self) -> Result<(), BoardError> { + if let Some(tx) = self.monitor_shutdown.take() { + let _ = tx.send(true); + } + if let Some(handle) = self.monitor_task.take() { + let _ = handle.await; + } + for handle in &self.shutdown_handles { handle.shutdown(); } self.shutdown_handles.clear(); + self.serial_controls.clear(); let _ = self.state_tx.send_modify(|state| { for thread in &mut state.threads { thread.is_active = false; @@ -162,25 +481,142 @@ impl Board for Bzm2Board { config.nonce_gap = self.config.nonce_gap; config.dispatch_interval = self.config.dispatch_interval; config.nominal_hashrate_ths = self.config.nominal_hashrate_ths; + config.dts_vs_generation = self.config.dts_vs_generation; + self.serial_controls.push(control.clone()); let thread = Bzm2Thread::new(thread_name.clone(), reader, writer, control, config); self.shutdown_handles.push(thread.shutdown_handle()); - thread_states.push(crate::api_client::types::ThreadState { + thread_states.push(ThreadState { name: thread_name, hashrate: 0, is_active: false, }); - threads.push(Box::new(thread)); + threads.push(Box::new(Bzm2ManagedThread::new( + Box::new(thread), + self.state_tx.clone(), + index, + ))); } let _ = self.state_tx.send_modify(|state| { state.threads = thread_states.clone(); }); + self.spawn_monitor(); + Ok(threads) } } +struct Bzm2ManagedThread { + inner: Box, + state_tx: watch::Sender, + thread_index: usize, +} + +impl Bzm2ManagedThread { + fn new( + inner: Box, + state_tx: watch::Sender, + thread_index: usize, + ) -> Self { + Self { + inner, + state_tx, + thread_index, + } + } + + fn publish_status(&self, status: &HashThreadStatus) { + publish_thread_status(&self.state_tx, self.thread_index, status); + } +} + +#[async_trait] +impl HashThread for Bzm2ManagedThread { + fn name(&self) -> &str { + self.inner.name() + } + + fn capabilities(&self) -> &HashThreadCapabilities { + self.inner.capabilities() + } + + async fn update_task( + &mut self, + new_task: HashTask, + ) -> Result, HashThreadError> { + let result = self.inner.update_task(new_task).await; + self.publish_status(&self.inner.status()); + result + } + + async fn replace_task( + &mut self, + new_task: HashTask, + ) -> Result, HashThreadError> { + let result = self.inner.replace_task(new_task).await; + self.publish_status(&self.inner.status()); + result + } + + async fn go_idle(&mut self) -> Result, HashThreadError> { + let result = self.inner.go_idle().await; + self.publish_status(&self.inner.status()); + result + } + + fn take_event_receiver(&mut self) -> Option> { + let mut inner_rx = self.inner.take_event_receiver()?; + let (event_tx, event_rx) = mpsc::channel(64); + let state_tx = self.state_tx.clone(); + let thread_index = self.thread_index; + + tokio::spawn(async move { + while let Some(event) = inner_rx.recv().await { + if let HashThreadEvent::StatusUpdate(ref status) = event { + publish_thread_status(&state_tx, thread_index, status); + } + + if event_tx.send(event).await.is_err() { + break; + } + } + }); + + Some(event_rx) + } + + fn status(&self) -> HashThreadStatus { + self.inner.status() + } +} + +fn publish_thread_status( + state_tx: &watch::Sender, + thread_index: usize, + status: &HashThreadStatus, +) { + let _ = state_tx.send_modify(|state| { + if let Some(thread) = state.threads.get_mut(thread_index) { + thread.hashrate = status.hashrate.0; + thread.is_active = status.is_active; + } + }); +} + +fn parse_scaled_sensor_value(raw: &str, scale: f32) -> Option { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; + } + trimmed.parse::().ok().map(|value| value * scale) +} + +fn env_f32(key: &str) -> Option { + env::var(key).ok().and_then(|value| value.parse().ok()) +} + async fn create_bzm2_board() -> crate::error::Result<(Box, super::BoardRegistration)> { let config = Bzm2VirtualDeviceConfig::from_env().ok_or_else(|| { @@ -209,5 +645,148 @@ inventory::submit! { } } +#[cfg(test)] +mod tests { + use super::*; + use nix::pty::openpty; + use std::fs; + use std::os::fd::AsRawFd; + use std::time::{SystemTime, UNIX_EPOCH}; + #[tokio::test] + async fn board_safety_trip_closes_scheduler_event_stream() { + let pty = openpty(None, None).unwrap(); + let serial_path = fs::read_link(format!("/proc/self/fd/{}", pty.slave.as_raw_fd())) + .unwrap() + .to_string_lossy() + .into_owned(); + let sensor_path = std::env::temp_dir().join(format!( + "bzm2-trip-{}-{}.txt", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::write(&sensor_path, "90\n").unwrap(); + + let config = Bzm2VirtualDeviceConfig { + serial_paths: vec![serial_path], + baud_rate: DEFAULT_BAUD_RATE, + timestamp_count: crate::asic::bzm2::protocol::DEFAULT_TIMESTAMP_COUNT, + nonce_gap: crate::asic::bzm2::protocol::DEFAULT_NONCE_GAP, + dispatch_interval: Duration::from_millis(50), + nominal_hashrate_ths: DEFAULT_NOMINAL_HASHRATE_THS, + dts_vs_generation: crate::asic::bzm2::protocol::DtsVsGeneration::Gen2, + telemetry: Bzm2TelemetryConfig { + poll_interval: Duration::from_millis(20), + asic_temp: Some(SensorSpec { + path: sensor_path.to_string_lossy().into_owned(), + scale: 1.0, + }), + max_asic_temp_c: Some(80.0), + ..Default::default() + }, + }; + let (state_tx, mut state_rx) = watch::channel(BoardState { + name: "bzm2-test".into(), + model: "BZM2".into(), + serial: Some("bzm2-test".into()), + ..Default::default() + }); + let mut board = Bzm2Board::new(config, state_tx); + + let mut threads = board.create_hash_threads().await.unwrap(); + let mut event_rx = threads[0].take_event_receiver().unwrap(); + + let closed = tokio::time::timeout(Duration::from_secs(1), async { + while event_rx.recv().await.is_some() {} + }) + .await; + assert!( + closed.is_ok(), + "event stream should close after safety trip" + ); + + let state = tokio::time::timeout(Duration::from_secs(1), async { + loop { + let snapshot = state_rx.borrow().clone(); + if snapshot + .temperatures + .iter() + .any(|sensor| sensor.name == "asic" && sensor.temperature_c == Some(90.0)) + { + break snapshot; + } + state_rx.changed().await.unwrap(); + } + }) + .await + .unwrap(); + assert_eq!(state.threads[0].hashrate, 0); + + board.shutdown().await.unwrap(); + let _ = fs::remove_file(sensor_path); + drop(pty); + } + #[test] + fn parse_scaled_sensor_value_applies_scale() { + let parsed = parse_scaled_sensor_value("42500\n", 0.001).unwrap(); + assert!((parsed - 42.5).abs() < 0.001); + assert_eq!(parse_scaled_sensor_value("", 0.001), None); + assert_eq!(parse_scaled_sensor_value("nope", 1.0), None); + } + + #[test] + fn telemetry_trip_detects_thresholds() { + let telemetry = Bzm2TelemetryConfig { + max_asic_temp_c: Some(80.0), + max_input_power_w: Some(1200.0), + ..Default::default() + }; + + assert!( + telemetry + .trip_reason(Some(81.0), None, None) + .unwrap() + .contains("ASIC temperature") + ); + assert!(telemetry.trip_reason(None, None, Some(1250.0)).is_some()); + assert!( + telemetry + .trip_reason(Some(75.0), None, Some(1100.0)) + .is_none() + ); + } + + #[test] + fn publish_thread_status_updates_state_slot() { + let (state_tx, state_rx) = watch::channel(BoardState { + name: "bzm2-test".into(), + model: "BZM2".into(), + serial: Some("bzm2-test".into()), + threads: vec![ThreadState { + name: "BZM2 UART 0".into(), + hashrate: 0, + is_active: false, + }], + ..Default::default() + }); + + let status = HashThreadStatus { + hashrate: crate::types::HashRate::from_terahashes(42.0), + is_active: true, + ..Default::default() + }; + + publish_thread_status(&state_tx, 0, &status); + + let state = state_rx.borrow().clone(); + assert_eq!( + state.threads[0].hashrate, + crate::types::HashRate::from_terahashes(42.0).0 + ); + assert!(state.threads[0].is_active); + } +} 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, } From b6ff723bd8561ec09f8bcebcbd72b051e6b97eb9 Mon Sep 17 00:00:00 2001 From: Reckless Apotheosis <125509978+recklessnode@users.noreply.github.com> Date: Fri, 6 Mar 2026 15:53:57 -0800 Subject: [PATCH 03/24] feat(bzm2): add UART clock diagnostics Add the reusable UART-side PLL diagnostic path for BZM2. This introduces the first clock-control surface needed for bring-up and debugging: - PLL divider calculation and programming - enable/disable control - lock-state polling - structured clock status reporting The port note is added here so the remaining BZM2 docs can evolve in place with later functionality. --- docs/bzm2-port.md | 105 ++++++++ mujina-miner/src/asic/bzm2/clock.rs | 403 ++++++++++++++++++++++++++++ mujina-miner/src/asic/bzm2/mod.rs | 5 + 3 files changed, 513 insertions(+) create mode 100644 docs/bzm2-port.md create mode 100644 mujina-miner/src/asic/bzm2/clock.rs diff --git a/docs/bzm2-port.md b/docs/bzm2-port.md new file mode 100644 index 00000000..7148bb5a --- /dev/null +++ b/docs/bzm2-port.md @@ -0,0 +1,105 @@ +# BZM2 Mujina Port + +## Architecture + +This port keeps BZM2 support inside Mujina rather than reviving the original split `cgminer` + `bzmd` process model. + +The legacy split looked like this: + +- `cgminer` handled scheduling, pool interaction, and IPC to `bzmd` +- `bzmd` owned UART transport, job fanout, result validation, and board-management glue + +In Mujina, those responsibilities map cleanly onto existing abstractions: + +- `Daemon` injects a virtual `bzm2` board when configured +- `Backplane` instantiates the virtual board through `inventory` +- `board::bzm2::Bzm2Board` opens serial transports and creates hash threads +- `asic::bzm2::Bzm2Thread` performs direct UART job dispatch, telemetry parsing, and share validation +- `asic::bzm2::control` provides reusable GPIO-reset and PMBus/I2C rail sequencing primitives + +A standalone Rust daemon is therefore not required for the mining path. + +## Implemented Behavior + +The BZM2 Mujina thread now reimplements the core legacy data path and the generally reusable portions of the control path: + +- 20 x 12 logical engine grid with the four excluded engines from legacy code +- enhanced-mode 4-midstate dispatch per logical engine +- version-rolling micro-jobs in slots `0, 2, 4, 8` +- UART register writes for target bits, leading-zero threshold, and timestamp count +- TDM result parsing with sequence parity matching +- nonce correction via enhanced-mode nonce gap +- in-thread Bitcoin header reconstruction and share validation before scheduler submission +- UART opcode coverage for: + - `WRITEJOB` + - `WRITEREG` + - `READREG` + - `MULTICAST_WRITE` + - `READRESULT` + - `NOOP` + - `LOOPBACK` + - `DTS_VS` +- DTS/VS generation 1 and generation 2 frame decoding +- live DTS/VS gen2 hardware-fault handling that shuts down the hash thread on thermal or voltage fault indications +- reusable GPIO reset-line control through `AsicEnable` +- reusable TPS546 PMBus rail control through `VoltageRegulator` +- reusable multi-rail bring-up and shutdown sequencing for single-rail, small-stack, and larger multi-stack designs +- UART-register-based PLL diagnostic/control flow for divider programming, enable/disable, lock polling, and readback + +## Configuration + +Enable BZM2 by setting `MUJINA_BZM2_SERIAL` to one or more comma-separated serial device paths. + +Supported environment variables: + +- `MUJINA_BZM2_SERIAL`: required, comma-separated serial device paths +- `MUJINA_BZM2_SERIAL_PATHS`: alternate name for the same setting +- `MUJINA_BZM2_BAUD`: UART baud rate, default `5000000` +- `MUJINA_BZM2_TIMESTAMP_COUNT`: default `60` +- `MUJINA_BZM2_NONCE_GAP`: default `0x28` +- `MUJINA_BZM2_DISPATCH_MS`: redispatch interval in milliseconds, default `500` +- `MUJINA_BZM2_HASHRATE_THS`: nominal per-thread hashrate estimate, default `40` +- `MUJINA_BZM2_DTS_VS_GEN`: DTS/VS payload generation, `1` or `2`, default `2` + +## Design Boundary + +The legacy `bzmd` board-power path mixes three different concerns: + +- genuinely reusable sequencing concepts +- generic peripheral protocols like PMBus/I2C regulators and reset GPIOs +- highly board-specific MCU command sets, sysfs GPIO numbering, CAN PSU control, and platform wiring assumptions + +Only the first two belong in a generally applicable Mujina BZM2 implementation. + +Ported into Mujina: + +- generic reset assertion/deassertion +- generic ordered rail bring-up and shutdown +- generic PMBus/TPS546 voltage control and telemetry adapters +- ASIC-originated DTS/VS telemetry and fault handling + +Intentionally not ported verbatim: + +- Intel board MCU command protocol from `mcu.c` +- hard-coded board GPIO numbering and sysfs reset pulses from `util.c` / `daemon.c` +- platform CAN PSU control from `psu.c` +- board-specific fan and ambient-sensor plumbing that depends on the original platform layout + +Those pieces should only be added behind a concrete Mujina board implementation when the target hardware actually uses them. + +## Current Limits + +Still not implemented from the broader legacy stack: + +- JTAG workflows from the standalone platform documents +- JTAG-only PLL debug sequences that are not represented in the shipped UART code +- calibration and autotuning state machines +- manufacturing and diagnostics RPC surface +- any board-MCU protocol that is specific to one carrier or backplane design + +The top-level `docs` PDFs reference additional JTAG and opcode material, but this port currently implements the opcode surface that is evidenced in the legacy shipping UART path and not an inferred JTAG control plane. + + +See also: + +- docs/bzm2-opcode-grounding.md for the source-grounded opcode matrix and the current JTAG evidence boundary diff --git a/mujina-miner/src/asic/bzm2/clock.rs b/mujina-miner/src/asic/bzm2/clock.rs new file mode 100644 index 00000000..7806a5d7 --- /dev/null +++ b/mujina-miner/src/asic/bzm2/clock.rs @@ -0,0 +1,403 @@ +use std::time::Duration; + +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::time::{Instant, sleep}; + +use crate::transport::{SerialReader, SerialWriter}; + +use super::protocol::{encode_read_register, encode_write_register}; + +const NOTCH_REG: u16 = 0x0fff; +const REF_CLK_MHZ: f32 = 50.0; +const REF_DIVIDER: u8 = 1; +const POST2_DIVIDER: u8 = 0; + +const LOCAL_REG_PLL_POSTDIV: u8 = 0x10; +const LOCAL_REG_PLL_FBDIV: u8 = 0x11; +const LOCAL_REG_PLL_ENABLE: u8 = 0x12; +const LOCAL_REG_PLL_MISC: u8 = 0x13; +const LOCAL_REG_PLL1_POSTDIV: u8 = 0x1a; +const LOCAL_REG_PLL1_FBDIV: u8 = 0x1b; +const LOCAL_REG_PLL1_ENABLE: u8 = 0x1c; +const LOCAL_REG_PLL1_MISC: u8 = 0x1d; + +#[derive(Debug, thiserror::Error)] +pub enum Bzm2ClockError { + #[error("serial write failed: {0}")] + Write(#[from] std::io::Error), + + #[error("invalid PLL index")] + InvalidPll, + + #[error("invalid desired PLL frequency {0} MHz")] + InvalidFrequency(f32), + + #[error("invalid PLL post divider {0}")] + InvalidPostDivider(u8), + + #[error("short read-register response: expected {expected} bytes, got {actual}")] + ShortRead { expected: usize, actual: usize }, + + #[error( + "unexpected read-register response header: expected asic {expected_asic:#x} opcode 0x3, got asic {actual_asic:#x} opcode {actual_opcode:#x}" + )] + UnexpectedResponseHeader { + expected_asic: u8, + actual_asic: u8, + actual_opcode: u8, + }, + + #[error( + "PLL {pll:?} on ASIC {asic} did not lock before timeout; last enable value {last_enable:#x}" + )] + PllLockTimeout { + asic: u8, + pll: Bzm2Pll, + last_enable: u32, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Bzm2Pll { + Pll0, + Pll1, +} + +impl Bzm2Pll { + fn register_block(self) -> (u8, u8, u8) { + match self { + Self::Pll0 => ( + LOCAL_REG_PLL_POSTDIV, + LOCAL_REG_PLL_FBDIV, + LOCAL_REG_PLL_ENABLE, + ), + Self::Pll1 => ( + LOCAL_REG_PLL1_POSTDIV, + LOCAL_REG_PLL1_FBDIV, + LOCAL_REG_PLL1_ENABLE, + ), + } + } + + fn misc_register(self) -> u8 { + match self { + Self::Pll0 => LOCAL_REG_PLL_MISC, + Self::Pll1 => LOCAL_REG_PLL1_MISC, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Bzm2PllConfig { + pub frequency_mhz: f32, + pub post1_divider: u8, + pub ref_divider: u8, + pub post2_divider: u8, + pub feedback_divider: u16, + pub packed_post_divider: u32, +} + +impl Bzm2PllConfig { + pub fn from_target_frequency( + frequency_mhz: f32, + post1_divider: u8, + ) -> Result { + if !frequency_mhz.is_finite() || frequency_mhz <= 0.0 { + return Err(Bzm2ClockError::InvalidFrequency(frequency_mhz)); + } + if post1_divider > 7 { + return Err(Bzm2ClockError::InvalidPostDivider(post1_divider)); + } + + let feedback = REF_DIVIDER as f32 + * (post1_divider as f32 + 1.0) + * (POST2_DIVIDER as f32 + 1.0) + * frequency_mhz + / REF_CLK_MHZ; + let feedback_divider = round_legacy(feedback); + let packed_post_divider = (1u32 << 12) + | ((POST2_DIVIDER as u32) << 9) + | ((post1_divider as u32) << 6) + | REF_DIVIDER as u32; + + Ok(Self { + frequency_mhz, + post1_divider, + ref_divider: REF_DIVIDER, + post2_divider: POST2_DIVIDER, + feedback_divider, + packed_post_divider, + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Bzm2PllStatus { + pub pll: Bzm2Pll, + pub enable_register: u32, + pub misc_register: u32, + pub enabled: bool, + pub locked: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Bzm2ClockDebugReport { + pub asic: u8, + pub pll0: Bzm2PllStatus, + pub pll1: Bzm2PllStatus, +} + +pub struct Bzm2ClockController { + reader: SerialReader, + writer: SerialWriter, +} + +impl Bzm2ClockController { + pub fn new(reader: SerialReader, writer: SerialWriter) -> Self { + Self { reader, writer } + } + + pub async fn write_local_reg_u8( + &mut self, + asic: u8, + offset: u8, + value: u8, + ) -> Result<(), Bzm2ClockError> { + self.writer + .write_all(&encode_write_register(asic, NOTCH_REG, offset, &[value])) + .await?; + self.writer.flush().await?; + Ok(()) + } + + pub async fn write_local_reg_u32( + &mut self, + asic: u8, + offset: u8, + value: u32, + ) -> Result<(), Bzm2ClockError> { + self.writer + .write_all(&encode_write_register( + asic, + NOTCH_REG, + offset, + &value.to_le_bytes(), + )) + .await?; + self.writer.flush().await?; + Ok(()) + } + + pub async fn read_local_reg_u8(&mut self, asic: u8, offset: u8) -> Result { + let data = self.read_local_reg(asic, offset, 1).await?; + Ok(data[0]) + } + + pub async fn read_local_reg_u32( + &mut self, + asic: u8, + offset: u8, + ) -> Result { + let data = self.read_local_reg(asic, offset, 4).await?; + Ok(u32::from_le_bytes(data.try_into().unwrap())) + } + + pub async fn program_pll( + &mut self, + asic: u8, + pll: Bzm2Pll, + config: Bzm2PllConfig, + ) -> Result<(), Bzm2ClockError> { + let (postdiv_reg, fbdiv_reg, _) = pll.register_block(); + self.write_local_reg_u32(asic, fbdiv_reg, config.feedback_divider as u32) + .await?; + self.write_local_reg_u32(asic, postdiv_reg, config.packed_post_divider) + .await?; + sleep(Duration::from_millis(1)).await; + Ok(()) + } + + pub async fn enable_pll(&mut self, asic: u8, pll: Bzm2Pll) -> Result<(), Bzm2ClockError> { + let (_, _, enable_reg) = pll.register_block(); + self.write_local_reg_u32(asic, enable_reg, 1).await + } + + pub async fn disable_pll(&mut self, asic: u8, pll: Bzm2Pll) -> Result<(), Bzm2ClockError> { + let (_, _, enable_reg) = pll.register_block(); + self.write_local_reg_u32(asic, enable_reg, 0).await + } + + pub async fn set_pll_frequency( + &mut self, + asic: u8, + pll: Bzm2Pll, + frequency_mhz: f32, + post1_divider: u8, + ) -> Result { + let config = Bzm2PllConfig::from_target_frequency(frequency_mhz, post1_divider)?; + self.program_pll(asic, pll, config).await?; + Ok(config) + } + + pub async fn wait_for_pll_lock( + &mut self, + asic: u8, + pll: Bzm2Pll, + timeout: Duration, + poll_interval: Duration, + ) -> Result { + let (_, _, enable_reg) = pll.register_block(); + let start = Instant::now(); + let mut last_enable = 0u32; + + loop { + last_enable = self.read_local_reg_u32(asic, enable_reg).await?; + let status = self.read_pll_status(asic, pll).await?; + if status.locked { + return Ok(status); + } + if start.elapsed() >= timeout { + return Err(Bzm2ClockError::PllLockTimeout { + asic, + pll, + last_enable, + }); + } + sleep(poll_interval).await; + } + } + + pub async fn configure_and_lock_pll( + &mut self, + asic: u8, + pll: Bzm2Pll, + frequency_mhz: f32, + post1_divider: u8, + timeout: Duration, + ) -> Result<(Bzm2PllConfig, Bzm2PllStatus), Bzm2ClockError> { + let config = self + .set_pll_frequency(asic, pll, frequency_mhz, post1_divider) + .await?; + self.enable_pll(asic, pll).await?; + let status = self + .wait_for_pll_lock(asic, pll, timeout, Duration::from_millis(100)) + .await?; + Ok((config, status)) + } + + pub async fn read_pll_status( + &mut self, + asic: u8, + pll: Bzm2Pll, + ) -> Result { + let (_, _, enable_reg) = pll.register_block(); + let enable = self.read_local_reg_u32(asic, enable_reg).await?; + let misc = self.read_local_reg_u32(asic, pll.misc_register()).await?; + Ok(Bzm2PllStatus { + pll, + enable_register: enable, + misc_register: misc, + enabled: (enable & 0x1) != 0, + locked: (enable & 0x4) != 0, + }) + } + + pub async fn debug_report(&mut self, asic: u8) -> Result { + Ok(Bzm2ClockDebugReport { + asic, + pll0: self.read_pll_status(asic, Bzm2Pll::Pll0).await?, + pll1: self.read_pll_status(asic, Bzm2Pll::Pll1).await?, + }) + } + + async fn read_local_reg( + &mut self, + asic: u8, + offset: u8, + count: u8, + ) -> Result, Bzm2ClockError> { + let request = encode_read_register(asic, NOTCH_REG, offset, count); + self.writer.write_all(&request).await?; + self.writer.flush().await?; + + let expected = count as usize + 2; + let mut response = vec![0u8; expected]; + let actual = self.reader.read_exact(&mut response).await; + if let Err(err) = actual { + if let std::io::ErrorKind::UnexpectedEof = err.kind() { + return Err(Bzm2ClockError::ShortRead { + expected, + actual: 0, + }); + } + return Err(Bzm2ClockError::Write(err)); + } + + validate_read_response_header(asic, &response)?; + Ok(response[2..].to_vec()) + } +} + +fn validate_read_response_header(expected_asic: u8, response: &[u8]) -> Result<(), Bzm2ClockError> { + if response.len() < 2 { + return Err(Bzm2ClockError::ShortRead { + expected: 2, + actual: response.len(), + }); + } + let actual_asic = response[0]; + let actual_opcode = response[1]; + if actual_asic != expected_asic || actual_opcode != 0x03 { + return Err(Bzm2ClockError::UnexpectedResponseHeader { + expected_asic, + actual_asic, + actual_opcode, + }); + } + Ok(()) +} + +fn round_legacy(value: f32) -> u16 { + let truncated = value as u16; + if value - truncated as f32 > 0.5 { + truncated.saturating_add(1) + } else { + truncated + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pll_divider_rounding_matches_legacy_formula() { + let config = Bzm2PllConfig::from_target_frequency(625.0, 0).unwrap(); + assert_eq!(config.ref_divider, 1); + assert_eq!(config.post1_divider, 0); + assert_eq!(config.post2_divider, 0); + assert_eq!(config.feedback_divider, 12); + assert_eq!(config.packed_post_divider, 0x1001); + } + + #[test] + fn pll_divider_rounding_uses_half_down_legacy_behavior() { + assert_eq!(round_legacy(12.49), 12); + assert_eq!(round_legacy(12.50), 12); + assert_eq!(round_legacy(12.51), 13); + } + + #[test] + fn read_response_header_validation_matches_legacy_contract() { + validate_read_response_header(0x12, &[0x12, 0x03, 0xaa]).unwrap(); + let err = validate_read_response_header(0x12, &[0x13, 0x0f, 0xaa]).unwrap_err(); + assert!(matches!( + err, + Bzm2ClockError::UnexpectedResponseHeader { + expected_asic: 0x12, + actual_asic: 0x13, + actual_opcode: 0x0f, + } + )); + } +} diff --git a/mujina-miner/src/asic/bzm2/mod.rs b/mujina-miner/src/asic/bzm2/mod.rs index 642b7499..5af59a5c 100644 --- a/mujina-miner/src/asic/bzm2/mod.rs +++ b/mujina-miner/src/asic/bzm2/mod.rs @@ -1,5 +1,10 @@ +pub mod clock; pub mod control; pub mod protocol; pub mod thread; +pub use clock::{ + Bzm2ClockController, Bzm2ClockDebugReport, Bzm2ClockError, Bzm2Pll, Bzm2PllConfig, + Bzm2PllStatus, +}; pub use thread::{Bzm2Thread, Bzm2ThreadConfig, Bzm2ThreadHandle}; From 623d2d9f3eab64338cec9607f444a455f16109a1 Mon Sep 17 00:00:00 2001 From: Reckless Apotheosis <125509978+recklessnode@users.noreply.github.com> Date: Fri, 6 Mar 2026 15:54:34 -0800 Subject: [PATCH 04/24] feat(bzm2): add debug CLI and DLL diagnostics Add the standalone BZM2 debug binary and extend the clock-control path beyond PLL status. This commit adds: - the UART-focused debug CLI for live serial interaction - DLL configuration and diagnostics alongside the existing PLL flow - protocol tests covering the extra wire-format and parser behavior needed by the tooling The port note and UART guide are updated in the same slice because they document the new bring-up and debug surface. --- docs/bzm2-port.md | 2 + docs/bzm2-uart-debug.md | 114 +++++++ mujina-miner/Cargo.toml | 4 + mujina-miner/src/asic/bzm2/clock.rs | 437 +++++++++++++++++-------- mujina-miner/src/asic/bzm2/mod.rs | 6 +- mujina-miner/src/asic/bzm2/protocol.rs | 19 ++ mujina-miner/src/asic/bzm2/uart.rs | 309 +++++++++++++++++ mujina-miner/src/bin/bzm2-debug.rs | 332 +++++++++++++++++++ 8 files changed, 1089 insertions(+), 134 deletions(-) create mode 100644 docs/bzm2-uart-debug.md create mode 100644 mujina-miner/src/asic/bzm2/uart.rs create mode 100644 mujina-miner/src/bin/bzm2-debug.rs diff --git a/docs/bzm2-port.md b/docs/bzm2-port.md index 7148bb5a..af159ec6 100644 --- a/docs/bzm2-port.md +++ b/docs/bzm2-port.md @@ -45,6 +45,8 @@ The BZM2 Mujina thread now reimplements the core legacy data path and the genera - reusable TPS546 PMBus rail control through `VoltageRegulator` - reusable multi-rail bring-up and shutdown sequencing for single-rail, small-stack, and larger multi-stack designs - UART-register-based PLL diagnostic/control flow for divider programming, enable/disable, lock polling, and readback +- UART-register-based DLL diagnostic/control flow for duty-cycle programming, enable/disable, lock polling, and fincon validation +- developer-facing UART debug CLI documented in [bzm2-uart-debug.md](C:/Users/prael/Documents/Codex/bzm2_mujina/docs/bzm2-uart-debug.md) with unicast, multicast, and broadcast examples ## Configuration diff --git a/docs/bzm2-uart-debug.md b/docs/bzm2-uart-debug.md new file mode 100644 index 00000000..e4b32b12 --- /dev/null +++ b/docs/bzm2-uart-debug.md @@ -0,0 +1,114 @@ +# BZM2 UART Debug Guide + +This guide documents the direct BZM2 UART developer interface added to Mujina. + +## Routing Modes + +- unicast: one ASIC, one destination address +- broadcast: all ASICs on the UART bus via ASIC id `0xff` +- multicast: one ASIC, one engine-row group + +## Binary + +Use [bzm2-debug.rs](C:/Users/prael/Documents/Codex/bzm2_mujina/mujina-miner/src/bin/bzm2-debug.rs) through Cargo: + +```text +cargo run -p mujina-miner --bin mujina-bzm2-debug -- ... +``` + +## Unicast Examples + +Read one ASIC-local register: + +```text +cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ + uart-read /dev/ttyUSB0 2 notch 0x12 4 5000000 +``` + +Write one ASIC-local register: + +```text +cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ + uart-write /dev/ttyUSB0 2 notch 0x12 01000000 5000000 +``` + +Run a NOOP sanity check: + +```text +cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ + uart-noop /dev/ttyUSB0 2 5000000 +``` + +Run a loopback payload echo: + +```text +cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ + uart-loopback /dev/ttyUSB0 2 aabbccdd 5000000 +``` + +## Broadcast Examples + +Broadcast-enable a PLL register across every ASIC on the bus: + +```text +cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ + uart-write /dev/ttyUSB0 broadcast notch 0x12 01000000 5000000 +``` + +Broadcast a full PLL program and enable sequence: + +```text +cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ + pll-set /dev/ttyUSB0 broadcast pll0 625 0 5000000 +``` + +Broadcast a DLL duty-cycle configuration: + +```text +cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ + dll-set /dev/ttyUSB0 broadcast dll0 50 5000000 +``` + +## Multicast Example + +Set timestamp count across one ASIC row-group: + +```text +cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ + uart-multicast-write /dev/ttyUSB0 2 7 0x48 3c 5000000 +``` + +## Clock Diagnostics + +Read PLL and DLL status for one ASIC: + +```text +cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ + clock-report /dev/ttyUSB0 2 5000000 +``` + +Program and lock one PLL on one ASIC: + +```text +cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ + pll-set /dev/ttyUSB0 2 pll1 625 0 5000000 +``` + +Program, enable, and validate one DLL on one ASIC: + +```text +cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ + dll-set /dev/ttyUSB0 2 dll1 55 5000000 +``` + +## Scope Boundary + +This interface is grounded in the legacy shipped UART path: + +- register read/write +- multicast write +- noop/loopback +- PLL divider programming, enable, and lock polling +- DLL duty-cycle programming, enable, lock polling, and fincon validation + +It is not a JTAG debug interface. \ No newline at end of file diff --git a/mujina-miner/Cargo.toml b/mujina-miner/Cargo.toml index 1cb651a9..78db80f9 100644 --- a/mujina-miner/Cargo.toml +++ b/mujina-miner/Cargo.toml @@ -65,6 +65,10 @@ path = "src/bin/cli.rs" name = "mujina-tui" path = "src/bin/tui.rs" + +[[bin]] +name = "mujina-bzm2-debug" +path = "src/bin/bzm2-debug.rs" [features] default = [] skip-pty-tests = [] # Skip PTY-based serial tests that may hang in some environments diff --git a/mujina-miner/src/asic/bzm2/clock.rs b/mujina-miner/src/asic/bzm2/clock.rs index 7806a5d7..5b16d71d 100644 --- a/mujina-miner/src/asic/bzm2/clock.rs +++ b/mujina-miner/src/asic/bzm2/clock.rs @@ -1,13 +1,11 @@ use std::time::Duration; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::time::{Instant, sleep}; use crate::transport::{SerialReader, SerialWriter}; -use super::protocol::{encode_read_register, encode_write_register}; +use super::uart::{Bzm2UartController, Bzm2UartError}; -const NOTCH_REG: u16 = 0x0fff; const REF_CLK_MHZ: f32 = 50.0; const REF_DIVIDER: u8 = 1; const POST2_DIVIDER: u8 = 0; @@ -21,13 +19,23 @@ const LOCAL_REG_PLL1_FBDIV: u8 = 0x1b; const LOCAL_REG_PLL1_ENABLE: u8 = 0x1c; const LOCAL_REG_PLL1_MISC: u8 = 0x1d; +const LOCAL_REG_CKDCCR_2_0: u8 = 0x56; +const LOCAL_REG_CKDCCR_3_0: u8 = 0x57; +const LOCAL_REG_CKDCCR_4_0: u8 = 0x58; +const LOCAL_REG_CKDCCR_5_0: u8 = 0x59; +const LOCAL_REG_CKDLLR_0_0: u8 = 0x5a; +const LOCAL_REG_CKDLLR_1_0: u8 = 0x5b; +const LOCAL_REG_CKDCCR_2_1: u8 = 0x5e; +const LOCAL_REG_CKDCCR_3_1: u8 = 0x5f; +const LOCAL_REG_CKDCCR_4_1: u8 = 0x60; +const LOCAL_REG_CKDCCR_5_1: u8 = 0x61; +const LOCAL_REG_CKDLLR_0_1: u8 = 0x62; +const LOCAL_REG_CKDLLR_1_1: u8 = 0x63; + #[derive(Debug, thiserror::Error)] pub enum Bzm2ClockError { - #[error("serial write failed: {0}")] - Write(#[from] std::io::Error), - - #[error("invalid PLL index")] - InvalidPll, + #[error(transparent)] + Uart(#[from] Bzm2UartError), #[error("invalid desired PLL frequency {0} MHz")] InvalidFrequency(f32), @@ -35,17 +43,8 @@ pub enum Bzm2ClockError { #[error("invalid PLL post divider {0}")] InvalidPostDivider(u8), - #[error("short read-register response: expected {expected} bytes, got {actual}")] - ShortRead { expected: usize, actual: usize }, - - #[error( - "unexpected read-register response header: expected asic {expected_asic:#x} opcode 0x3, got asic {actual_asic:#x} opcode {actual_opcode:#x}" - )] - UnexpectedResponseHeader { - expected_asic: u8, - actual_asic: u8, - actual_opcode: u8, - }, + #[error("unsupported DLL duty cycle {0}; supported values are 25, 50, 55, 60, 75")] + InvalidDllDutyCycle(u8), #[error( "PLL {pll:?} on ASIC {asic} did not lock before timeout; last enable value {last_enable:#x}" @@ -55,6 +54,18 @@ pub enum Bzm2ClockError { pll: Bzm2Pll, last_enable: u32, }, + + #[error( + "DLL {dll:?} on ASIC {asic} did not lock before timeout; last control value {last_control:#x}" + )] + DllLockTimeout { + asic: u8, + dll: Bzm2Dll, + last_control: u8, + }, + + #[error("DLL {dll:?} on ASIC {asic} reported invalid fincon {fincon:#x}")] + InvalidDllFincon { asic: u8, dll: Bzm2Dll, fincon: u8 }, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -64,25 +75,54 @@ pub enum Bzm2Pll { } impl Bzm2Pll { - fn register_block(self) -> (u8, u8, u8) { + fn register_block(self) -> (u8, u8, u8, u8) { match self { Self::Pll0 => ( LOCAL_REG_PLL_POSTDIV, LOCAL_REG_PLL_FBDIV, LOCAL_REG_PLL_ENABLE, + LOCAL_REG_PLL_MISC, ), Self::Pll1 => ( LOCAL_REG_PLL1_POSTDIV, LOCAL_REG_PLL1_FBDIV, LOCAL_REG_PLL1_ENABLE, + LOCAL_REG_PLL1_MISC, ), } } +} - fn misc_register(self) -> u8 { +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Bzm2Dll { + Dll0, + Dll1, +} + +impl Bzm2Dll { + fn registers(self) -> (u8, u8, u8, u8, u8) { match self { - Self::Pll0 => LOCAL_REG_PLL_MISC, - Self::Pll1 => LOCAL_REG_PLL1_MISC, + Self::Dll0 => ( + LOCAL_REG_CKDCCR_2_0, + LOCAL_REG_CKDCCR_3_0, + LOCAL_REG_CKDCCR_4_0, + LOCAL_REG_CKDCCR_5_0, + LOCAL_REG_CKDLLR_0_0, + ), + Self::Dll1 => ( + LOCAL_REG_CKDCCR_2_1, + LOCAL_REG_CKDCCR_3_1, + LOCAL_REG_CKDCCR_4_1, + LOCAL_REG_CKDCCR_5_1, + LOCAL_REG_CKDLLR_0_1, + ), + } + } + + fn fincon_register(self) -> u8 { + match self { + Self::Dll0 => LOCAL_REG_CKDLLR_1_0, + Self::Dll1 => LOCAL_REG_CKDLLR_1_1, } } } @@ -131,6 +171,51 @@ impl Bzm2PllConfig { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Bzm2DllConfig { + pub duty_cycle: u8, + pub nde_dll: u8, + pub nde_clk: u8, + pub npi_clk: u8, + pub pibypb: u8, + pub dllfreeze: u8, +} + +impl Bzm2DllConfig { + pub fn from_duty_cycle(duty_cycle: u8) -> Result { + let mut config = Self { + duty_cycle, + nde_dll: 0x1f, + nde_clk: 0x0f, + npi_clk: 0x0, + pibypb: 1, + dllfreeze: 0, + }; + + match duty_cycle { + 50 => {} + 75 => config.nde_clk = 0x17, + 60 => { + config.nde_dll = 0x1d; + config.nde_clk = 0x11; + } + 55 => { + config.nde_dll = 0x1d; + config.nde_clk = 0x0f; + config.npi_clk = 0x4; + } + 25 => config.nde_clk = 0x07, + _ => return Err(Bzm2ClockError::InvalidDllDutyCycle(duty_cycle)), + } + + Ok(config) + } + + fn control2(self) -> u8 { + ((self.npi_clk & 0x7) << 3) | ((self.pibypb & 0x1) << 2) | ((self.dllfreeze & 0x1) << 1) + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Bzm2PllStatus { pub pll: Bzm2Pll, @@ -140,66 +225,44 @@ pub struct Bzm2PllStatus { pub locked: bool, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Bzm2DllStatus { + pub dll: Bzm2Dll, + pub control2: u8, + pub control5: u8, + pub coarsecon: u8, + pub fincon: u8, + pub freeze_valid: bool, + pub locked: bool, + pub fincon_valid: bool, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct Bzm2ClockDebugReport { pub asic: u8, pub pll0: Bzm2PllStatus, pub pll1: Bzm2PllStatus, + pub dll0: Bzm2DllStatus, + pub dll1: Bzm2DllStatus, } pub struct Bzm2ClockController { - reader: SerialReader, - writer: SerialWriter, + uart: Bzm2UartController, } impl Bzm2ClockController { pub fn new(reader: SerialReader, writer: SerialWriter) -> Self { - Self { reader, writer } - } - - pub async fn write_local_reg_u8( - &mut self, - asic: u8, - offset: u8, - value: u8, - ) -> Result<(), Bzm2ClockError> { - self.writer - .write_all(&encode_write_register(asic, NOTCH_REG, offset, &[value])) - .await?; - self.writer.flush().await?; - Ok(()) - } - - pub async fn write_local_reg_u32( - &mut self, - asic: u8, - offset: u8, - value: u32, - ) -> Result<(), Bzm2ClockError> { - self.writer - .write_all(&encode_write_register( - asic, - NOTCH_REG, - offset, - &value.to_le_bytes(), - )) - .await?; - self.writer.flush().await?; - Ok(()) + Self { + uart: Bzm2UartController::new(reader, writer), + } } - pub async fn read_local_reg_u8(&mut self, asic: u8, offset: u8) -> Result { - let data = self.read_local_reg(asic, offset, 1).await?; - Ok(data[0]) + pub fn from_uart(uart: Bzm2UartController) -> Self { + Self { uart } } - pub async fn read_local_reg_u32( - &mut self, - asic: u8, - offset: u8, - ) -> Result { - let data = self.read_local_reg(asic, offset, 4).await?; - Ok(u32::from_le_bytes(data.try_into().unwrap())) + pub fn into_uart(self) -> Bzm2UartController { + self.uart } pub async fn program_pll( @@ -208,23 +271,27 @@ impl Bzm2ClockController { pll: Bzm2Pll, config: Bzm2PllConfig, ) -> Result<(), Bzm2ClockError> { - let (postdiv_reg, fbdiv_reg, _) = pll.register_block(); - self.write_local_reg_u32(asic, fbdiv_reg, config.feedback_divider as u32) + let (postdiv_reg, fbdiv_reg, _, _) = pll.register_block(); + self.uart + .write_local_reg_u32(asic, fbdiv_reg, config.feedback_divider as u32) .await?; - self.write_local_reg_u32(asic, postdiv_reg, config.packed_post_divider) + self.uart + .write_local_reg_u32(asic, postdiv_reg, config.packed_post_divider) .await?; sleep(Duration::from_millis(1)).await; Ok(()) } pub async fn enable_pll(&mut self, asic: u8, pll: Bzm2Pll) -> Result<(), Bzm2ClockError> { - let (_, _, enable_reg) = pll.register_block(); - self.write_local_reg_u32(asic, enable_reg, 1).await + let (_, _, enable_reg, _) = pll.register_block(); + self.uart.write_local_reg_u32(asic, enable_reg, 1).await?; + Ok(()) } pub async fn disable_pll(&mut self, asic: u8, pll: Bzm2Pll) -> Result<(), Bzm2ClockError> { - let (_, _, enable_reg) = pll.register_block(); - self.write_local_reg_u32(asic, enable_reg, 0).await + let (_, _, enable_reg, _) = pll.register_block(); + self.uart.write_local_reg_u32(asic, enable_reg, 0).await?; + Ok(()) } pub async fn set_pll_frequency( @@ -246,12 +313,11 @@ impl Bzm2ClockController { timeout: Duration, poll_interval: Duration, ) -> Result { - let (_, _, enable_reg) = pll.register_block(); + let (_, _, enable_reg, _) = pll.register_block(); let start = Instant::now(); - let mut last_enable = 0u32; loop { - last_enable = self.read_local_reg_u32(asic, enable_reg).await?; + let last_enable = self.uart.read_local_reg_u32(asic, enable_reg).await?; let status = self.read_pll_status(asic, pll).await?; if status.locked { return Ok(status); @@ -290,9 +356,9 @@ impl Bzm2ClockController { asic: u8, pll: Bzm2Pll, ) -> Result { - let (_, _, enable_reg) = pll.register_block(); - let enable = self.read_local_reg_u32(asic, enable_reg).await?; - let misc = self.read_local_reg_u32(asic, pll.misc_register()).await?; + let (_, _, enable_reg, misc_reg) = pll.register_block(); + let enable = self.uart.read_local_reg_u32(asic, enable_reg).await?; + let misc = self.uart.read_local_reg_u32(asic, misc_reg).await?; Ok(Bzm2PllStatus { pll, enable_register: enable, @@ -302,59 +368,159 @@ impl Bzm2ClockController { }) } - pub async fn debug_report(&mut self, asic: u8) -> Result { - Ok(Bzm2ClockDebugReport { - asic, - pll0: self.read_pll_status(asic, Bzm2Pll::Pll0).await?, - pll1: self.read_pll_status(asic, Bzm2Pll::Pll1).await?, - }) + pub async fn program_dll( + &mut self, + asic: u8, + dll: Bzm2Dll, + config: Bzm2DllConfig, + ) -> Result<(), Bzm2ClockError> { + let (control2_reg, control3_reg, control4_reg, _, _) = dll.registers(); + self.uart + .write_local_reg_u8(asic, control3_reg, config.nde_dll & 0x1f) + .await?; + self.uart + .write_local_reg_u8(asic, control4_reg, config.nde_clk & 0x1f) + .await?; + self.uart + .write_local_reg_u8(asic, control2_reg, config.control2()) + .await?; + sleep(Duration::from_millis(1)).await; + Ok(()) } - async fn read_local_reg( + pub async fn set_dll_duty_cycle( &mut self, asic: u8, - offset: u8, - count: u8, - ) -> Result, Bzm2ClockError> { - let request = encode_read_register(asic, NOTCH_REG, offset, count); - self.writer.write_all(&request).await?; - self.writer.flush().await?; - - let expected = count as usize + 2; - let mut response = vec![0u8; expected]; - let actual = self.reader.read_exact(&mut response).await; - if let Err(err) = actual { - if let std::io::ErrorKind::UnexpectedEof = err.kind() { - return Err(Bzm2ClockError::ShortRead { - expected, - actual: 0, + dll: Bzm2Dll, + duty_cycle: u8, + ) -> Result { + let config = Bzm2DllConfig::from_duty_cycle(duty_cycle)?; + self.program_dll(asic, dll, config).await?; + Ok(config) + } + + pub async fn enable_dll(&mut self, asic: u8, dll: Bzm2Dll) -> Result<(), Bzm2ClockError> { + let (_, _, _, control5_reg, _) = dll.registers(); + let value = self.uart.read_local_reg_u8(asic, control5_reg).await?; + self.uart + .write_local_reg_u8(asic, control5_reg, value | 0x1) + .await?; + let value = self.uart.read_local_reg_u8(asic, control5_reg).await?; + self.uart + .write_local_reg_u8(asic, control5_reg, value | (0x1 << 2)) + .await?; + Ok(()) + } + + pub async fn disable_dll(&mut self, asic: u8, dll: Bzm2Dll) -> Result<(), Bzm2ClockError> { + let (_, _, _, control5_reg, _) = dll.registers(); + self.uart.write_local_reg_u8(asic, control5_reg, 0).await?; + Ok(()) + } + + pub async fn wait_for_dll_lock( + &mut self, + asic: u8, + dll: Bzm2Dll, + timeout: Duration, + poll_interval: Duration, + ) -> Result { + let (control2_reg, _, _, control5_reg, _) = dll.registers(); + let control2 = self.uart.read_local_reg_u8(asic, control2_reg).await?; + if (control2 & 0x2) != 0 { + sleep(Duration::from_millis(10)).await; + return self.read_dll_status(asic, dll).await; + } + + let start = Instant::now(); + + loop { + let last_control = self.uart.read_local_reg_u8(asic, control5_reg).await?; + let status = self.read_dll_status(asic, dll).await?; + if status.locked { + return Ok(status); + } + if start.elapsed() >= timeout { + return Err(Bzm2ClockError::DllLockTimeout { + asic, + dll, + last_control, }); } - return Err(Bzm2ClockError::Write(err)); + sleep(poll_interval).await; } + } - validate_read_response_header(asic, &response)?; - Ok(response[2..].to_vec()) + pub async fn ensure_dll_fincon_valid( + &mut self, + asic: u8, + dll: Bzm2Dll, + ) -> Result { + let status = self.read_dll_status(asic, dll).await?; + if !status.fincon_valid { + return Err(Bzm2ClockError::InvalidDllFincon { + asic, + dll, + fincon: status.fincon, + }); + } + Ok(status) } -} -fn validate_read_response_header(expected_asic: u8, response: &[u8]) -> Result<(), Bzm2ClockError> { - if response.len() < 2 { - return Err(Bzm2ClockError::ShortRead { - expected: 2, - actual: response.len(), - }); + pub async fn configure_and_lock_dll( + &mut self, + asic: u8, + dll: Bzm2Dll, + duty_cycle: u8, + timeout: Duration, + ) -> Result<(Bzm2DllConfig, Bzm2DllStatus), Bzm2ClockError> { + let config = self.set_dll_duty_cycle(asic, dll, duty_cycle).await?; + self.enable_dll(asic, dll).await?; + self.wait_for_dll_lock(asic, dll, timeout, Duration::from_millis(10)) + .await?; + let status = self.ensure_dll_fincon_valid(asic, dll).await?; + Ok((config, status)) + } + + pub async fn read_dll_status( + &mut self, + asic: u8, + dll: Bzm2Dll, + ) -> Result { + let (control2_reg, _, _, control5_reg, coarse_reg) = dll.registers(); + let control2 = self.uart.read_local_reg_u8(asic, control2_reg).await?; + let control5 = self.uart.read_local_reg_u8(asic, control5_reg).await?; + let coarse_raw = self.uart.read_local_reg_u8(asic, coarse_reg).await?; + let fincon = self + .uart + .read_local_reg_u8(asic, dll.fincon_register()) + .await?; + + Ok(Bzm2DllStatus { + dll, + control2, + control5, + coarsecon: (coarse_raw >> 5) & 0x7, + fincon, + freeze_valid: (control2 & 0x2) != 0, + locked: (control5 & 0x2) != 0, + fincon_valid: fincon_is_valid(fincon), + }) } - let actual_asic = response[0]; - let actual_opcode = response[1]; - if actual_asic != expected_asic || actual_opcode != 0x03 { - return Err(Bzm2ClockError::UnexpectedResponseHeader { - expected_asic, - actual_asic, - actual_opcode, - }); + + pub async fn debug_report(&mut self, asic: u8) -> Result { + Ok(Bzm2ClockDebugReport { + asic, + pll0: self.read_pll_status(asic, Bzm2Pll::Pll0).await?, + pll1: self.read_pll_status(asic, Bzm2Pll::Pll1).await?, + dll0: self.read_dll_status(asic, Bzm2Dll::Dll0).await?, + dll1: self.read_dll_status(asic, Bzm2Dll::Dll1).await?, + }) } - Ok(()) +} + +fn fincon_is_valid(fincon: u8) -> bool { + !matches!(fincon & 0xf0, 0xf0 | 0x00) && !matches!(fincon & 0xe0, 0xe0 | 0x00) } fn round_legacy(value: f32) -> u16 { @@ -388,16 +554,23 @@ mod tests { } #[test] - fn read_response_header_validation_matches_legacy_contract() { - validate_read_response_header(0x12, &[0x12, 0x03, 0xaa]).unwrap(); - let err = validate_read_response_header(0x12, &[0x13, 0x0f, 0xaa]).unwrap_err(); - assert!(matches!( - err, - Bzm2ClockError::UnexpectedResponseHeader { - expected_asic: 0x12, - actual_asic: 0x13, - actual_opcode: 0x0f, - } - )); + fn dll_duty_cycle_matches_legacy_presets() { + let duty_55 = Bzm2DllConfig::from_duty_cycle(55).unwrap(); + assert_eq!(duty_55.nde_dll, 0x1d); + assert_eq!(duty_55.nde_clk, 0x0f); + assert_eq!(duty_55.npi_clk, 0x4); + + let duty_75 = Bzm2DllConfig::from_duty_cycle(75).unwrap(); + assert_eq!(duty_75.nde_dll, 0x1f); + assert_eq!(duty_75.nde_clk, 0x17); + assert_eq!(duty_75.npi_clk, 0x0); + } + + #[test] + fn fincon_validation_matches_legacy_rules() { + assert!(fincon_is_valid(0x9c)); + assert!(!fincon_is_valid(0xf4)); + assert!(!fincon_is_valid(0x0f)); + assert!(!fincon_is_valid(0xe1)); } } diff --git a/mujina-miner/src/asic/bzm2/mod.rs b/mujina-miner/src/asic/bzm2/mod.rs index 5af59a5c..b62f961a 100644 --- a/mujina-miner/src/asic/bzm2/mod.rs +++ b/mujina-miner/src/asic/bzm2/mod.rs @@ -2,9 +2,11 @@ pub mod clock; pub mod control; pub mod protocol; pub mod thread; +pub mod uart; pub use clock::{ - Bzm2ClockController, Bzm2ClockDebugReport, Bzm2ClockError, Bzm2Pll, Bzm2PllConfig, - Bzm2PllStatus, + Bzm2ClockController, Bzm2ClockDebugReport, Bzm2ClockError, Bzm2Dll, Bzm2DllConfig, + Bzm2DllStatus, Bzm2Pll, Bzm2PllConfig, Bzm2PllStatus, }; pub use thread::{Bzm2Thread, Bzm2ThreadConfig, Bzm2ThreadHandle}; +pub use uart::{BROADCAST_GROUP_ASIC, Bzm2UartController, Bzm2UartError, NOTCH_REG}; diff --git a/mujina-miner/src/asic/bzm2/protocol.rs b/mujina-miner/src/asic/bzm2/protocol.rs index 5520cb08..a4f703e6 100644 --- a/mujina-miner/src/asic/bzm2/protocol.rs +++ b/mujina-miner/src/asic/bzm2/protocol.rs @@ -532,6 +532,25 @@ mod tests { } } + #[test] + fn parser_decodes_gen1_dts_vs() { + let mut parser = TdmFrameParser::new(DtsVsGeneration::Gen1); + let raw = [0x02, OPCODE_UART_DTS_VS, 0x91, 0xab, 0xcd, 0x45]; + let parsed = parser.push(&raw); + assert_eq!(parsed.len(), 1); + match &parsed[0] { + TdmFrame::DtsVs(TdmDtsVsFrame::Gen1(frame)) => { + assert_eq!(frame.asic, 0x02); + assert_eq!(frame.voltage, 0x545); + assert!(frame.voltage_enabled); + assert_eq!(frame.thermal_tune_code, 0xab); + assert!(!frame.thermal_validity); + assert!(frame.thermal_enabled); + } + other => panic!("unexpected frame: {other:?}"), + } + } + #[test] fn parser_decodes_readreg_and_noop() { let mut parser = TdmFrameParser::new(DtsVsGeneration::Gen2); diff --git a/mujina-miner/src/asic/bzm2/uart.rs b/mujina-miner/src/asic/bzm2/uart.rs new file mode 100644 index 00000000..b2425911 --- /dev/null +++ b/mujina-miner/src/asic/bzm2/uart.rs @@ -0,0 +1,309 @@ +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +use crate::transport::{SerialReader, SerialWriter}; + +use super::protocol::{ + BROADCAST_ASIC, OPCODE_UART_LOOPBACK, OPCODE_UART_NOOP, OPCODE_UART_READREG, encode_loopback, + encode_multicast_write, encode_noop, encode_read_register, encode_write_job, + encode_write_register, +}; + +pub const NOTCH_REG: u16 = 0x0fff; +pub const BROADCAST_GROUP_ASIC: u8 = BROADCAST_ASIC; + +#[derive(Debug, thiserror::Error)] +pub enum Bzm2UartError { + #[error("serial I/O failed: {0}")] + Io(#[from] std::io::Error), + + #[error("short UART response: expected {expected} bytes, got {actual}")] + ShortResponse { expected: usize, actual: usize }, + + #[error( + "unexpected UART response header: expected asic {expected_asic:#x} opcode {expected_opcode:#x}, got asic {actual_asic:#x} opcode {actual_opcode:#x}" + )] + UnexpectedHeader { + expected_asic: u8, + expected_opcode: u8, + actual_asic: u8, + actual_opcode: u8, + }, +} + +/// Low-level BZM2 UART control surface. +/// +/// This controller wraps the legacy BZM2 UART framing in a small, explicit API. +/// It is intended for board bring-up, ASIC diagnostics, and developer tooling. +/// The methods are organized around the three routing modes exposed by the ASIC: +/// +/// - unicast: target one ASIC and one register space address +/// - multicast: target an ASIC and one engine group row +/// - broadcast: target all ASICs on a bus via ASIC id `0xff` +/// +/// Typical usage patterns: +/// +/// ```rust,no_run +/// # async fn demo(mut uart: mujina_miner::asic::bzm2::Bzm2UartController) -> Result<(), Box> { +/// use mujina_miner::asic::bzm2::{Bzm2Pll, Bzm2UartController, NOTCH_REG}; +/// +/// // Unicast: write one ASIC-local register. +/// uart.write_local_reg_u32(0x02, 0x12, 1).await?; +/// +/// // Broadcast: push one local register update to every ASIC on the UART bus. +/// uart.broadcast_local_reg_u32(0x07, 0x1).await?; +/// +/// // Multicast: update all engines in one row group on one ASIC. +/// uart.multicast_write_reg_u8(0x02, 7, 0x49, 60).await?; +/// # Ok(()) } +/// ``` +pub struct Bzm2UartController { + reader: SerialReader, + writer: SerialWriter, +} + +impl Bzm2UartController { + pub fn new(reader: SerialReader, writer: SerialWriter) -> Self { + Self { reader, writer } + } + + pub async fn write_register( + &mut self, + asic: u8, + engine_address: u16, + offset: u8, + value: &[u8], + ) -> Result<(), Bzm2UartError> { + self.writer + .write_all(&encode_write_register(asic, engine_address, offset, value)) + .await?; + self.writer.flush().await?; + Ok(()) + } + + pub async fn write_register_u8( + &mut self, + asic: u8, + engine_address: u16, + offset: u8, + value: u8, + ) -> Result<(), Bzm2UartError> { + self.write_register(asic, engine_address, offset, &[value]) + .await + } + + pub async fn write_register_u32( + &mut self, + asic: u8, + engine_address: u16, + offset: u8, + value: u32, + ) -> Result<(), Bzm2UartError> { + self.write_register(asic, engine_address, offset, &value.to_le_bytes()) + .await + } + + pub async fn write_local_reg_u8( + &mut self, + asic: u8, + offset: u8, + value: u8, + ) -> Result<(), Bzm2UartError> { + self.write_register_u8(asic, NOTCH_REG, offset, value).await + } + + pub async fn write_local_reg_u32( + &mut self, + asic: u8, + offset: u8, + value: u32, + ) -> Result<(), Bzm2UartError> { + self.write_register_u32(asic, NOTCH_REG, offset, value) + .await + } + + pub async fn broadcast_local_reg_u8( + &mut self, + offset: u8, + value: u8, + ) -> Result<(), Bzm2UartError> { + self.write_local_reg_u8(BROADCAST_ASIC, offset, value).await + } + + pub async fn broadcast_local_reg_u32( + &mut self, + offset: u8, + value: u32, + ) -> Result<(), Bzm2UartError> { + self.write_local_reg_u32(BROADCAST_ASIC, offset, value) + .await + } + + pub async fn multicast_write_register( + &mut self, + asic: u8, + group: u16, + offset: u8, + value: &[u8], + ) -> Result<(), Bzm2UartError> { + self.writer + .write_all(&encode_multicast_write(asic, group, offset, value)) + .await?; + self.writer.flush().await?; + Ok(()) + } + + pub async fn multicast_write_reg_u8( + &mut self, + asic: u8, + group: u16, + offset: u8, + value: u8, + ) -> Result<(), Bzm2UartError> { + self.multicast_write_register(asic, group, offset, &[value]) + .await + } + + pub async fn read_register( + &mut self, + asic: u8, + engine_address: u16, + offset: u8, + count: u8, + ) -> Result, Bzm2UartError> { + let request = encode_read_register(asic, engine_address, offset, count); + self.writer.write_all(&request).await?; + self.writer.flush().await?; + + let expected = count as usize + 2; + let mut response = vec![0u8; expected]; + self.reader.read_exact(&mut response).await?; + validate_response_header(asic, OPCODE_UART_READREG, &response)?; + Ok(response[2..].to_vec()) + } + + pub async fn read_register_u8( + &mut self, + asic: u8, + engine_address: u16, + offset: u8, + ) -> Result { + Ok(self.read_register(asic, engine_address, offset, 1).await?[0]) + } + + pub async fn read_register_u32( + &mut self, + asic: u8, + engine_address: u16, + offset: u8, + ) -> Result { + let data = self.read_register(asic, engine_address, offset, 4).await?; + Ok(u32::from_le_bytes(data.try_into().unwrap())) + } + + pub async fn read_local_reg_u8(&mut self, asic: u8, offset: u8) -> Result { + self.read_register_u8(asic, NOTCH_REG, offset).await + } + + pub async fn read_local_reg_u32(&mut self, asic: u8, offset: u8) -> Result { + self.read_register_u32(asic, NOTCH_REG, offset).await + } + + pub async fn noop(&mut self, asic: u8) -> Result<[u8; 3], Bzm2UartError> { + let request = encode_noop(asic); + self.writer.write_all(&request).await?; + self.writer.flush().await?; + + let mut response = [0u8; 5]; + self.reader.read_exact(&mut response).await?; + validate_response_header(asic, OPCODE_UART_NOOP, &response)?; + Ok(response[2..5].try_into().unwrap()) + } + + pub async fn loopback(&mut self, asic: u8, payload: &[u8]) -> Result, Bzm2UartError> { + let request = encode_loopback(asic, payload); + self.writer.write_all(&request).await?; + self.writer.flush().await?; + + let expected = payload.len() + 2; + let mut response = vec![0u8; expected]; + self.reader.read_exact(&mut response).await?; + validate_response_header(asic, OPCODE_UART_LOOPBACK, &response)?; + Ok(response[2..].to_vec()) + } + + pub async fn write_job( + &mut self, + asic: u8, + engine_address: u16, + midstate: &[u8; 32], + merkle_root_residue: u32, + ntime: u32, + sequence_id: u8, + job_control: u8, + ) -> Result<(), Bzm2UartError> { + self.writer + .write_all(&encode_write_job( + asic, + engine_address, + midstate, + merkle_root_residue, + ntime, + sequence_id, + job_control, + )) + .await?; + self.writer.flush().await?; + Ok(()) + } +} + +fn validate_response_header( + expected_asic: u8, + expected_opcode: u8, + response: &[u8], +) -> Result<(), Bzm2UartError> { + if response.len() < 2 { + return Err(Bzm2UartError::ShortResponse { + expected: 2, + actual: response.len(), + }); + } + + let actual_asic = response[0]; + let actual_opcode = response[1]; + if actual_asic != expected_asic || actual_opcode != expected_opcode { + return Err(Bzm2UartError::UnexpectedHeader { + expected_asic, + expected_opcode, + actual_asic, + actual_opcode, + }); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn response_header_validation_accepts_matching_unicast_response() { + validate_response_header(0x12, OPCODE_UART_READREG, &[0x12, OPCODE_UART_READREG]).unwrap(); + } + + #[test] + fn response_header_validation_rejects_mismatched_response() { + let err = validate_response_header(0x12, OPCODE_UART_NOOP, &[0x13, OPCODE_UART_LOOPBACK]) + .unwrap_err(); + assert!(matches!( + err, + Bzm2UartError::UnexpectedHeader { + expected_asic: 0x12, + expected_opcode: OPCODE_UART_NOOP, + actual_asic: 0x13, + actual_opcode: OPCODE_UART_LOOPBACK, + } + )); + } +} diff --git a/mujina-miner/src/bin/bzm2-debug.rs b/mujina-miner/src/bin/bzm2-debug.rs new file mode 100644 index 00000000..1c642b62 --- /dev/null +++ b/mujina-miner/src/bin/bzm2-debug.rs @@ -0,0 +1,332 @@ +use std::env; +use std::time::Duration; + +use anyhow::{Context, Result, bail}; +use mujina_miner::asic::bzm2::{ + BROADCAST_GROUP_ASIC, Bzm2ClockController, Bzm2Dll, Bzm2Pll, Bzm2UartController, NOTCH_REG, +}; +use mujina_miner::transport::SerialStream; + +const DEFAULT_BAUD: u32 = 5_000_000; + +#[tokio::main] +async fn main() -> Result<()> { + let args: Vec = env::args().collect(); + if args.len() < 2 { + print_usage(); + std::process::exit(1); + } + + match args[1].as_str() { + "uart-read" => cmd_uart_read(&args[2..]).await, + "uart-write" => cmd_uart_write(&args[2..]).await, + "uart-multicast-write" => cmd_uart_multicast_write(&args[2..]).await, + "uart-noop" => cmd_uart_noop(&args[2..]).await, + "uart-loopback" => cmd_uart_loopback(&args[2..]).await, + "clock-report" => cmd_clock_report(&args[2..]).await, + "pll-set" => cmd_pll_set(&args[2..]).await, + "dll-set" => cmd_dll_set(&args[2..]).await, + other => { + bail!("unknown command: {other}"); + } + } +} + +fn print_usage() { + eprintln!("Usage: mujina-bzm2-debug [args]"); + eprintln!(); + eprintln!("Direct UART developer commands:"); + eprintln!(" uart-read [baud]"); + eprintln!(" uart-write [baud]"); + eprintln!( + " uart-multicast-write [baud]" + ); + eprintln!(" uart-noop [baud]"); + eprintln!(" uart-loopback [baud]"); + eprintln!(); + eprintln!("Clock diagnostics:"); + eprintln!(" clock-report [baud]"); + eprintln!(" pll-set [baud]"); + eprintln!(" dll-set [baud]"); + eprintln!(); + eprintln!("Addressing examples:"); + eprintln!(" unicast : uart-write /dev/ttyUSB0 2 notch 0x12 01000000"); + eprintln!(" broadcast : uart-write /dev/ttyUSB0 broadcast notch 0x12 01000000"); + eprintln!(" multicast : uart-multicast-write /dev/ttyUSB0 2 7 0x49 3c"); +} + +fn parse_baud(raw: Option<&String>) -> Result { + match raw { + Some(raw) => Ok(parse_u32(raw)?), + None => Ok(DEFAULT_BAUD), + } +} + +fn parse_u8(raw: &str) -> Result { + Ok(parse_u32(raw)? as u8) +} + +fn parse_u16(raw: &str) -> Result { + Ok(parse_u32(raw)? as u16) +} + +fn parse_u32(raw: &str) -> Result { + if let Some(stripped) = raw.strip_prefix("0x").or_else(|| raw.strip_prefix("0X")) { + Ok(u32::from_str_radix(stripped, 16)?) + } else { + Ok(raw.parse()?) + } +} + +fn parse_f32(raw: &str) -> Result { + Ok(raw.parse()?) +} + +fn parse_hex_bytes(raw: &str) -> Result> { + let sanitized: String = raw + .chars() + .filter(|c| !c.is_ascii_whitespace() && *c != '_' && *c != ':') + .collect(); + if sanitized.len() % 2 != 0 { + bail!("hex payload must have an even number of digits"); + } + Ok(hex::decode(sanitized)?) +} + +fn parse_asic_or_broadcast(raw: &str) -> Result { + if raw.eq_ignore_ascii_case("broadcast") { + Ok(BROADCAST_GROUP_ASIC) + } else { + parse_u8(raw) + } +} + +fn parse_engine_address(raw: &str) -> Result { + if raw.eq_ignore_ascii_case("notch") { + Ok(NOTCH_REG) + } else { + parse_u16(raw) + } +} + +fn parse_pll(raw: &str) -> Result { + match raw.to_ascii_lowercase().as_str() { + "pll0" | "0" => Ok(Bzm2Pll::Pll0), + "pll1" | "1" => Ok(Bzm2Pll::Pll1), + _ => bail!("invalid PLL selector: {raw}"), + } +} + +fn parse_dll(raw: &str) -> Result { + match raw.to_ascii_lowercase().as_str() { + "dll0" | "0" => Ok(Bzm2Dll::Dll0), + "dll1" | "1" => Ok(Bzm2Dll::Dll1), + _ => bail!("invalid DLL selector: {raw}"), + } +} + +fn open_uart(serial: &str, baud: u32) -> Result { + let stream = SerialStream::new(serial, baud) + .with_context(|| format!("failed to open serial port {serial} at {baud} baud"))?; + let (reader, writer, _) = stream.split(); + Ok(Bzm2UartController::new(reader, writer)) +} + +fn open_clock(serial: &str, baud: u32) -> Result { + let stream = SerialStream::new(serial, baud) + .with_context(|| format!("failed to open serial port {serial} at {baud} baud"))?; + let (reader, writer, _) = stream.split(); + Ok(Bzm2ClockController::new(reader, writer)) +} + +async fn cmd_uart_read(args: &[String]) -> Result<()> { + if args.len() < 5 || args.len() > 6 { + bail!("usage: uart-read [baud]"); + } + let mut uart = open_uart(&args[0], parse_baud(args.get(5))?)?; + let asic = parse_u8(&args[1])?; + let engine = parse_engine_address(&args[2])?; + let offset = parse_u8(&args[3])?; + let count = parse_u8(&args[4])?; + let data = uart.read_register(asic, engine, offset, count).await?; + println!("{}", hex::encode(data)); + Ok(()) +} + +async fn cmd_uart_write(args: &[String]) -> Result<()> { + if args.len() < 5 || args.len() > 6 { + bail!( + "usage: uart-write [baud]" + ); + } + let mut uart = open_uart(&args[0], parse_baud(args.get(5))?)?; + let asic = parse_asic_or_broadcast(&args[1])?; + let engine = parse_engine_address(&args[2])?; + let offset = parse_u8(&args[3])?; + let value = parse_hex_bytes(&args[4])?; + uart.write_register(asic, engine, offset, &value).await?; + println!("ok"); + Ok(()) +} + +async fn cmd_uart_multicast_write(args: &[String]) -> Result<()> { + if args.len() < 5 || args.len() > 6 { + bail!( + "usage: uart-multicast-write [baud]" + ); + } + let mut uart = open_uart(&args[0], parse_baud(args.get(5))?)?; + let asic = parse_asic_or_broadcast(&args[1])?; + let group = parse_u16(&args[2])?; + let offset = parse_u8(&args[3])?; + let value = parse_hex_bytes(&args[4])?; + uart.multicast_write_register(asic, group, offset, &value) + .await?; + println!("ok"); + Ok(()) +} + +async fn cmd_uart_noop(args: &[String]) -> Result<()> { + if args.len() < 2 || args.len() > 3 { + bail!("usage: uart-noop [baud]"); + } + let mut uart = open_uart(&args[0], parse_baud(args.get(2))?)?; + let asic = parse_u8(&args[1])?; + let value = uart.noop(asic).await?; + println!( + "ascii={}{}{} hex={}", + value[0] as char, + value[1] as char, + value[2] as char, + hex::encode(value) + ); + Ok(()) +} + +async fn cmd_uart_loopback(args: &[String]) -> Result<()> { + if args.len() < 3 || args.len() > 4 { + bail!("usage: uart-loopback [baud]"); + } + let mut uart = open_uart(&args[0], parse_baud(args.get(3))?)?; + let asic = parse_u8(&args[1])?; + let payload = parse_hex_bytes(&args[2])?; + let echoed = uart.loopback(asic, &payload).await?; + println!("{}", hex::encode(echoed)); + Ok(()) +} + +async fn cmd_clock_report(args: &[String]) -> Result<()> { + if args.len() < 2 || args.len() > 3 { + bail!("usage: clock-report [baud]"); + } + let mut clock = open_clock(&args[0], parse_baud(args.get(2))?)?; + let asic = parse_u8(&args[1])?; + let report = clock.debug_report(asic).await?; + println!("ASIC {}", report.asic); + println!( + " PLL0: enabled={} locked={} enable={:#010x} misc={:#010x}", + report.pll0.enabled, + report.pll0.locked, + report.pll0.enable_register, + report.pll0.misc_register + ); + println!( + " PLL1: enabled={} locked={} enable={:#010x} misc={:#010x}", + report.pll1.enabled, + report.pll1.locked, + report.pll1.enable_register, + report.pll1.misc_register + ); + println!( + " DLL0: locked={} freeze_valid={} coarsecon={} fincon={:#04x} fincon_valid={} control2={:#04x} control5={:#04x}", + report.dll0.locked, + report.dll0.freeze_valid, + report.dll0.coarsecon, + report.dll0.fincon, + report.dll0.fincon_valid, + report.dll0.control2, + report.dll0.control5 + ); + println!( + " DLL1: locked={} freeze_valid={} coarsecon={} fincon={:#04x} fincon_valid={} control2={:#04x} control5={:#04x}", + report.dll1.locked, + report.dll1.freeze_valid, + report.dll1.coarsecon, + report.dll1.fincon, + report.dll1.fincon_valid, + report.dll1.control2, + report.dll1.control5 + ); + Ok(()) +} + +async fn cmd_pll_set(args: &[String]) -> Result<()> { + if args.len() < 5 || args.len() > 6 { + bail!( + "usage: pll-set [baud]" + ); + } + let mut clock = open_clock(&args[0], parse_baud(args.get(5))?)?; + let asic = parse_asic_or_broadcast(&args[1])?; + let pll = parse_pll(&args[2])?; + let freq = parse_f32(&args[3])?; + let post1 = parse_u8(&args[4])?; + + if asic == BROADCAST_GROUP_ASIC { + let config = clock.set_pll_frequency(asic, pll, freq, post1).await?; + clock.enable_pll(asic, pll).await?; + println!( + "broadcast {:?}: freq={}MHz fbdiv={} postdiv={:#x}", + pll, config.frequency_mhz, config.feedback_divider, config.packed_post_divider + ); + } else { + let (config, status) = clock + .configure_and_lock_pll(asic, pll, freq, post1, Duration::from_secs(3)) + .await?; + println!( + "asic {} {:?}: freq={}MHz fbdiv={} postdiv={:#x} locked={} enable={:#010x}", + asic, + pll, + config.frequency_mhz, + config.feedback_divider, + config.packed_post_divider, + status.locked, + status.enable_register + ); + } + Ok(()) +} + +async fn cmd_dll_set(args: &[String]) -> Result<()> { + if args.len() < 4 || args.len() > 5 { + bail!("usage: dll-set [baud]"); + } + let mut clock = open_clock(&args[0], parse_baud(args.get(4))?)?; + let asic = parse_asic_or_broadcast(&args[1])?; + let dll = parse_dll(&args[2])?; + let duty = parse_u8(&args[3])?; + + if asic == BROADCAST_GROUP_ASIC { + let config = clock.set_dll_duty_cycle(asic, dll, duty).await?; + clock.enable_dll(asic, dll).await?; + println!( + "broadcast {:?}: duty={} nde_dll={:#x} nde_clk={:#x} npi_clk={:#x}", + dll, config.duty_cycle, config.nde_dll, config.nde_clk, config.npi_clk + ); + } else { + let (config, status) = clock + .configure_and_lock_dll(asic, dll, duty, Duration::from_secs(2)) + .await?; + println!( + "asic {} {:?}: duty={} locked={} coarsecon={} fincon={:#04x} fincon_valid={}", + asic, + dll, + config.duty_cycle, + status.locked, + status.coarsecon, + status.fincon, + status.fincon_valid + ); + } + Ok(()) +} From 4b2321bec477b67c26f41356e3c7d559061d031a Mon Sep 17 00:00:00 2001 From: Reckless Apotheosis <125509978+recklessnode@users.noreply.github.com> Date: Fri, 6 Mar 2026 15:55:35 -0800 Subject: [PATCH 05/24] feat(bzm2): add tuning planner and startup calibration Build out the BZM2 board runtime beyond basic mining support. This commit adds: - the tuning planner and saved operating-point model - startup calibration and replay of saved operating points - broadcast-oriented bring-up helpers in the debug CLI - the naming cleanup for BZM2 tuning concepts so the Rust surface is less coupled to legacy internal terminology It also keeps the supporting operator docs in sync with the new tuning and bring-up behavior. --- docs/bzm2-pnp.md | 101 +++ docs/bzm2-port.md | 1 + docs/bzm2-uart-debug.md | 171 ++++- mujina-miner/src/asic/bzm2/clock.rs | 30 + mujina-miner/src/asic/bzm2/mod.rs | 7 + mujina-miner/src/asic/bzm2/pnp.rs | 847 ++++++++++++++++++++++++ mujina-miner/src/asic/mod.rs | 2 +- mujina-miner/src/backplane.rs | 4 +- mujina-miner/src/bin/bzm2-debug.rs | 804 +++++++++++++++++++++- mujina-miner/src/board/bzm2.rs | 990 +++++++++++++++++++++++++++- mujina-miner/src/board/mod.rs | 2 +- mujina-miner/src/daemon.rs | 8 +- 12 files changed, 2898 insertions(+), 69 deletions(-) create mode 100644 docs/bzm2-pnp.md create mode 100644 mujina-miner/src/asic/bzm2/pnp.rs diff --git a/docs/bzm2-pnp.md b/docs/bzm2-pnp.md new file mode 100644 index 00000000..c4df4e25 --- /dev/null +++ b/docs/bzm2-pnp.md @@ -0,0 +1,101 @@ +# BZM2 PnP Calibration In Mujina + +This note captures the current BZM2 PnP state in Mujina, what the legacy `bzmd` implementation did, and what is now implemented in the Rust port. + +## Current Gap + +Before this change, Mujina's BZM2 support had: + +- UART work dispatch +- result parsing +- thermal and power safety shutdowns +- UART register access +- PLL and DLL control + +What it did not have was the legacy PnP calibration planner: + +- strategy and bin-specific target selection +- parameter sweep generation +- initial voltage and frequency selection from site temperature +- saved operating point reuse checks +- retune decisions when measured throughput regresses +- domain-aware planning for hardware with multiple voltage domains +- per-ASIC or per-stack frequency fine-tuning around a target pass-rate window + +## Legacy `pnp.c` Behavior + +The original C implementation mixed: + +- calibration search policy +- board and PSU policy +- persisted board calibration profiles +- per-ASIC telemetry accumulation +- per-engine pass-rate accounting +- platform-specific data collection and file I/O + +The reusable algorithmic parts are: + +- derive target voltage, frequency, and pass rate from operating class and performance mode +- derive initial voltage and frequency from site thermal conditions +- broadcast a starting frequency +- sweep upward while respecting power and thermal guard rails +- tune back down on individual ASICs or stacks when pass rate falls outside the target window +- invalidate saved operating point when throughput regresses materially + +## Mujina Ported Behavior + +The new Rust module at +[C:\Users\prael\Documents\Codex\bzm2_mujina\mujina-miner\src\asic\bzm2\pnp.rs](C:\Users\prael\Documents\Codex\bzm2_mujina\mujina-miner\src\asic\bzm2\pnp.rs) +implements the reusable planner without pulling board-MCU or PSU glue into the ASIC layer. + +Implemented: + +- performance mode targets for: + - generic + - EarlyValidation + - ProductionValidation + - StackTunedA + - StackTunedB + - ExtendedHeadroom + - ExtendedHeadroomB +- parameter sweep generation analogous to legacy `pnp_create_paramters_vector()` +- ambient-aware initial voltage and frequency planning analogous to `pnp_set_initial_voltage_frequency()` +- saved operating point reuse vs. full retune decisions +- domain-aware voltage planning using explicit voltage-domain offsets and guards +- per-domain frequency planning using aggregated pass-rate, thermal, and power data +- per-ASIC fine-tuning with optional per-stack / per-PLL behavior + +## Efficiency Model + +The planner is structured to scale cleanly from a single ASIC to large chains: + +- one pass to aggregate domain-level metrics +- one pass to emit per-domain plans +- one pass to emit per-ASIC adjustments + +That keeps the planning work effectively linear in ASIC count for normal use. + +For larger systems with multiple voltage domains, the planner prefers: + +- domain-level voltage decisions first +- domain-average frequency targets next +- per-ASIC or per-PLL corrections only where pass-rate or thermal data requires it + +That is materially more scalable than treating a 100-ASIC machine as 100 independent full-search problems. + +## Scope Boundary + +The planner is now wired into `Bzm2Board` startup so Mujina can: + +- execute a live pre-thread calibration phase +- persist applied calibration results as a stored profile +- replay a compatible stored profile directly on restart before falling back to retune + +What still remains outside the ASIC planner layer: + +- board-specific PSU ramp policy +- dynamic runtime retune from live pass-rate or throughput feedback +- reimplementation of the legacy CSV/database layer + +Those pieces still belong above the ASIC planner, in board or daemon integration layers. + diff --git a/docs/bzm2-port.md b/docs/bzm2-port.md index af159ec6..4327913e 100644 --- a/docs/bzm2-port.md +++ b/docs/bzm2-port.md @@ -47,6 +47,7 @@ The BZM2 Mujina thread now reimplements the core legacy data path and the genera - UART-register-based PLL diagnostic/control flow for divider programming, enable/disable, lock polling, and readback - UART-register-based DLL diagnostic/control flow for duty-cycle programming, enable/disable, lock polling, and fincon validation - developer-facing UART debug CLI documented in [bzm2-uart-debug.md](C:/Users/prael/Documents/Codex/bzm2_mujina/docs/bzm2-uart-debug.md) with unicast, multicast, and broadcast examples +- domain-aware PnP calibration planner documented in [bzm2-pnp.md](C:/Users/prael/Documents/Codex/bzm2_mujina/docs/bzm2-pnp.md) for strategy/bin target selection, parameter sweeps, and per-domain plus per-ASIC tuning ## Configuration diff --git a/docs/bzm2-uart-debug.md b/docs/bzm2-uart-debug.md index e4b32b12..065de36e 100644 --- a/docs/bzm2-uart-debug.md +++ b/docs/bzm2-uart-debug.md @@ -1,6 +1,25 @@ # BZM2 UART Debug Guide -This guide documents the direct BZM2 UART developer interface added to Mujina. +This guide documents the direct BZM2 UART and silicon-validation interface added to Mujina. + +## Intent + +The current CLI folds in the portable parts of the legacy `silicon validation` BZM2 validation surface: + +- ASIC discovery and liveness scans +- loopback data-path validation +- explicit TDM enable and disable control +- live TDM result, register, noop, and DTS/VS observation +- broadcast TDM register-read observation across many ASICs +- engine-wide timestamp, target, and leading-zero programming +- deterministic grid-job exercisers for single-phase and back-to-back job dispatch +- existing PLL and DLL diagnostics and bring-up helpers + +What is deliberately not ported here: + +- legacy board-reset and board-count orchestration +- BCH-vector and nonce-golden-data regression harnesses +- opaque manufacturing hooks and carrier-specific glue ## Routing Modes @@ -16,7 +35,21 @@ Use [bzm2-debug.rs](C:/Users/prael/Documents/Codex/bzm2_mujina/mujina-miner/src/ cargo run -p mujina-miner --bin mujina-bzm2-debug -- ... ``` -## Unicast Examples +## Legacy Test Mapping + +The most useful `silicon validation` tests map to the following commands: + +- `test_noop_all_asic` -> `noop-scan` +- `test_loopback` -> `loopback-scan` +- `test_effbst_tdm_selectable_leadingzeros` -> `engine-zeros-all` plus `job-grid-watch` +- `test_effbst_tdm_jobsubmit_with_writejobcmd` -> `job-grid` or `job-grid-watch` +- `test_effbst_tdm_continuous_b2b_jobs` -> `job-grid-2phase-watch` +- `uart_command_broadcast_readreg_tdm_async` flows -> `tdm-broadcast-read-watch` +- general TDM callback validation -> `tdm-watch` + +The old multicasted-EFFBST and auto-clock-gating tests depended on the legacy BCH vector library and runtime board harness. The new CLI does not claim golden nonce validation there; it provides deterministic packet generation and live observation so developers can still exercise the same ASIC paths when debugging hardware. + +## Direct UART Examples Read one ASIC-local register: @@ -32,52 +65,138 @@ cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ uart-write /dev/ttyUSB0 2 notch 0x12 01000000 5000000 ``` -Run a NOOP sanity check: +Run a NOOP sanity check across an ASIC range: ```text cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ - uart-noop /dev/ttyUSB0 2 5000000 + noop-scan /dev/ttyUSB0 0 15 5000000 ``` -Run a loopback payload echo: +Run deterministic loopback validation across an ASIC range: ```text cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ - uart-loopback /dev/ttyUSB0 2 aabbccdd 5000000 + loopback-scan /dev/ttyUSB0 0 15 8 5000000 ``` -## Broadcast Examples +Read one synchronous result frame from one ASIC: -Broadcast-enable a PLL register across every ASIC on the bus: +```text +cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ + uart-read-result /dev/ttyUSB0 2 gen2 5000000 +``` + +## TDM Control and Observation + +The legacy `enable_tdm()` path is grounded in a local-register write to `LOCAL_REG_UART_TDM_CTL` (`0x07`) with control word: + +```text +(prediv_raw << 9) | (tdm_counter << 1) | enable_bit +``` + +Enable TDM explicitly: ```text cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ - uart-write /dev/ttyUSB0 broadcast notch 0x12 01000000 5000000 + tdm-enable /dev/ttyUSB0 0x12 16 5000000 ``` -Broadcast a full PLL program and enable sequence: +Disable TDM explicitly: ```text cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ - pll-set /dev/ttyUSB0 broadcast pll0 625 0 5000000 + tdm-disable /dev/ttyUSB0 0x12 16 5000000 ``` -Broadcast a DLL duty-cycle configuration: +Watch the mixed TDM stream for five seconds: ```text cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ - dll-set /dev/ttyUSB0 broadcast dll0 50 5000000 + tdm-watch /dev/ttyUSB0 gen2 5 5000000 ``` -## Multicast Example +Broadcast a register read and collect returned TDM read frames from ASICs `0` through `15`: -Set timestamp count across one ASIC row-group: +```text +cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ + tdm-broadcast-read-watch /dev/ttyUSB0 gen2 0 15 notch 0x12 4 5000000 +``` + +## Engine-Wide Programming Helpers + +Set the target register across every engine row on one ASIC or the whole bus: + +```text +cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ + engine-target-all /dev/ttyUSB0 2 0x1705ffff 5000000 +``` + +Set timestamp count across all engine rows: + +```text +cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ + engine-timestamp-all /dev/ttyUSB0 2 60 5000000 +``` + +Program selectable leading zeros across all engine rows: + +```text +cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ + engine-zeros-all /dev/ttyUSB0 2 48 5000000 +``` + +`engine-zeros-all` accepts `32..=64`, matching the legacy validation flow. The actual register value written is `zeros_to_find - 32`, which is what the C source used. + +## Job Exercisers + +These commands intentionally generate deterministic synthetic job material. They are designed to exercise the ASIC job path and TDM-result machinery without pretending to replace the legacy golden-vector regression harness. + +Dispatch one full grid of `WRITEJOB` packets: + +```text +cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ + job-grid /dev/ttyUSB0 2 0 1700000000 60 5000000 +``` + +Dispatch one full grid and watch live TDM responses: + +```text +cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ + job-grid-watch /dev/ttyUSB0 2 0 1700000000 60 5 gen2 5000000 +``` + +Dispatch back-to-back grids with the second phase offset by the legacy `MAX_EFFBST_SUBJOBS` sequence spacing: + +```text +cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ + job-grid-2phase-watch /dev/ttyUSB0 2 0 1700000000 60 5 gen2 5000000 +``` + +## Broadcast, Multicast, and Unicast Reference + +Use unicast when you need one ASIC-local or one engine-local change: + +```text +cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ + uart-write /dev/ttyUSB0 2 notch 0x12 01000000 5000000 +``` + +Use broadcast when every ASIC on the bus should receive the same packet: + +```text +cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ + uart-write /dev/ttyUSB0 broadcast notch 0x12 01000000 5000000 +``` + +Use multicast when one ASIC needs the same engine-register update across a full row group: ```text cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ uart-multicast-write /dev/ttyUSB0 2 7 0x48 3c 5000000 ``` +The higher-level helpers such as `engine-timestamp-all`, `engine-zeros-all`, and `job-grid` are implemented on top of this same routing model so developers can either stay at the helper level or drop down to raw packet control when needed. + ## Clock Diagnostics Read PLL and DLL status for one ASIC: @@ -101,14 +220,20 @@ cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ dll-set /dev/ttyUSB0 2 dll1 55 5000000 ``` -## Scope Boundary +Broadcast one PLL program and verify lock across ASIC ids `0` through `15`: + +```text +cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ + pll-broadcast-lock-check /dev/ttyUSB0 pll0 625 0 0 15 5000000 +``` -This interface is grounded in the legacy shipped UART path: +Broadcast one DLL program and verify lock and `fincon` validity across ASIC ids `0` through `15`: -- register read/write -- multicast write -- noop/loopback -- PLL divider programming, enable, and lock polling -- DLL duty-cycle programming, enable, lock polling, and fincon validation +```text +cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ + dll-broadcast-lock-check /dev/ttyUSB0 dll0 50 0 15 5000000 +``` + +## Scope Boundary -It is not a JTAG debug interface. \ No newline at end of file +This interface is grounded in the legacy shipped UART path and the `silicon validation` validation source. It is not a JTAG debug interface and it is not a full BCH-vector regression harness. \ No newline at end of file diff --git a/mujina-miner/src/asic/bzm2/clock.rs b/mujina-miner/src/asic/bzm2/clock.rs index 5b16d71d..9a52fd3f 100644 --- a/mujina-miner/src/asic/bzm2/clock.rs +++ b/mujina-miner/src/asic/bzm2/clock.rs @@ -306,6 +306,36 @@ impl Bzm2ClockController { Ok(config) } + pub async fn broadcast_pll_frequency( + &mut self, + pll: Bzm2Pll, + frequency_mhz: f32, + post1_divider: u8, + ) -> Result { + let config = Bzm2PllConfig::from_target_frequency(frequency_mhz, post1_divider)?; + let (postdiv_reg, fbdiv_reg, _, _) = pll.register_block(); + self.uart + .broadcast_local_reg_u32(fbdiv_reg, config.feedback_divider as u32) + .await?; + self.uart + .broadcast_local_reg_u32(postdiv_reg, config.packed_post_divider) + .await?; + sleep(Duration::from_millis(1)).await; + Ok(config) + } + + pub async fn broadcast_enable_pll(&mut self, pll: Bzm2Pll) -> Result<(), Bzm2ClockError> { + let (_, _, enable_reg, _) = pll.register_block(); + self.uart.broadcast_local_reg_u32(enable_reg, 1).await?; + Ok(()) + } + + pub async fn broadcast_disable_pll(&mut self, pll: Bzm2Pll) -> Result<(), Bzm2ClockError> { + let (_, _, enable_reg, _) = pll.register_block(); + self.uart.broadcast_local_reg_u32(enable_reg, 0).await?; + Ok(()) + } + pub async fn wait_for_pll_lock( &mut self, asic: u8, diff --git a/mujina-miner/src/asic/bzm2/mod.rs b/mujina-miner/src/asic/bzm2/mod.rs index b62f961a..bcdb6e04 100644 --- a/mujina-miner/src/asic/bzm2/mod.rs +++ b/mujina-miner/src/asic/bzm2/mod.rs @@ -1,5 +1,6 @@ pub mod clock; pub mod control; +pub mod pnp; pub mod protocol; pub mod thread; pub mod uart; @@ -8,5 +9,11 @@ pub use clock::{ Bzm2ClockController, Bzm2ClockDebugReport, Bzm2ClockError, Bzm2Dll, Bzm2DllConfig, Bzm2DllStatus, Bzm2Pll, Bzm2PllConfig, Bzm2PllStatus, }; +pub use pnp::{ + Bzm2AsicMeasurement, Bzm2AsicPlan, Bzm2AsicTopology, Bzm2BoardCalibrationInput, + Bzm2CalibrationConstraints, Bzm2CalibrationMode, Bzm2CalibrationPlan, Bzm2CalibrationPlanner, + Bzm2CalibrationSweepRequest, Bzm2DomainMeasurement, Bzm2DomainPlan, Bzm2OperatingClass, + Bzm2PerformanceMode, Bzm2SavedOperatingPoint, Bzm2VoltageDomain, +}; pub use thread::{Bzm2Thread, Bzm2ThreadConfig, Bzm2ThreadHandle}; pub use uart::{BROADCAST_GROUP_ASIC, Bzm2UartController, Bzm2UartError, NOTCH_REG}; diff --git a/mujina-miner/src/asic/bzm2/pnp.rs b/mujina-miner/src/asic/bzm2/pnp.rs new file mode 100644 index 00000000..78147665 --- /dev/null +++ b/mujina-miner/src/asic/bzm2/pnp.rs @@ -0,0 +1,847 @@ +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +const CALI_VOLTAGE_MV: u32 = 50; +const CALI_FREQ_MHZ: f32 = 25.0; +const CALI_PASS_RATE_STEP: f32 = 0.0025; +const TARGET_VOLTAGE_MAX_MV: u32 = 21_000; +const TARGET_VOLTAGE_MIN_MV: u32 = 16_950; +const TARGET_FREQ_MAX_MHZ: f32 = 2_000.0; +const TARGET_FREQ_MIN_MHZ: f32 = 800.0; +const TARGET_FREQ_HIGH_PLUS_MHZ: f32 = 1_312.5; +const TARGET_FREQ_HIGH_MHZ: f32 = 1_200.0; +const TARGET_FREQ_BALANCED_MHZ: f32 = 1_150.0; +const TARGET_FREQ_LOW_MHZ: f32 = 1_000.0; +const FREQ_RANGE_MHZ: f32 = 100.0; +const MAX_FREQ_RANGE_MHZ: f32 = 150.0; +const ACCEPT_RATIO_BAND_MAX_THROUGHPUT: f32 = 0.02; +const ACCEPT_RATIO_BAND_STANDARD: f32 = 0.02; +const ACCEPT_RATIO_BAND_EFFICIENCY: f32 = 0.02; +const MIN_ACCEPT_RATIO: f32 = 0.90; +const DESIRED_ACCEPT_RATIO_MAX_THROUGHPUT: f32 = 0.975; +const DESIRED_ACCEPT_RATIO_STANDARD: f32 = 0.975; +const DESIRED_ACCEPT_RATIO_EFFICIENCY: f32 = 0.975; +const STARTUP_VOLTAGE_BIAS_MV: i32 = 50; +const SITE_TEMP_COLD_SOAK_C: f32 = -2.5; +const SITE_TEMP_COOL_C: f32 = 7.5; +const SITE_TEMP_NOMINAL_C: f32 = 17.5; +const SITE_TEMP_WARM_C: f32 = 27.5; +const DEFAULT_THERMAL_THRESHOLD_C: f32 = 100.0; +const DEFAULT_AVG_THERMAL_THRESHOLD_C: f32 = 85.0; +const DEFAULT_CURRENT_THRESHOLD_A: f32 = 260.0; +const DEFAULT_POWER_THRESHOLD_W: f32 = 4_900.0; +const DEFAULT_FREQ_INCREASE_RATIO_HIGH: f32 = 0.28; +const DEFAULT_FREQ_INCREASE_RATIO_LOW: f32 = 0.24; +const DEFAULT_RECALIBRATE_THROUGHPUT_RATIO: f32 = 0.80; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum Bzm2PerformanceMode { + MaxThroughput, + Standard, + Efficiency, +} + +impl Bzm2PerformanceMode { + fn pass_rate_range(self) -> f32 { + match self { + Self::MaxThroughput => ACCEPT_RATIO_BAND_MAX_THROUGHPUT, + Self::Standard => ACCEPT_RATIO_BAND_STANDARD, + Self::Efficiency => ACCEPT_RATIO_BAND_EFFICIENCY, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Bzm2OperatingClass { + Generic, + EarlyValidation, + ProductionValidation, + StackTunedA, + StackTunedB, + ExtendedHeadroom, + ExtendedHeadroomB, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Bzm2CalibrationMode { + pub sweep_strategy: bool, + pub sweep_voltage: bool, + pub sweep_frequency: bool, + pub sweep_pass_rate: bool, +} + +#[derive(Debug, Clone)] +pub struct Bzm2CalibrationConstraints { + pub max_power_w: f32, + pub max_current_a: f32, + pub max_thermal_c: f32, + pub max_avg_thermal_c: f32, + pub freq_range_mhz: f32, + pub max_freq_range_mhz: f32, + pub recalibrate_throughput_ratio: f32, +} + +impl Default for Bzm2CalibrationConstraints { + fn default() -> Self { + Self { + max_power_w: DEFAULT_POWER_THRESHOLD_W, + max_current_a: DEFAULT_CURRENT_THRESHOLD_A, + max_thermal_c: DEFAULT_THERMAL_THRESHOLD_C, + max_avg_thermal_c: DEFAULT_AVG_THERMAL_THRESHOLD_C, + freq_range_mhz: FREQ_RANGE_MHZ, + max_freq_range_mhz: MAX_FREQ_RANGE_MHZ, + recalibrate_throughput_ratio: DEFAULT_RECALIBRATE_THROUGHPUT_RATIO, + } + } +} + +#[derive(Debug, Clone)] +pub struct Bzm2CalibrationSweepRequest { + pub operating_class: Bzm2OperatingClass, + pub target_mode: Bzm2PerformanceMode, + pub mode: Bzm2CalibrationMode, + pub voltage_steps: u8, + pub frequency_steps: u8, + pub pass_rate_steps: u8, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Bzm2SavedOperatingPoint { + pub board_voltage_mv: u32, + pub board_throughput_ths: f32, + pub per_asic_pll_mhz: BTreeMap, +} + +#[derive(Debug, Clone)] +pub struct Bzm2AsicTopology { + pub asic_id: u16, + pub domain_id: u16, + pub pll_count: usize, + pub alive: bool, +} + +#[derive(Debug, Clone)] +pub struct Bzm2VoltageDomain { + pub domain_id: u16, + pub asic_ids: Vec, + pub voltage_offset_mv: i32, + pub max_power_w: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct Bzm2DomainMeasurement { + pub domain_id: u16, + pub measured_voltage_mv: Option, + pub measured_power_w: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct Bzm2AsicMeasurement { + pub asic_id: u16, + pub temperature_c: Option, + pub throughput_ths: Option, + pub average_pass_rate: Option, + pub pll_pass_rates: [Option; 2], +} + +#[derive(Debug, Clone)] +pub struct Bzm2BoardCalibrationInput { + pub operating_class: Bzm2OperatingClass, + pub site_temp_c: f32, + pub target_mode: Bzm2PerformanceMode, + pub mode: Bzm2CalibrationMode, + pub per_stack_clocking: bool, + pub voltage_domains: Vec, + pub asics: Vec, + pub saved_operating_point: Option, + pub domain_measurements: Vec, + pub asic_measurements: Vec, + pub constraints: Bzm2CalibrationConstraints, + pub force_retune: bool, +} + +#[derive(Debug, Clone)] +pub struct Bzm2DomainPlan { + pub domain_id: u16, + pub voltage_mv: u32, + pub average_frequency_mhz: f32, + pub guarded: bool, + pub notes: Vec, +} + +#[derive(Debug, Clone)] +pub struct Bzm2AsicPlan { + pub asic_id: u16, + pub domain_id: u16, + pub pll_frequencies_mhz: [f32; 2], + pub notes: Vec, +} + +#[derive(Debug, Clone)] +pub struct Bzm2CalibrationPlan { + pub reuse_saved_operating_point: bool, + pub needs_retune: bool, + pub desired_voltage_mv: u32, + pub desired_clock_mhz: f32, + pub desired_accept_ratio: f32, + pub initial_voltage_mv: u32, + pub initial_frequency_mhz: f32, + pub freq_increase_threshold_mhz: f32, + pub search_space: Vec, + pub domain_plans: Vec, + pub asic_plans: Vec, + pub notes: Vec, +} + +#[derive(Debug, Clone)] +pub struct Bzm2ParameterSet { + pub mode: Bzm2PerformanceMode, + pub desired_voltage_mv: u32, + pub desired_clock_mhz: f32, + pub desired_accept_ratio: f32, +} + +#[derive(Debug, Default)] +pub struct Bzm2CalibrationPlanner; + +impl Bzm2CalibrationPlanner { + pub fn build_search_space( + &self, + request: &Bzm2CalibrationSweepRequest, + ) -> Vec { + let modes = if request.mode.sweep_strategy { + vec![ + Bzm2PerformanceMode::MaxThroughput, + Bzm2PerformanceMode::Standard, + Bzm2PerformanceMode::Efficiency, + ] + } else { + vec![request.target_mode] + }; + + let mut parameters = Vec::new(); + for mode in modes { + let target = operating_targets(request.operating_class, mode); + let voltage_offsets = build_offsets(request.mode.sweep_voltage, request.voltage_steps); + let frequency_offsets = + build_frequency_offsets(request.mode.sweep_frequency, request.frequency_steps); + let pass_rate_offsets = + build_pass_rate_offsets(request.mode.sweep_pass_rate, request.pass_rate_steps); + + for voltage_offset in &voltage_offsets { + for frequency_offset in &frequency_offsets { + for pass_rate_offset in &pass_rate_offsets { + parameters.push(Bzm2ParameterSet { + mode, + desired_voltage_mv: clamp_voltage(apply_i32( + target.voltage_mv, + *voltage_offset, + )), + desired_clock_mhz: clamp_frequency( + target.frequency_mhz + *frequency_offset, + ), + desired_accept_ratio: clamp_pass_rate( + target.pass_rate + *pass_rate_offset, + ), + }); + } + } + } + } + + parameters.sort_by(|a, b| { + a.mode + .cmp(&b.mode) + .then(a.desired_voltage_mv.cmp(&b.desired_voltage_mv)) + .then_with(|| a.desired_clock_mhz.total_cmp(&b.desired_clock_mhz)) + .then_with(|| a.desired_accept_ratio.total_cmp(&b.desired_accept_ratio)) + }); + parameters.dedup_by(|a, b| { + a.mode == b.mode + && a.desired_voltage_mv == b.desired_voltage_mv + && (a.desired_clock_mhz - b.desired_clock_mhz).abs() < f32::EPSILON + && (a.desired_accept_ratio - b.desired_accept_ratio).abs() < f32::EPSILON + }); + parameters + } + + pub fn plan(&self, input: &Bzm2BoardCalibrationInput) -> Bzm2CalibrationPlan { + let target = operating_targets(input.operating_class, input.target_mode); + let search_space = self.build_search_space(&Bzm2CalibrationSweepRequest { + operating_class: input.operating_class, + target_mode: input.target_mode, + mode: input.mode, + voltage_steps: 4, + frequency_steps: 4, + pass_rate_steps: 2, + }); + + let current_throughput = input + .asic_measurements + .iter() + .filter_map(|asic| asic.throughput_ths) + .sum::(); + let has_live_throughput = input + .asic_measurements + .iter() + .any(|asic| asic.throughput_ths.is_some()); + let reuse_saved_operating_point = + input.saved_operating_point.as_ref().is_some_and(|stored| { + !input.force_retune + && stored.per_asic_pll_mhz.len() + == input.asics.iter().filter(|asic| asic.alive).count() + && (!has_live_throughput + || current_throughput + >= stored.board_throughput_ths + * input.constraints.recalibrate_throughput_ratio) + }); + let needs_retune = input.force_retune + || (has_live_throughput + && input.saved_operating_point.as_ref().is_some_and(|stored| { + current_throughput + < stored.board_throughput_ths + * input.constraints.recalibrate_throughput_ratio + })); + + let (initial_voltage_mv, freq_increase_threshold_mhz) = initial_voltage_and_threshold( + target.voltage_mv, + input.site_temp_c, + input.mode.sweep_frequency, + ); + let initial_frequency_mhz = clamp_frequency( + (target.frequency_mhz - input.constraints.freq_range_mhz).max(TARGET_FREQ_MIN_MHZ), + ); + + let domain_measurements: BTreeMap = input + .domain_measurements + .iter() + .map(|measurement| (measurement.domain_id, measurement)) + .collect(); + let asic_measurements: BTreeMap = input + .asic_measurements + .iter() + .map(|measurement| (measurement.asic_id, measurement)) + .collect(); + + let mut domain_plans = Vec::new(); + let mut asic_plans = Vec::new(); + let mut notes = Vec::new(); + + for domain in &input.voltage_domains { + let domain_target_voltage = + clamp_voltage(apply_i32(initial_voltage_mv, domain.voltage_offset_mv)); + let domain_power = domain_measurements + .get(&domain.domain_id) + .and_then(|measurement| measurement.measured_power_w) + .unwrap_or_default(); + let domain_guarded = domain.max_power_w.is_some_and(|limit| domain_power > limit) + || domain_power > input.constraints.max_power_w; + + let domain_asics: Vec<&Bzm2AsicTopology> = input + .asics + .iter() + .filter(|asic| asic.alive && asic.domain_id == domain.domain_id) + .collect(); + let domain_avg_temp = average( + domain_asics + .iter() + .filter_map(|asic| asic_measurements.get(&asic.asic_id)) + .filter_map(|measurement| measurement.temperature_c), + ); + let domain_avg_pass_rate = average( + domain_asics + .iter() + .filter_map(|asic| asic_measurements.get(&asic.asic_id)) + .filter_map(|measurement| measurement.average_pass_rate), + ); + + let mut domain_frequency = initial_frequency_mhz; + let mut domain_notes = Vec::new(); + if let Some(pass_rate) = domain_avg_pass_rate { + if pass_rate >= target.pass_rate && !domain_guarded { + domain_frequency = clamp_frequency( + target + .frequency_mhz + .min(initial_frequency_mhz + input.constraints.max_freq_range_mhz), + ); + domain_notes.push(format!( + "domain average pass rate {:.2}% supports target frequency", + pass_rate * 100.0 + )); + } else { + domain_notes.push(format!( + "domain average pass rate {:.2}% below target {:.2}%", + pass_rate * 100.0, + target.pass_rate * 100.0 + )); + } + } + if let Some(temp) = domain_avg_temp { + if temp >= input.constraints.max_avg_thermal_c { + domain_frequency = clamp_frequency(domain_frequency - CALI_FREQ_MHZ); + domain_notes.push(format!( + "domain average temperature {:.1}C triggered thermal guard", + temp + )); + } + } + if domain_guarded { + domain_frequency = clamp_frequency(domain_frequency - CALI_FREQ_MHZ); + domain_notes.push("domain power guard active".into()); + } + + domain_plans.push(Bzm2DomainPlan { + domain_id: domain.domain_id, + voltage_mv: domain_target_voltage, + average_frequency_mhz: domain_frequency, + guarded: domain_guarded, + notes: domain_notes.clone(), + }); + + for asic in domain_asics { + let measurement = asic_measurements.get(&asic.asic_id).copied(); + let mut pll_frequencies = [domain_frequency; 2]; + let mut asic_notes = Vec::new(); + + if reuse_saved_operating_point { + if let Some(stored) = input + .saved_operating_point + .as_ref() + .and_then(|stored| stored.per_asic_pll_mhz.get(&asic.asic_id)) + { + pll_frequencies = *stored; + asic_notes.push("reusing stored per-ASIC calibration".into()); + } + } else if let Some(measurement) = measurement { + if let Some(temp) = measurement.temperature_c { + if temp >= input.constraints.max_thermal_c { + pll_frequencies = + [clamp_frequency(domain_frequency - CALI_FREQ_MHZ); 2]; + asic_notes.push(format!( + "ASIC temperature {:.1}C exceeded thermal threshold", + temp + )); + } + } + + if input.per_stack_clocking { + for (pll_index, pass_rate) in measurement.pll_pass_rates.iter().enumerate() + { + if let Some(pass_rate) = pass_rate { + let low = target.pass_rate - input.target_mode.pass_rate_range(); + let high = target.pass_rate + input.target_mode.pass_rate_range(); + if *pass_rate < low { + pll_frequencies[pll_index] = + clamp_frequency(pll_frequencies[pll_index] - CALI_FREQ_MHZ); + asic_notes.push(format!( + "PLL {} pass rate {:.2}% below window", + pll_index, + pass_rate * 100.0 + )); + } else if *pass_rate > high && !domain_guarded { + pll_frequencies[pll_index] = clamp_frequency( + pll_frequencies[pll_index] + CALI_FREQ_MHZ / 2.0, + ); + asic_notes.push(format!( + "PLL {} pass rate {:.2}% above window", + pll_index, + pass_rate * 100.0 + )); + } + } + } + } else if let Some(pass_rate) = measurement.average_pass_rate { + if pass_rate < target.pass_rate - input.target_mode.pass_rate_range() { + pll_frequencies = + [clamp_frequency(domain_frequency - CALI_FREQ_MHZ); 2]; + asic_notes.push(format!( + "ASIC pass rate {:.2}% below target window", + pass_rate * 100.0 + )); + } + } + } + + if domain_guarded { + asic_notes.push("bounded by domain power guard".into()); + } + + asic_plans.push(Bzm2AsicPlan { + asic_id: asic.asic_id, + domain_id: asic.domain_id, + pll_frequencies_mhz: pll_frequencies, + notes: asic_notes, + }); + } + } + + if reuse_saved_operating_point { + notes.push("saved operating point is consistent with current throughput".into()); + } else if needs_retune { + notes.push( + "saved operating point is missing or underperforming; full retune required".into(), + ); + } else { + notes.push("building fresh domain-aware calibration plan".into()); + } + + if input.voltage_domains.len() > 1 { + notes.push("domain-first planning enabled for multi-domain hardware".into()); + } + if input.asics.len() >= 100 { + notes.push("planner uses one domain aggregation pass and one ASIC tuning pass".into()); + } + + Bzm2CalibrationPlan { + reuse_saved_operating_point, + needs_retune, + desired_voltage_mv: target.voltage_mv, + desired_clock_mhz: target.frequency_mhz, + desired_accept_ratio: target.pass_rate, + initial_voltage_mv, + initial_frequency_mhz, + freq_increase_threshold_mhz, + search_space, + domain_plans, + asic_plans, + notes, + } + } +} + +#[derive(Debug, Clone, Copy)] +struct OperatingTarget { + voltage_mv: u32, + frequency_mhz: f32, + pass_rate: f32, +} + +fn operating_targets( + operating_class: Bzm2OperatingClass, + mode: Bzm2PerformanceMode, +) -> OperatingTarget { + let (high_voltage, balanced_voltage, low_voltage, high_freq) = match operating_class { + Bzm2OperatingClass::Generic => (17_600, 17_500, 17_150, TARGET_FREQ_HIGH_MHZ), + Bzm2OperatingClass::EarlyValidation => (17_800, 17_700, 17_350, TARGET_FREQ_HIGH_MHZ), + Bzm2OperatingClass::ProductionValidation => (17_550, 17_450, 17_100, TARGET_FREQ_HIGH_MHZ), + Bzm2OperatingClass::StackTunedA => (17_300, 17_200, 16_850, TARGET_FREQ_HIGH_MHZ), + Bzm2OperatingClass::StackTunedB => (17_600, 17_500, 17_150, TARGET_FREQ_HIGH_MHZ), + Bzm2OperatingClass::ExtendedHeadroom => (17_900, 17_450, 17_100, TARGET_FREQ_HIGH_PLUS_MHZ), + Bzm2OperatingClass::ExtendedHeadroomB => { + (18_050, 17_550, 17_150, TARGET_FREQ_HIGH_PLUS_MHZ) + } + }; + + match mode { + Bzm2PerformanceMode::MaxThroughput => OperatingTarget { + voltage_mv: high_voltage, + frequency_mhz: high_freq, + pass_rate: DESIRED_ACCEPT_RATIO_MAX_THROUGHPUT, + }, + Bzm2PerformanceMode::Standard => OperatingTarget { + voltage_mv: balanced_voltage, + frequency_mhz: TARGET_FREQ_BALANCED_MHZ, + pass_rate: DESIRED_ACCEPT_RATIO_STANDARD, + }, + Bzm2PerformanceMode::Efficiency => OperatingTarget { + voltage_mv: low_voltage, + frequency_mhz: TARGET_FREQ_LOW_MHZ, + pass_rate: DESIRED_ACCEPT_RATIO_EFFICIENCY, + }, + } +} + +fn build_offsets(enabled: bool, steps: u8) -> Vec { + if !enabled { + return vec![0]; + } + + let steps = steps.min(20) as i32; + (-steps..=steps) + .map(|step| step * CALI_VOLTAGE_MV as i32) + .collect() +} + +fn build_frequency_offsets(enabled: bool, steps: u8) -> Vec { + if !enabled { + return vec![0.0]; + } + + let steps = steps.min(16) as i32; + (-steps..=steps) + .map(|step| step as f32 * CALI_FREQ_MHZ) + .collect() +} + +fn build_pass_rate_offsets(enabled: bool, steps: u8) -> Vec { + if !enabled { + return vec![0.0]; + } + + let steps = steps.min(4) as i32; + (-steps..=steps) + .map(|step| step as f32 * CALI_PASS_RATE_STEP) + .collect() +} + +fn initial_voltage_and_threshold( + desired_voltage_mv: u32, + site_temp_c: f32, + frequency_mode: bool, +) -> (u32, f32) { + let (offset, ratio) = if site_temp_c < SITE_TEMP_COLD_SOAK_C { + (STARTUP_VOLTAGE_BIAS_MV * 2, DEFAULT_FREQ_INCREASE_RATIO_LOW) + } else if site_temp_c < SITE_TEMP_COOL_C { + (STARTUP_VOLTAGE_BIAS_MV, DEFAULT_FREQ_INCREASE_RATIO_HIGH) + } else if site_temp_c < SITE_TEMP_NOMINAL_C { + (0, DEFAULT_FREQ_INCREASE_RATIO_HIGH) + } else if site_temp_c < SITE_TEMP_WARM_C { + (-STARTUP_VOLTAGE_BIAS_MV, DEFAULT_FREQ_INCREASE_RATIO_HIGH) + } else { + ( + -STARTUP_VOLTAGE_BIAS_MV * 2, + DEFAULT_FREQ_INCREASE_RATIO_LOW, + ) + }; + + let threshold = if frequency_mode { + CALI_FREQ_MHZ * DEFAULT_FREQ_INCREASE_RATIO_LOW + } else { + CALI_FREQ_MHZ * ratio + }; + + ( + clamp_voltage(apply_i32(desired_voltage_mv, offset)), + threshold, + ) +} + +fn clamp_voltage(voltage_mv: u32) -> u32 { + voltage_mv.clamp(TARGET_VOLTAGE_MIN_MV, TARGET_VOLTAGE_MAX_MV) +} + +fn clamp_frequency(frequency_mhz: f32) -> f32 { + frequency_mhz.clamp(TARGET_FREQ_MIN_MHZ, TARGET_FREQ_MAX_MHZ) +} + +fn clamp_pass_rate(pass_rate: f32) -> f32 { + pass_rate.clamp(MIN_ACCEPT_RATIO, DESIRED_ACCEPT_RATIO_MAX_THROUGHPUT) +} + +fn apply_i32(value: u32, offset: i32) -> u32 { + if offset >= 0 { + value.saturating_add(offset as u32) + } else { + value.saturating_sub(offset.unsigned_abs()) + } +} + +fn average(values: impl Iterator) -> Option { + let mut total = 0.0; + let mut count = 0usize; + for value in values { + total += value; + count += 1; + } + (count > 0).then_some(total / count as f32) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn search_space_expands_requested_axes() { + let planner = Bzm2CalibrationPlanner; + let parameters = planner.build_search_space(&Bzm2CalibrationSweepRequest { + operating_class: Bzm2OperatingClass::Generic, + target_mode: Bzm2PerformanceMode::Standard, + mode: Bzm2CalibrationMode { + sweep_strategy: true, + sweep_voltage: true, + sweep_frequency: true, + sweep_pass_rate: true, + }, + voltage_steps: 1, + frequency_steps: 1, + pass_rate_steps: 1, + }); + + assert!(parameters.len() > 20); + assert!( + parameters + .iter() + .any(|p| p.mode == Bzm2PerformanceMode::MaxThroughput) + ); + assert!(parameters.iter().any(|p| p.desired_voltage_mv < 17_500)); + assert!( + parameters + .iter() + .any(|p| p.desired_clock_mhz > TARGET_FREQ_BALANCED_MHZ) + ); + } + + #[test] + fn single_asic_plan_prefers_saved_operating_point_when_consistent() { + let planner = Bzm2CalibrationPlanner; + let mut stored = BTreeMap::new(); + stored.insert(0, [1_075.0, 1_075.0]); + let plan = planner.plan(&Bzm2BoardCalibrationInput { + operating_class: Bzm2OperatingClass::Generic, + site_temp_c: 15.0, + target_mode: Bzm2PerformanceMode::Standard, + mode: Bzm2CalibrationMode::default(), + per_stack_clocking: false, + voltage_domains: vec![Bzm2VoltageDomain { + domain_id: 0, + asic_ids: vec![0], + voltage_offset_mv: 0, + max_power_w: None, + }], + asics: vec![Bzm2AsicTopology { + asic_id: 0, + domain_id: 0, + pll_count: 2, + alive: true, + }], + saved_operating_point: Some(Bzm2SavedOperatingPoint { + board_voltage_mv: 17_500, + board_throughput_ths: 42.0, + per_asic_pll_mhz: stored, + }), + domain_measurements: vec![Bzm2DomainMeasurement { + domain_id: 0, + measured_voltage_mv: Some(17_480), + measured_power_w: Some(320.0), + }], + asic_measurements: vec![Bzm2AsicMeasurement { + asic_id: 0, + temperature_c: Some(72.0), + throughput_ths: Some(40.0), + average_pass_rate: Some(0.98), + pll_pass_rates: [Some(0.98), Some(0.98)], + }], + constraints: Bzm2CalibrationConstraints::default(), + force_retune: false, + }); + + assert!(plan.reuse_saved_operating_point); + assert!(!plan.needs_retune); + assert_eq!(plan.asic_plans[0].pll_frequencies_mhz, [1_075.0, 1_075.0]); + } + + #[test] + fn planner_requests_retune_when_throughput_drops() { + let planner = Bzm2CalibrationPlanner; + let mut stored = BTreeMap::new(); + stored.insert(0, [1_150.0, 1_150.0]); + let plan = planner.plan(&Bzm2BoardCalibrationInput { + operating_class: Bzm2OperatingClass::Generic, + site_temp_c: 20.0, + target_mode: Bzm2PerformanceMode::Standard, + mode: Bzm2CalibrationMode::default(), + per_stack_clocking: false, + voltage_domains: vec![Bzm2VoltageDomain { + domain_id: 0, + asic_ids: vec![0], + voltage_offset_mv: 0, + max_power_w: None, + }], + asics: vec![Bzm2AsicTopology { + asic_id: 0, + domain_id: 0, + pll_count: 2, + alive: true, + }], + saved_operating_point: Some(Bzm2SavedOperatingPoint { + board_voltage_mv: 17_500, + board_throughput_ths: 50.0, + per_asic_pll_mhz: stored, + }), + domain_measurements: vec![], + asic_measurements: vec![Bzm2AsicMeasurement { + asic_id: 0, + temperature_c: Some(74.0), + throughput_ths: Some(20.0), + average_pass_rate: Some(0.94), + pll_pass_rates: [Some(0.94), Some(0.94)], + }], + constraints: Bzm2CalibrationConstraints::default(), + force_retune: false, + }); + + assert!(!plan.reuse_saved_operating_point); + assert!(plan.needs_retune); + } + + #[test] + fn multi_domain_plan_scales_to_large_topology() { + let planner = Bzm2CalibrationPlanner; + let domains: Vec = (0..25) + .map(|domain_id| Bzm2VoltageDomain { + domain_id, + asic_ids: (0..4).map(|offset| domain_id * 4 + offset).collect(), + voltage_offset_mv: if domain_id % 2 == 0 { 0 } else { 25 }, + max_power_w: Some(450.0), + }) + .collect(); + let asics: Vec = (0..100) + .map(|asic_id| Bzm2AsicTopology { + asic_id, + domain_id: asic_id / 4, + pll_count: 2, + alive: true, + }) + .collect(); + let domain_measurements: Vec = (0..25) + .map(|domain_id| Bzm2DomainMeasurement { + domain_id, + measured_voltage_mv: Some(17_450), + measured_power_w: Some(if domain_id == 3 { 500.0 } else { 300.0 }), + }) + .collect(); + let asic_measurements: Vec = (0..100) + .map(|asic_id| Bzm2AsicMeasurement { + asic_id, + temperature_c: Some(if asic_id == 13 { 101.0 } else { 74.0 }), + throughput_ths: Some(0.4), + average_pass_rate: Some(if asic_id % 9 == 0 { 0.93 } else { 0.98 }), + pll_pass_rates: [Some(0.97), Some(0.98)], + }) + .collect(); + + let plan = planner.plan(&Bzm2BoardCalibrationInput { + operating_class: Bzm2OperatingClass::ExtendedHeadroom, + site_temp_c: 10.0, + target_mode: Bzm2PerformanceMode::Standard, + mode: Bzm2CalibrationMode::default(), + per_stack_clocking: true, + voltage_domains: domains, + asics, + saved_operating_point: None, + domain_measurements, + asic_measurements, + constraints: Bzm2CalibrationConstraints::default(), + force_retune: false, + }); + + assert_eq!(plan.domain_plans.len(), 25); + assert_eq!(plan.asic_plans.len(), 100); + assert!( + plan.domain_plans + .iter() + .find(|domain| domain.domain_id == 3) + .unwrap() + .guarded + ); + assert!( + plan.asic_plans + .iter() + .find(|asic| asic.asic_id == 13) + .unwrap() + .pll_frequencies_mhz[0] + < plan.initial_frequency_mhz + 1.0 + ); + assert!(plan.notes.iter().any(|note| note.contains("domain-first"))); + } +} diff --git a/mujina-miner/src/asic/mod.rs b/mujina-miner/src/asic/mod.rs index 02322036..4cb84451 100644 --- a/mujina-miner/src/asic/mod.rs +++ b/mujina-miner/src/asic/mod.rs @@ -1,5 +1,5 @@ -pub mod bzm2; pub mod bm13xx; +pub mod bzm2; pub mod hash_thread; /// Information about a chip diff --git a/mujina-miner/src/backplane.rs b/mujina-miner/src/backplane.rs index f6e9c566..b9bbcb26 100644 --- a/mujina-miner/src/backplane.rs +++ b/mujina-miner/src/backplane.rs @@ -386,7 +386,9 @@ impl Backplane { debug!(board = %model, id = %device_id, "Shutting down virtual board"); match board.shutdown().await { - Ok(()) => info!(board = %model, id = %device_id, "Virtual board disconnected"), + Ok(()) => { + info!(board = %model, id = %device_id, "Virtual board disconnected") + } Err(e) => error!( board = %model, id = %device_id, diff --git a/mujina-miner/src/bin/bzm2-debug.rs b/mujina-miner/src/bin/bzm2-debug.rs index 1c642b62..40086df8 100644 --- a/mujina-miner/src/bin/bzm2-debug.rs +++ b/mujina-miner/src/bin/bzm2-debug.rs @@ -1,13 +1,27 @@ +use std::collections::BTreeMap; use std::env; +use std::ops::RangeInclusive; use std::time::Duration; use anyhow::{Context, Result, bail}; +use mujina_miner::asic::bzm2::protocol::{ + DtsVsGeneration, ENGINE_REG_TARGET, ENGINE_REG_TIMESTAMP_COUNT, ENGINE_REG_ZEROS_TO_FIND, + TdmFrame, TdmFrameParser, default_engine_coordinates, encode_read_register, + encode_read_result_command, logical_engine_address, +}; use mujina_miner::asic::bzm2::{ BROADCAST_GROUP_ASIC, Bzm2ClockController, Bzm2Dll, Bzm2Pll, Bzm2UartController, NOTCH_REG, }; -use mujina_miner::transport::SerialStream; +use mujina_miner::transport::{SerialReader, SerialStream, SerialWriter}; +use sha2::{Digest, Sha256}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::time::{Instant, timeout}; const DEFAULT_BAUD: u32 = 5_000_000; +const DEFAULT_WATCH_POLL_MS: u64 = 100; +const DEFAULT_BROADCAST_READ_TIMEOUT_MS: u64 = 2_000; +const LOCAL_REG_UART_TDM_CTL: u8 = 0x07; +const MAX_EFFBST_SUBJOBS: u8 = 4; #[tokio::main] async fn main() -> Result<()> { @@ -23,12 +37,25 @@ async fn main() -> Result<()> { "uart-multicast-write" => cmd_uart_multicast_write(&args[2..]).await, "uart-noop" => cmd_uart_noop(&args[2..]).await, "uart-loopback" => cmd_uart_loopback(&args[2..]).await, + "uart-read-result" => cmd_uart_read_result(&args[2..]).await, + "noop-scan" => cmd_noop_scan(&args[2..]).await, + "loopback-scan" => cmd_loopback_scan(&args[2..]).await, + "tdm-enable" => cmd_tdm_enable(&args[2..]).await, + "tdm-disable" => cmd_tdm_disable(&args[2..]).await, + "tdm-watch" => cmd_tdm_watch(&args[2..]).await, + "tdm-broadcast-read-watch" => cmd_tdm_broadcast_read_watch(&args[2..]).await, + "engine-target-all" => cmd_engine_target_all(&args[2..]).await, + "engine-timestamp-all" => cmd_engine_timestamp_all(&args[2..]).await, + "engine-zeros-all" => cmd_engine_zeros_all(&args[2..]).await, + "job-grid" => cmd_job_grid(&args[2..]).await, + "job-grid-watch" => cmd_job_grid_watch(&args[2..]).await, + "job-grid-2phase-watch" => cmd_job_grid_2phase_watch(&args[2..]).await, "clock-report" => cmd_clock_report(&args[2..]).await, "pll-set" => cmd_pll_set(&args[2..]).await, "dll-set" => cmd_dll_set(&args[2..]).await, - other => { - bail!("unknown command: {other}"); - } + "pll-broadcast-lock-check" => cmd_pll_broadcast_lock_check(&args[2..]).await, + "dll-broadcast-lock-check" => cmd_dll_broadcast_lock_check(&args[2..]).await, + other => bail!("unknown command: {other}"), } } @@ -43,16 +70,48 @@ fn print_usage() { ); eprintln!(" uart-noop [baud]"); eprintln!(" uart-loopback [baud]"); + eprintln!(" uart-read-result [baud]"); + eprintln!(" noop-scan [baud]"); + eprintln!(" loopback-scan [baud]"); + eprintln!(); + eprintln!("TDM control and observation:"); + eprintln!(" tdm-enable [baud]"); + eprintln!(" tdm-disable [baud]"); + eprintln!(" tdm-watch [baud]"); + eprintln!( + " tdm-broadcast-read-watch [baud]" + ); + eprintln!(); + eprintln!("Engine-wide validation helpers:"); + eprintln!(" engine-target-all [baud]"); + eprintln!(" engine-timestamp-all [baud]"); + eprintln!(" engine-zeros-all [baud]"); + eprintln!(" job-grid [baud]"); + eprintln!( + " job-grid-watch [baud]" + ); + eprintln!( + " job-grid-2phase-watch [baud]" + ); eprintln!(); eprintln!("Clock diagnostics:"); eprintln!(" clock-report [baud]"); eprintln!(" pll-set [baud]"); eprintln!(" dll-set [baud]"); + eprintln!( + " pll-broadcast-lock-check [baud]" + ); + eprintln!( + " dll-broadcast-lock-check [baud]" + ); eprintln!(); eprintln!("Addressing examples:"); eprintln!(" unicast : uart-write /dev/ttyUSB0 2 notch 0x12 01000000"); eprintln!(" broadcast : uart-write /dev/ttyUSB0 broadcast notch 0x12 01000000"); eprintln!(" multicast : uart-multicast-write /dev/ttyUSB0 2 7 0x49 3c"); + eprintln!(" scan : noop-scan /dev/ttyUSB0 0 15"); + eprintln!(" TDM watch : tdm-watch /dev/ttyUSB0 gen2 5"); + eprintln!(" grid job : job-grid-watch /dev/ttyUSB0 2 0 1700000000 60 5 gen2"); } fn parse_baud(raw: Option<&String>) -> Result { @@ -62,6 +121,19 @@ fn parse_baud(raw: Option<&String>) -> Result { } } +fn parse_watch_duration(raw: &str) -> Result { + let seconds = parse_f32(raw)?; + if seconds <= 0.0 { + bail!("watch duration must be greater than zero"); + } + Ok(Duration::from_secs_f32(seconds)) +} + +fn parse_dts_generation(raw: &str) -> Result { + DtsVsGeneration::from_env_value(raw) + .ok_or_else(|| anyhow::anyhow!("invalid DTS/VS generation: {raw}")) +} + fn parse_u8(raw: &str) -> Result { Ok(parse_u32(raw)? as u8) } @@ -101,6 +173,15 @@ fn parse_asic_or_broadcast(raw: &str) -> Result { } } +fn parse_asic_range(start: &str, end: &str) -> Result> { + let start = parse_u8(start)?; + let end = parse_u8(end)?; + if start > end { + bail!("asic-start must be less than or equal to asic-end"); + } + Ok(start..=end) +} + fn parse_engine_address(raw: &str) -> Result { if raw.eq_ignore_ascii_case("notch") { Ok(NOTCH_REG) @@ -125,6 +206,14 @@ fn parse_dll(raw: &str) -> Result { } } +fn parse_zeros_to_find(raw: &str) -> Result { + let zeros = parse_u8(raw)?; + if !(32..=64).contains(&zeros) { + bail!("zeros-to-find must be in the inclusive range 32..=64"); + } + Ok(zeros) +} + fn open_uart(serial: &str, baud: u32) -> Result { let stream = SerialStream::new(serial, baud) .with_context(|| format!("failed to open serial port {serial} at {baud} baud"))?; @@ -139,6 +228,13 @@ fn open_clock(serial: &str, baud: u32) -> Result { Ok(Bzm2ClockController::new(reader, writer)) } +fn open_raw(serial: &str, baud: u32) -> Result<(SerialReader, SerialWriter)> { + let stream = SerialStream::new(serial, baud) + .with_context(|| format!("failed to open serial port {serial} at {baud} baud"))?; + let (reader, writer, _) = stream.split(); + Ok((reader, writer)) +} + async fn cmd_uart_read(args: &[String]) -> Result<()> { if args.len() < 5 || args.len() > 6 { bail!("usage: uart-read [baud]"); @@ -215,6 +311,383 @@ async fn cmd_uart_loopback(args: &[String]) -> Result<()> { Ok(()) } +async fn cmd_uart_read_result(args: &[String]) -> Result<()> { + if args.len() < 3 || args.len() > 4 { + bail!("usage: uart-read-result [baud]"); + } + + let asic = parse_u8(&args[1])?; + let generation = parse_dts_generation(&args[2])?; + let baud = parse_baud(args.get(3))?; + let (mut reader, mut writer) = open_raw(&args[0], baud)?; + writer + .write_all(&encode_read_result_command(asic)) + .await + .context("failed to send read-result command")?; + writer + .flush() + .await + .context("failed to flush serial stream")?; + + let mut parser = TdmFrameParser::new(generation); + let frames = read_tdm_frames_once( + &mut reader, + &mut parser, + Duration::from_millis(DEFAULT_BROADCAST_READ_TIMEOUT_MS), + ) + .await?; + + if frames.is_empty() { + bail!("no TDM frame returned for ASIC {asic}"); + } + + for frame in frames { + print_tdm_frame(&frame); + } + + Ok(()) +} + +async fn cmd_noop_scan(args: &[String]) -> Result<()> { + if args.len() < 3 || args.len() > 4 { + bail!("usage: noop-scan [baud]"); + } + + let mut uart = open_uart(&args[0], parse_baud(args.get(3))?)?; + let asics = parse_asic_range(&args[1], &args[2])?; + let mut found = Vec::new(); + + for asic in asics { + match uart.noop(asic).await { + Ok(value) => { + let ascii = String::from_utf8_lossy(&value); + println!("asic {asic}: ascii={ascii} hex={}", hex::encode(value)); + found.push(asic); + } + Err(err) => { + println!("asic {asic}: no response ({err})"); + } + } + } + + println!("responsive_asics={}", found.len()); + if !found.is_empty() { + println!("asic_ids={}", format_asic_ids(&found)); + } + + Ok(()) +} + +async fn cmd_loopback_scan(args: &[String]) -> Result<()> { + if args.len() < 4 || args.len() > 5 { + bail!("usage: loopback-scan [baud]"); + } + + let mut uart = open_uart(&args[0], parse_baud(args.get(4))?)?; + let asics = parse_asic_range(&args[1], &args[2])?; + let payload_len = parse_u8(&args[3])? as usize; + if payload_len == 0 { + bail!("payload-len must be greater than zero"); + } + + for asic in asics { + let payload = synthetic_loopback_payload(asic, payload_len); + let echoed = uart.loopback(asic, &payload).await?; + let matched = payload == echoed; + println!( + "asic {asic}: matched={} payload={} echoed={}", + matched, + hex::encode(&payload), + hex::encode(&echoed) + ); + if !matched { + bail!("loopback mismatch on ASIC {asic}"); + } + } + + Ok(()) +} + +async fn cmd_tdm_enable(args: &[String]) -> Result<()> { + if args.len() < 3 || args.len() > 4 { + bail!("usage: tdm-enable [baud]"); + } + + let mut uart = open_uart(&args[0], parse_baud(args.get(3))?)?; + let prediv = parse_u32(&args[1])?; + let counter = parse_u8(&args[2])?; + let control = encode_tdm_control(prediv, counter, true); + uart.write_local_reg_u32(BROADCAST_GROUP_ASIC, LOCAL_REG_UART_TDM_CTL, control) + .await?; + println!( + "broadcast local_reg=0x{LOCAL_REG_UART_TDM_CTL:02x} control={control:#010x} enabled=true" + ); + Ok(()) +} + +async fn cmd_tdm_disable(args: &[String]) -> Result<()> { + if args.len() < 3 || args.len() > 4 { + bail!("usage: tdm-disable [baud]"); + } + + let mut uart = open_uart(&args[0], parse_baud(args.get(3))?)?; + let prediv = parse_u32(&args[1])?; + let counter = parse_u8(&args[2])?; + let control = encode_tdm_control(prediv, counter, false); + uart.write_local_reg_u32(BROADCAST_GROUP_ASIC, LOCAL_REG_UART_TDM_CTL, control) + .await?; + println!( + "broadcast local_reg=0x{LOCAL_REG_UART_TDM_CTL:02x} control={control:#010x} enabled=false" + ); + Ok(()) +} + +async fn cmd_tdm_watch(args: &[String]) -> Result<()> { + if args.len() < 3 || args.len() > 4 { + bail!("usage: tdm-watch [baud]"); + } + + let generation = parse_dts_generation(&args[1])?; + let duration = parse_watch_duration(&args[2])?; + let baud = parse_baud(args.get(3))?; + let (mut reader, _) = open_raw(&args[0], baud)?; + let mut parser = TdmFrameParser::new(generation); + let stats = watch_tdm_frames(&mut reader, &mut parser, duration).await?; + println!( + "summary: result={} register={} noop={} dts_vs={}", + stats.result, stats.register, stats.noop, stats.dts_vs + ); + Ok(()) +} + +async fn cmd_tdm_broadcast_read_watch(args: &[String]) -> Result<()> { + if args.len() < 7 || args.len() > 8 { + bail!( + "usage: tdm-broadcast-read-watch [baud]" + ); + } + + let generation = parse_dts_generation(&args[1])?; + let asics = parse_asic_range(&args[2], &args[3])?; + let engine = parse_engine_address(&args[4])?; + let offset = parse_u8(&args[5])?; + let count = parse_u8(&args[6])?; + let baud = parse_baud(args.get(7))?; + let (mut reader, mut writer) = open_raw(&args[0], baud)?; + let mut parser = TdmFrameParser::new(generation); + let expected = asics.clone().count(); + + for asic in asics.clone() { + parser.expect_read_register_bytes(asic, count as usize); + } + + writer + .write_all(&encode_read_register( + BROADCAST_GROUP_ASIC, + engine, + offset, + count, + )) + .await + .context("failed to send broadcast read-register command")?; + writer + .flush() + .await + .context("failed to flush serial stream")?; + + let mut received = BTreeMap::new(); + let deadline = Instant::now() + Duration::from_millis(DEFAULT_BROADCAST_READ_TIMEOUT_MS); + while Instant::now() < deadline && received.len() < expected { + let frames = read_tdm_frames_once( + &mut reader, + &mut parser, + Duration::from_millis(DEFAULT_WATCH_POLL_MS), + ) + .await?; + for frame in frames { + print_tdm_frame(&frame); + if let TdmFrame::Register(register) = frame { + received.insert(register.asic, register.data); + } + } + } + + println!( + "broadcast-read summary: received={} expected={} missing={}", + received.len(), + expected, + expected.saturating_sub(received.len()) + ); + + if received.len() != expected { + let mut missing = Vec::new(); + for asic in asics { + if !received.contains_key(&asic) { + missing.push(asic); + } + } + if !missing.is_empty() { + println!("missing_asics={}", format_asic_ids(&missing)); + } + } + + Ok(()) +} + +async fn cmd_engine_target_all(args: &[String]) -> Result<()> { + if args.len() < 3 || args.len() > 4 { + bail!("usage: engine-target-all [baud]"); + } + + let mut uart = open_uart(&args[0], parse_baud(args.get(3))?)?; + let asic = parse_asic_or_broadcast(&args[1])?; + let target = parse_u32(&args[2])?; + write_engine_reg_all_u32(&mut uart, asic, ENGINE_REG_TARGET, target).await?; + println!("programmed engine target across all rows: asic={asic:#04x} target={target:#010x}"); + Ok(()) +} + +async fn cmd_engine_timestamp_all(args: &[String]) -> Result<()> { + if args.len() < 3 || args.len() > 4 { + bail!("usage: engine-timestamp-all [baud]"); + } + + let mut uart = open_uart(&args[0], parse_baud(args.get(3))?)?; + let asic = parse_asic_or_broadcast(&args[1])?; + let value = parse_u8(&args[2])?; + write_engine_reg_all_u8(&mut uart, asic, ENGINE_REG_TIMESTAMP_COUNT, value).await?; + println!( + "programmed ENGINE_REG_TIMESTAMP_COUNT across all rows: asic={asic:#04x} value={value}" + ); + Ok(()) +} + +async fn cmd_engine_zeros_all(args: &[String]) -> Result<()> { + if args.len() < 3 || args.len() > 4 { + bail!("usage: engine-zeros-all [baud]"); + } + + let mut uart = open_uart(&args[0], parse_baud(args.get(3))?)?; + let asic = parse_asic_or_broadcast(&args[1])?; + let zeros = parse_zeros_to_find(&args[2])?; + let register_value = zeros - 32; + write_engine_reg_all_u8(&mut uart, asic, ENGINE_REG_ZEROS_TO_FIND, register_value).await?; + println!( + "programmed ENGINE_REG_ZEROS_TO_FIND across all rows: asic={asic:#04x} zeros_to_find={zeros} register_value={register_value}" + ); + Ok(()) +} + +async fn cmd_job_grid(args: &[String]) -> Result<()> { + if args.len() < 5 || args.len() > 6 { + bail!( + "usage: job-grid [baud]" + ); + } + + let mut uart = open_uart(&args[0], parse_baud(args.get(5))?)?; + let asic = parse_asic_or_broadcast(&args[1])?; + let sequence_base = parse_u8(&args[2])?; + let ntime = parse_u32(&args[3])?; + let timestamp_count = parse_u8(&args[4])?; + + dispatch_grid_jobs( + &mut uart, + asic, + sequence_base, + ntime, + timestamp_count, + false, + ) + .await?; + println!( + "dispatched grid job set: asic={asic:#04x} engines={} seq_base={} ntime={ntime:#010x} timestamp_count={timestamp_count}", + default_engine_coordinates().len(), + sequence_base + ); + Ok(()) +} + +async fn cmd_job_grid_watch(args: &[String]) -> Result<()> { + if args.len() < 7 || args.len() > 8 { + bail!( + "usage: job-grid-watch [baud]" + ); + } + + let asic = parse_asic_or_broadcast(&args[1])?; + let sequence_base = parse_u8(&args[2])?; + let ntime = parse_u32(&args[3])?; + let timestamp_count = parse_u8(&args[4])?; + let duration = parse_watch_duration(&args[5])?; + let generation = parse_dts_generation(&args[6])?; + let baud = parse_baud(args.get(7))?; + let (mut reader, writer) = open_raw(&args[0], baud)?; + let mut parser = TdmFrameParser::new(generation); + let mut uart = Bzm2UartController::new(reader.clone(), writer); + + dispatch_grid_jobs( + &mut uart, + asic, + sequence_base, + ntime, + timestamp_count, + false, + ) + .await?; + let stats = watch_tdm_frames(&mut reader, &mut parser, duration).await?; + println!( + "summary: result={} register={} noop={} dts_vs={}", + stats.result, stats.register, stats.noop, stats.dts_vs + ); + Ok(()) +} + +async fn cmd_job_grid_2phase_watch(args: &[String]) -> Result<()> { + if args.len() < 7 || args.len() > 8 { + bail!( + "usage: job-grid-2phase-watch [baud]" + ); + } + + let asic = parse_asic_or_broadcast(&args[1])?; + let sequence_base = parse_u8(&args[2])?; + let ntime = parse_u32(&args[3])?; + let timestamp_count = parse_u8(&args[4])?; + let duration = parse_watch_duration(&args[5])?; + let generation = parse_dts_generation(&args[6])?; + let baud = parse_baud(args.get(7))?; + let (mut reader, writer) = open_raw(&args[0], baud)?; + let mut parser = TdmFrameParser::new(generation); + let mut uart = Bzm2UartController::new(reader.clone(), writer); + + dispatch_grid_jobs( + &mut uart, + asic, + sequence_base, + ntime, + timestamp_count, + false, + ) + .await?; + dispatch_grid_jobs( + &mut uart, + asic, + sequence_base.wrapping_add(MAX_EFFBST_SUBJOBS), + ntime.wrapping_add(1), + timestamp_count, + true, + ) + .await?; + + let stats = watch_tdm_frames(&mut reader, &mut parser, duration).await?; + println!( + "summary: result={} register={} noop={} dts_vs={}", + stats.result, stats.register, stats.noop, stats.dts_vs + ); + Ok(()) +} + async fn cmd_clock_report(args: &[String]) -> Result<()> { if args.len() < 2 || args.len() > 3 { bail!("usage: clock-report [baud]"); @@ -330,3 +803,326 @@ async fn cmd_dll_set(args: &[String]) -> Result<()> { } Ok(()) } + +async fn cmd_pll_broadcast_lock_check(args: &[String]) -> Result<()> { + if args.len() < 6 || args.len() > 7 { + bail!( + "usage: pll-broadcast-lock-check [baud]" + ); + } + + let mut clock = open_clock(&args[0], parse_baud(args.get(6))?)?; + let pll = parse_pll(&args[1])?; + let freq = parse_f32(&args[2])?; + let post1 = parse_u8(&args[3])?; + let asics = parse_asic_range(&args[4], &args[5])?; + + let config = clock + .set_pll_frequency(BROADCAST_GROUP_ASIC, pll, freq, post1) + .await?; + clock.enable_pll(BROADCAST_GROUP_ASIC, pll).await?; + + println!( + "broadcast {:?}: freq={}MHz fbdiv={} postdiv={:#x}", + pll, config.frequency_mhz, config.feedback_divider, config.packed_post_divider + ); + + for asic in asics { + let status = clock + .wait_for_pll_lock( + asic, + pll, + Duration::from_secs(3), + Duration::from_millis(100), + ) + .await?; + println!( + "asic {} {:?}: locked={} enable={:#010x} misc={:#010x}", + asic, pll, status.locked, status.enable_register, status.misc_register + ); + } + + Ok(()) +} + +async fn cmd_dll_broadcast_lock_check(args: &[String]) -> Result<()> { + if args.len() < 5 || args.len() > 6 { + bail!( + "usage: dll-broadcast-lock-check [baud]" + ); + } + + let mut clock = open_clock(&args[0], parse_baud(args.get(5))?)?; + let dll = parse_dll(&args[1])?; + let duty = parse_u8(&args[2])?; + let asics = parse_asic_range(&args[3], &args[4])?; + + let config = clock + .set_dll_duty_cycle(BROADCAST_GROUP_ASIC, dll, duty) + .await?; + clock.enable_dll(BROADCAST_GROUP_ASIC, dll).await?; + + println!( + "broadcast {:?}: duty={} nde_dll={:#x} nde_clk={:#x} npi_clk={:#x}", + dll, config.duty_cycle, config.nde_dll, config.nde_clk, config.npi_clk + ); + + for asic in asics { + clock + .wait_for_dll_lock(asic, dll, Duration::from_secs(2), Duration::from_millis(10)) + .await?; + let status = clock.ensure_dll_fincon_valid(asic, dll).await?; + + println!( + "asic {} {:?}: locked={} coarsecon={} fincon={:#04x} fincon_valid={}", + asic, dll, status.locked, status.coarsecon, status.fincon, status.fincon_valid + ); + } + + Ok(()) +} + +async fn write_engine_reg_all_u8( + uart: &mut Bzm2UartController, + asic: u8, + offset: u8, + value: u8, +) -> Result<()> { + for row in 0..20u16 { + uart.multicast_write_reg_u8(asic, row, offset, value) + .await?; + } + Ok(()) +} + +async fn write_engine_reg_all_u32( + uart: &mut Bzm2UartController, + asic: u8, + offset: u8, + value: u32, +) -> Result<()> { + for row in 0..20u16 { + uart.multicast_write_register(asic, row, offset, &value.to_le_bytes()) + .await?; + } + Ok(()) +} + +async fn dispatch_grid_jobs( + uart: &mut Bzm2UartController, + asic: u8, + sequence_base: u8, + ntime: u32, + timestamp_count: u8, + second_phase: bool, +) -> Result<()> { + write_engine_reg_all_u8(uart, asic, ENGINE_REG_TIMESTAMP_COUNT, timestamp_count).await?; + + for (index, (row, col)) in default_engine_coordinates().into_iter().enumerate() { + let engine = logical_engine_address(row, col); + let sequence = sequence_base.wrapping_add(index as u8); + let (midstate, merkle_root_residue) = + synthetic_job_material(asic, engine, sequence, second_phase); + uart.write_job( + asic, + engine, + &midstate, + merkle_root_residue, + ntime.wrapping_add(u32::from(second_phase)), + sequence, + 0, + ) + .await?; + } + + Ok(()) +} + +async fn watch_tdm_frames( + reader: &mut SerialReader, + parser: &mut TdmFrameParser, + duration: Duration, +) -> Result { + let deadline = Instant::now() + duration; + let mut stats = TdmStats::default(); + + while Instant::now() < deadline { + let frames = + read_tdm_frames_once(reader, parser, Duration::from_millis(DEFAULT_WATCH_POLL_MS)) + .await?; + for frame in frames { + stats.record(&frame); + print_tdm_frame(&frame); + } + } + + Ok(stats) +} + +async fn read_tdm_frames_once( + reader: &mut SerialReader, + parser: &mut TdmFrameParser, + wait: Duration, +) -> Result> { + let mut buf = [0u8; 1024]; + match timeout(wait, reader.read(&mut buf)).await { + Ok(Ok(0)) => bail!("serial stream closed while waiting for TDM data"), + Ok(Ok(read)) => Ok(parser.push(&buf[..read])), + Ok(Err(err)) => Err(err).context("failed to read TDM data"), + Err(_) => Ok(Vec::new()), + } +} +fn print_tdm_frame(frame: &TdmFrame) { + match frame { + TdmFrame::Result(result) => { + println!( + "tdm result: asic={} engine={:#05x} row={} col={} status={:#x} nonce={:#010x} seq={} time={}", + result.asic, + result.engine_address, + result.row(), + result.col(), + result.status, + result.nonce, + result.sequence_id, + result.reported_time + ); + } + TdmFrame::Register(register) => { + println!( + "tdm readreg: asic={} data={}", + register.asic, + hex::encode(®ister.data) + ); + } + TdmFrame::Noop(noop) => { + let ascii = String::from_utf8_lossy(&noop.data); + println!( + "tdm noop: asic={} ascii={} hex={}", + noop.asic, + ascii, + hex::encode(noop.data) + ); + } + TdmFrame::DtsVs(dts_vs) => match dts_vs { + mujina_miner::asic::bzm2::protocol::TdmDtsVsFrame::Gen1(frame) => { + println!( + "tdm dts_vs gen1: asic={} voltage={} thermal_tune_code={} voltage_enabled={} thermal_validity={} thermal_enabled={}", + frame.asic, + frame.voltage, + frame.thermal_tune_code, + frame.voltage_enabled, + frame.thermal_validity, + frame.thermal_enabled + ); + } + mujina_miner::asic::bzm2::protocol::TdmDtsVsFrame::Gen2(frame) => { + println!( + "tdm dts_vs gen2: asic={} ch0={} ch1={} ch2={} thermal_code={} thermal_trip={} thermal_fault={} voltage_fault={} pll_lock={} dll0_lock={} dll1_lock={}", + frame.asic, + frame.ch0_voltage, + frame.ch1_voltage, + frame.ch2_voltage, + frame.thermal_tune_code, + frame.thermal_trip_status, + frame.thermal_fault, + frame.voltage_fault, + frame.pll_lock, + frame.dll0_lock, + frame.dll1_lock + ); + } + }, + } +} + +fn synthetic_job_material( + asic: u8, + engine_address: u16, + sequence_id: u8, + second_phase: bool, +) -> ([u8; 32], u32) { + let phase = if second_phase { 1u8 } else { 0u8 }; + let seed = format!("{asic}:{engine_address}:{sequence_id}:{phase}"); + let digest = Sha256::digest(seed.as_bytes()); + let residue_digest = Sha256::digest(digest); + + let mut midstate = [0u8; 32]; + midstate.copy_from_slice(&digest); + + let mut residue_bytes = [0u8; 4]; + residue_bytes.copy_from_slice(&residue_digest[..4]); + let merkle_root_residue = u32::from_le_bytes(residue_bytes); + + (midstate, merkle_root_residue) +} + +fn synthetic_loopback_payload(asic: u8, payload_len: usize) -> Vec { + let mut payload = Vec::with_capacity(payload_len); + let mut block = Sha256::digest([asic]); + while payload.len() < payload_len { + let take = (payload_len - payload.len()).min(block.len()); + payload.extend_from_slice(&block[..take]); + block = Sha256::digest(block); + } + payload +} + +fn encode_tdm_control(prediv_raw: u32, counter: u8, enable: bool) -> u32 { + (prediv_raw << 9) | ((counter as u32) << 1) | u32::from(enable) +} + +fn format_asic_ids(asics: &[u8]) -> String { + asics + .iter() + .map(|asic| asic.to_string()) + .collect::>() + .join(",") +} + +#[derive(Debug, Default, Clone, Copy)] +struct TdmStats { + result: usize, + register: usize, + noop: usize, + dts_vs: usize, +} + +impl TdmStats { + fn record(&mut self, frame: &TdmFrame) { + match frame { + TdmFrame::Result(_) => self.result += 1, + TdmFrame::Register(_) => self.register += 1, + TdmFrame::Noop(_) => self.noop += 1, + TdmFrame::DtsVs(_) => self.dts_vs += 1, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn zeros_to_find_is_range_checked() { + assert!(parse_zeros_to_find("32").is_ok()); + assert!(parse_zeros_to_find("64").is_ok()); + assert!(parse_zeros_to_find("31").is_err()); + assert!(parse_zeros_to_find("65").is_err()); + } + + #[test] + fn tdm_control_word_matches_legacy_layout() { + let control = encode_tdm_control(0x12, 0x0f, true); + assert_eq!(control, (0x12 << 9) | (0x0f << 1) | 1); + } + + #[test] + fn synthetic_jobs_change_per_engine() { + let first = synthetic_job_material(2, logical_engine_address(0, 0), 0, false); + let second = synthetic_job_material(2, logical_engine_address(1, 0), 1, false); + let third = synthetic_job_material(2, logical_engine_address(0, 0), 0, true); + + assert_ne!(first, second); + assert_ne!(first, third); + } +} diff --git a/mujina-miner/src/board/bzm2.rs b/mujina-miner/src/board/bzm2.rs index 821672c9..40ae9474 100644 --- a/mujina-miner/src/board/bzm2.rs +++ b/mujina-miner/src/board/bzm2.rs @@ -1,9 +1,11 @@ +use std::collections::BTreeMap; use std::env; use std::fs; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::time::Duration; use async_trait::async_trait; +use serde::{Deserialize, Serialize}; use tokio::sync::{mpsc, watch}; use tokio::task::JoinHandle; @@ -11,7 +13,13 @@ use super::{Board, BoardError, BoardInfo, VirtualBoardDescriptor}; use crate::{ api_client::types::{BoardState, Fan, PowerMeasurement, TemperatureSensor, ThreadState}, asic::{ - bzm2::{Bzm2Thread, Bzm2ThreadConfig, Bzm2ThreadHandle}, + bzm2::{ + Bzm2AsicMeasurement, Bzm2AsicTopology, Bzm2BoardCalibrationInput, + Bzm2CalibrationConstraints, Bzm2CalibrationMode, Bzm2CalibrationPlanner, + Bzm2ClockController, Bzm2DomainMeasurement, Bzm2OperatingClass, Bzm2PerformanceMode, + Bzm2Pll, Bzm2SavedOperatingPoint, Bzm2Thread, Bzm2ThreadConfig, Bzm2ThreadHandle, + Bzm2VoltageDomain, + }, hash_thread::{ HashTask, HashThread, HashThreadCapabilities, HashThreadError, HashThreadEvent, HashThreadStatus, @@ -32,6 +40,10 @@ const DEFAULT_FAN_PERCENT_SCALE: f32 = 1.0; const DEFAULT_VOLTAGE_SCALE: f32 = 0.001; const DEFAULT_CURRENT_SCALE: f32 = 0.001; const DEFAULT_POWER_SCALE: f32 = 0.000001; +const DEFAULT_CALIBRATION_SITE_TEMP_C: f32 = 20.0; +const DEFAULT_CALIBRATION_POST1_DIVIDER: u8 = 0; +const DEFAULT_CALIBRATION_LOCK_TIMEOUT_MS: u64 = 1_000; +const DEFAULT_CALIBRATION_LOCK_POLL_MS: u64 = 100; #[derive(Debug, Clone)] pub struct Bzm2VirtualDeviceConfig { @@ -43,6 +55,7 @@ pub struct Bzm2VirtualDeviceConfig { pub nominal_hashrate_ths: f64, pub dts_vs_generation: crate::asic::bzm2::protocol::DtsVsGeneration, pub telemetry: Bzm2TelemetryConfig, + pub calibration: Bzm2CalibrationConfig, } impl Bzm2VirtualDeviceConfig { @@ -71,14 +84,7 @@ impl Bzm2VirtualDeviceConfig { .unwrap_or(crate::asic::bzm2::protocol::DEFAULT_TIMESTAMP_COUNT); let nonce_gap = env::var("MUJINA_BZM2_NONCE_GAP") .ok() - .and_then(|value| { - let trimmed = value.trim(); - if let Some(hex) = trimmed.strip_prefix("0x") { - u32::from_str_radix(hex, 16).ok() - } else { - trimmed.parse().ok() - } - }) + .and_then(|value| parse_u32(&value)) .unwrap_or(crate::asic::bzm2::protocol::DEFAULT_NONCE_GAP); let dispatch_interval = Duration::from_millis( env::var("MUJINA_BZM2_DISPATCH_MS") @@ -97,7 +103,7 @@ impl Bzm2VirtualDeviceConfig { .unwrap_or(crate::asic::bzm2::protocol::DtsVsGeneration::Gen2); Some(Self { - serial_paths, + serial_paths: serial_paths.clone(), baud_rate, timestamp_count, nonce_gap, @@ -105,6 +111,7 @@ impl Bzm2VirtualDeviceConfig { nominal_hashrate_ths, dts_vs_generation, telemetry: Bzm2TelemetryConfig::from_env(), + calibration: Bzm2CalibrationConfig::from_env(serial_paths.len()), }) } @@ -127,6 +134,139 @@ impl Bzm2VirtualDeviceConfig { } } +#[derive(Debug, Clone)] +pub struct Bzm2CalibrationConfig { + pub enabled: bool, + pub apply_saved_operating_point: bool, + pub operating_class: Bzm2OperatingClass, + pub performance_mode: Bzm2PerformanceMode, + pub mode: Bzm2CalibrationMode, + pub per_stack_clocking: bool, + pub force_retune: bool, + pub asics_per_bus: Vec, + pub asics_per_domain: Vec, + pub domain_voltage_offsets_mv: Vec, + pub profile_path: Option, + pub site_temp_c: Option, + pub pll_post1_divider: u8, + pub skip_lock_check: bool, + pub lock_timeout: Duration, + pub lock_poll_interval: Duration, +} + +impl Default for Bzm2CalibrationConfig { + fn default() -> Self { + Self { + enabled: false, + apply_saved_operating_point: true, + operating_class: Bzm2OperatingClass::Generic, + performance_mode: Bzm2PerformanceMode::Standard, + mode: Bzm2CalibrationMode::default(), + per_stack_clocking: false, + force_retune: false, + asics_per_bus: vec![1], + asics_per_domain: vec![1], + domain_voltage_offsets_mv: Vec::new(), + profile_path: None, + site_temp_c: None, + pll_post1_divider: DEFAULT_CALIBRATION_POST1_DIVIDER, + skip_lock_check: false, + lock_timeout: Duration::from_millis(DEFAULT_CALIBRATION_LOCK_TIMEOUT_MS), + lock_poll_interval: Duration::from_millis(DEFAULT_CALIBRATION_LOCK_POLL_MS), + } + } +} + +impl Bzm2CalibrationConfig { + fn from_env(serial_count: usize) -> Self { + let mut config = Self { + enabled: env_flag("MUJINA_BZM2_CALIBRATE") || env_flag("MUJINA_BZM2_ENABLE_PNP"), + apply_saved_operating_point: env_flag_default_any( + &[ + "MUJINA_BZM2_APPLY_SAVED_OPERATING_POINT", + "MUJINA_BZM2_REPLAY_STORED_CALIBRATION", + ], + true, + ), + operating_class: env_var_any(&["MUJINA_BZM2_OPERATING_CLASS", "MUJINA_BZM2_BOARD_BIN"]) + .as_deref() + .and_then(parse_operating_class) + .unwrap_or(Bzm2OperatingClass::Generic), + performance_mode: env_var_any(&[ + "MUJINA_BZM2_PERFORMANCE_MODE", + "MUJINA_BZM2_MINING_STRATEGY", + ]) + .as_deref() + .and_then(parse_performance_mode) + .unwrap_or(Bzm2PerformanceMode::Standard), + mode: Bzm2CalibrationMode { + sweep_strategy: env_flag_any(&[ + "MUJINA_BZM2_SWEEP_MODE", + "MUJINA_BZM2_SWEEP_STRATEGY", + ]), + sweep_voltage: env_flag("MUJINA_BZM2_SWEEP_VOLTAGE"), + sweep_frequency: env_flag("MUJINA_BZM2_SWEEP_FREQUENCY"), + sweep_pass_rate: env_flag("MUJINA_BZM2_SWEEP_PASS_RATE"), + }, + per_stack_clocking: env_flag_any(&[ + "MUJINA_BZM2_PER_STACK_CLOCKING", + "MUJINA_BZM2_SPLIT_STACK_FREQUENCY", + ]), + force_retune: env_flag_any(&[ + "MUJINA_BZM2_FORCE_RETUNE", + "MUJINA_BZM2_FORCE_RECALIBRATION", + ]), + asics_per_bus: parse_csv_numbers::("MUJINA_BZM2_ASICS_PER_BUS").unwrap_or_else( + || { + if serial_count == 0 { + Vec::new() + } else { + vec![1; serial_count] + } + }, + ), + asics_per_domain: parse_csv_numbers::("MUJINA_BZM2_ASICS_PER_DOMAIN") + .unwrap_or_else(|| vec![1]), + domain_voltage_offsets_mv: parse_csv_numbers::( + "MUJINA_BZM2_DOMAIN_VOLTAGE_OFFSETS_MV", + ) + .unwrap_or_default(), + profile_path: env_var_any(&[ + "MUJINA_BZM2_SAVED_OPERATING_POINT_PATH", + "MUJINA_BZM2_CALIBRATION_PROFILE", + ]) + .map(PathBuf::from), + site_temp_c: env_f32_any(&["MUJINA_BZM2_SITE_TEMP_C", "MUJINA_BZM2_AMBIENT_TEMP_C"]), + pll_post1_divider: env::var("MUJINA_BZM2_CALIBRATION_POST1_DIVIDER") + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(DEFAULT_CALIBRATION_POST1_DIVIDER), + skip_lock_check: env_flag("MUJINA_BZM2_CALIBRATION_SKIP_LOCK_CHECK"), + lock_timeout: Duration::from_millis( + env::var("MUJINA_BZM2_CALIBRATION_LOCK_TIMEOUT_MS") + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(DEFAULT_CALIBRATION_LOCK_TIMEOUT_MS), + ), + lock_poll_interval: Duration::from_millis( + env::var("MUJINA_BZM2_CALIBRATION_LOCK_POLL_MS") + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(DEFAULT_CALIBRATION_LOCK_POLL_MS), + ), + }; + + if config.asics_per_bus.is_empty() && serial_count > 0 { + config.asics_per_bus = vec![1; serial_count]; + } + if config.asics_per_domain.is_empty() { + config.asics_per_domain = vec![1]; + } + + config + } +} + #[derive(Debug, Clone, Default)] pub struct Bzm2TelemetryConfig { pub poll_interval: Duration, @@ -141,7 +281,6 @@ pub struct Bzm2TelemetryConfig { pub max_board_temp_c: Option, pub max_input_power_w: Option, } - impl Bzm2TelemetryConfig { fn from_env() -> Self { Self { @@ -270,7 +409,6 @@ impl Bzm2TelemetryConfig { }; let trip_reason = self.trip_reason(asic_temp, board_temp, power_w); - Bzm2TelemetrySnapshot { fans, temperatures, @@ -293,7 +431,6 @@ impl Bzm2TelemetryConfig { )); } } - if let (Some(limit), Some(value)) = (self.max_board_temp_c, board_temp) { if value > limit { return Some(format!( @@ -302,7 +439,6 @@ impl Bzm2TelemetryConfig { )); } } - if let (Some(limit), Some(value)) = (self.max_input_power_w, input_power_w) { if value > limit { return Some(format!( @@ -311,7 +447,6 @@ impl Bzm2TelemetryConfig { )); } } - None } } @@ -346,6 +481,68 @@ struct Bzm2TelemetrySnapshot { trip_reason: Option, } +#[derive(Debug, Clone, PartialEq, Eq)] +struct Bzm2BusLayout { + serial_path: String, + asic_start: u16, + asic_count: u16, +} + +impl Bzm2BusLayout { + fn contains(&self, global_asic_id: u16) -> bool { + global_asic_id >= self.asic_start && global_asic_id < self.asic_start + self.asic_count + } + + fn local_asic_id(&self, global_asic_id: u16) -> Option { + self.contains(global_asic_id) + .then_some((global_asic_id - self.asic_start) as u8) + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +struct Bzm2PersistedCalibrationProfile { + schema_version: u32, + #[serde(alias = "board_bin")] + operating_class: String, + #[serde(alias = "strategy")] + performance_mode: String, + asics_per_bus: Vec, + pll_post1_divider: u8, + #[serde(alias = "calibration")] + saved_state: Bzm2SavedOperatingPoint, +} + +impl Bzm2PersistedCalibrationProfile { + const SCHEMA_VERSION: u32 = 1; + + fn is_compatible( + &self, + calibration: &Bzm2CalibrationConfig, + bus_layouts: &[Bzm2BusLayout], + ) -> bool { + self.schema_version == Self::SCHEMA_VERSION + && self.operating_class == operating_class_name(calibration.operating_class) + && self.performance_mode == performance_mode_name(calibration.performance_mode) + && self.pll_post1_divider == calibration.pll_post1_divider + && self.asics_per_bus + == bus_layouts + .iter() + .map(|bus| bus.asic_count) + .collect::>() + && self.saved_state.per_asic_pll_mhz.len() + == bus_layouts + .iter() + .map(|bus| bus.asic_count as usize) + .sum::() + } +} + +#[derive(Debug, Clone)] +struct Bzm2LoadedCalibrationProfile { + persisted: Option, + saved_state: Bzm2SavedOperatingPoint, +} + pub struct Bzm2Board { config: Bzm2VirtualDeviceConfig, shutdown_handles: Vec, @@ -383,7 +580,6 @@ impl Bzm2Board { self.monitor_task = Some(tokio::spawn(async move { let mut interval = tokio::time::interval(telemetry.poll_interval); interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - loop { tokio::select! { _ = interval.tick() => { @@ -392,20 +588,12 @@ impl Bzm2Board { let stats = control.stats(); (acc.0 + stats.bytes_read, acc.1 + stats.bytes_written) }); - let _ = state_tx.send_modify(|state| { state.fans = snapshot.fans.clone(); state.temperatures = snapshot.temperatures.clone(); state.powers = snapshot.powers.clone(); }); - - trace!( - board = %board_name, - bytes_read = total_stats.0, - bytes_written = total_stats.1, - "BZM2 board telemetry updated" - ); - + trace!(board = %board_name, bytes_read = total_stats.0, bytes_written = total_stats.1, "BZM2 board telemetry updated"); if let Some(reason) = snapshot.trip_reason.clone() { warn!(board = %board_name, reason = %reason, "BZM2 safety trip triggered"); for handle in &shutdown_handles { @@ -429,6 +617,284 @@ impl Bzm2Board { } })); } + + async fn execute_live_calibration(&self) -> Result<(), BoardError> { + let calibration = &self.config.calibration; + if !calibration.enabled { + return Ok(()); + } + + let bus_layouts = build_bus_layouts(&self.config.serial_paths, &calibration.asics_per_bus); + let total_asics = bus_layouts + .iter() + .map(|layout| layout.asic_count as usize) + .sum::(); + if total_asics == 0 { + return Ok(()); + } + + let loaded_profile = + load_saved_operating_point_profile(calibration.profile_path.as_deref()) + .map_err(BoardError::InitializationFailed)?; + if calibration.apply_saved_operating_point && !calibration.force_retune { + if let Some(profile) = loaded_profile + .as_ref() + .and_then(|loaded| loaded.persisted.as_ref()) + .filter(|profile| profile.is_compatible(calibration, &bus_layouts)) + { + self.apply_saved_operating_point(&bus_layouts, profile) + .await?; + info!( + board = %self.config.device_id(), + asic_count = profile.saved_state.per_asic_pll_mhz.len(), + "BZM2 replayed saved operating point profile" + ); + return Ok(()); + } + } + + let telemetry = self.config.telemetry.snapshot(); + let site_temp_c = calibration + .site_temp_c + .or_else(|| snapshot_temperature(&telemetry, "board")) + .or_else(|| snapshot_temperature(&telemetry, "asic")) + .unwrap_or(DEFAULT_CALIBRATION_SITE_TEMP_C); + let saved_operating_point = loaded_profile + .as_ref() + .map(|loaded| loaded.saved_state.clone()); + let (voltage_domains, domain_lookup) = build_voltage_domains( + total_asics as u16, + &calibration.asics_per_domain, + &calibration.domain_voltage_offsets_mv, + ); + let asics = build_topology(&bus_layouts, &domain_lookup); + let alive_asics = asics.iter().filter(|asic| asic.alive).count().max(1); + let per_asic_throughput = saved_operating_point + .as_ref() + .map(|stored| stored.board_throughput_ths / alive_asics as f32); + let shared_temp = snapshot_temperature(&telemetry, "asic") + .or_else(|| snapshot_temperature(&telemetry, "board")); + let asic_measurements = asics + .iter() + .map(|asic| Bzm2AsicMeasurement { + asic_id: asic.asic_id, + temperature_c: shared_temp, + throughput_ths: per_asic_throughput, + average_pass_rate: None, + pll_pass_rates: [None, None], + }) + .collect::>(); + let shared_domain_power = snapshot_input_power(&telemetry).map(|power| { + if voltage_domains.is_empty() { + power + } else { + power / voltage_domains.len() as f32 + } + }); + let domain_measurements = voltage_domains + .iter() + .map(|domain| Bzm2DomainMeasurement { + domain_id: domain.domain_id, + measured_voltage_mv: None, + measured_power_w: shared_domain_power, + }) + .collect::>(); + + let planner = Bzm2CalibrationPlanner; + let plan = planner.plan(&Bzm2BoardCalibrationInput { + operating_class: calibration.operating_class, + site_temp_c, + target_mode: calibration.performance_mode, + mode: calibration.mode, + per_stack_clocking: calibration.per_stack_clocking, + voltage_domains: voltage_domains.clone(), + asics: asics.clone(), + saved_operating_point, + domain_measurements, + asic_measurements, + constraints: Bzm2CalibrationConstraints::default(), + force_retune: calibration.force_retune, + }); + let mut domain_voltages = plan + .domain_plans + .iter() + .map(|domain| domain.voltage_mv) + .collect::>(); + domain_voltages.sort_unstable(); + domain_voltages.dedup(); + if domain_voltages.len() > 1 { + warn!(board = %self.config.device_id(), ?domain_voltages, "planner requested multiple domain voltages; board runtime currently applies clock phases only"); + } + + let per_asic_pll_mhz = plan + .asic_plans + .iter() + .map(|plan| (plan.asic_id, plan.pll_frequencies_mhz)) + .collect::>(); + self.apply_frequency_map( + &bus_layouts, + [plan.initial_frequency_mhz; 2], + &per_asic_pll_mhz, + ) + .await?; + + if let Some(profile_path) = calibration.profile_path.as_deref() { + let saved_operating_point = Bzm2SavedOperatingPoint { + board_voltage_mv: average_u32( + plan.domain_plans.iter().map(|domain| domain.voltage_mv), + ) + .unwrap_or(plan.desired_voltage_mv), + board_throughput_ths: estimate_planned_hashrate( + &plan, + self.config.nominal_hashrate_ths as f32, + self.config.serial_paths.len(), + ), + per_asic_pll_mhz, + }; + let profile = Bzm2PersistedCalibrationProfile { + schema_version: Bzm2PersistedCalibrationProfile::SCHEMA_VERSION, + operating_class: operating_class_name(calibration.operating_class).into(), + performance_mode: performance_mode_name(calibration.performance_mode).into(), + asics_per_bus: bus_layouts.iter().map(|bus| bus.asic_count).collect(), + pll_post1_divider: calibration.pll_post1_divider, + saved_state: saved_operating_point, + }; + store_calibration_profile(profile_path, &profile) + .map_err(BoardError::InitializationFailed)?; + } + + info!(board = %self.config.device_id(), reuse_saved_operating_point = plan.reuse_saved_operating_point, needs_retune = plan.needs_retune, initial_frequency_mhz = plan.initial_frequency_mhz, asic_count = plan.asic_plans.len(), "BZM2 live calibration completed"); + Ok(()) + } + + async fn apply_saved_operating_point( + &self, + bus_layouts: &[Bzm2BusLayout], + profile: &Bzm2PersistedCalibrationProfile, + ) -> Result<(), BoardError> { + for bus in bus_layouts { + let initial_frequencies = [0usize, 1usize].map(|pll_index| { + average_f32( + (bus.asic_start..bus.asic_start + bus.asic_count) + .filter_map(|asic_id| profile.saved_state.per_asic_pll_mhz.get(&asic_id)) + .map(|frequencies| frequencies[pll_index]), + ) + .unwrap_or(DEFAULT_CALIBRATION_SITE_TEMP_C) + }); + self.apply_bus_frequency_map( + bus, + initial_frequencies, + &profile.saved_state.per_asic_pll_mhz, + ) + .await?; + } + Ok(()) + } + + async fn apply_frequency_map( + &self, + bus_layouts: &[Bzm2BusLayout], + initial_frequencies_mhz: [f32; 2], + per_asic_pll_mhz: &BTreeMap, + ) -> Result<(), BoardError> { + for bus in bus_layouts { + self.apply_bus_frequency_map(bus, initial_frequencies_mhz, per_asic_pll_mhz) + .await?; + } + Ok(()) + } + + async fn apply_bus_frequency_map( + &self, + bus: &Bzm2BusLayout, + initial_frequencies_mhz: [f32; 2], + per_asic_pll_mhz: &BTreeMap, + ) -> Result<(), BoardError> { + let stream = SerialStream::new(&bus.serial_path, self.config.baud_rate).map_err(|err| { + BoardError::InitializationFailed(format!( + "Failed to open BZM2 calibration transport {}: {}", + bus.serial_path, err + )) + })?; + let (reader, writer, _control) = stream.split(); + let mut clock = Bzm2ClockController::new(reader, writer); + + for (pll, frequency_mhz) in [Bzm2Pll::Pll0, Bzm2Pll::Pll1] + .into_iter() + .zip(initial_frequencies_mhz) + { + clock + .broadcast_pll_frequency( + pll, + frequency_mhz, + self.config.calibration.pll_post1_divider, + ) + .await + .map_err(|err| calibration_error(&bus.serial_path, err))?; + clock + .broadcast_enable_pll(pll) + .await + .map_err(|err| calibration_error(&bus.serial_path, err))?; + } + + if !self.config.calibration.skip_lock_check { + for local_asic in 0..bus.asic_count { + for pll in [Bzm2Pll::Pll0, Bzm2Pll::Pll1] { + clock + .wait_for_pll_lock( + local_asic as u8, + pll, + self.config.calibration.lock_timeout, + self.config.calibration.lock_poll_interval, + ) + .await + .map_err(|err| calibration_error(&bus.serial_path, err))?; + } + } + } + + for asic_id in bus.asic_start..bus.asic_start + bus.asic_count { + let Some(frequencies_mhz) = per_asic_pll_mhz.get(&asic_id) else { + continue; + }; + let local_asic = bus + .local_asic_id(asic_id) + .expect("bus layout must contain loop asic id"); + for (index, frequency_mhz) in frequencies_mhz.iter().enumerate() { + let pll = if index == 0 { + Bzm2Pll::Pll0 + } else { + Bzm2Pll::Pll1 + }; + clock + .set_pll_frequency( + local_asic, + pll, + *frequency_mhz, + self.config.calibration.pll_post1_divider, + ) + .await + .map_err(|err| calibration_error(&bus.serial_path, err))?; + clock + .enable_pll(local_asic, pll) + .await + .map_err(|err| calibration_error(&bus.serial_path, err))?; + if !self.config.calibration.skip_lock_check { + clock + .wait_for_pll_lock( + local_asic, + pll, + self.config.calibration.lock_timeout, + self.config.calibration.lock_poll_interval, + ) + .await + .map_err(|err| calibration_error(&bus.serial_path, err))?; + } + } + } + + Ok(()) + } } #[async_trait] @@ -448,7 +914,6 @@ impl Board for Bzm2Board { if let Some(handle) = self.monitor_task.take() { let _ = handle.await; } - for handle in &self.shutdown_handles { handle.shutdown(); } @@ -466,6 +931,14 @@ impl Board for Bzm2Board { async fn create_hash_threads(&mut self) -> Result>, BoardError> { let mut threads: Vec> = Vec::new(); let mut thread_states = Vec::new(); + let initial_snapshot = self.config.telemetry.snapshot(); + let _ = self.state_tx.send_modify(|state| { + state.fans = initial_snapshot.fans.clone(); + state.temperatures = initial_snapshot.temperatures.clone(); + state.powers = initial_snapshot.powers.clone(); + }); + + self.execute_live_calibration().await?; for (index, serial_path) in self.config.serial_paths.iter().enumerate() { let stream = SerialStream::new(serial_path, self.config.baud_rate).map_err(|err| { @@ -503,7 +976,6 @@ impl Board for Bzm2Board { }); self.spawn_monitor(); - Ok(threads) } } @@ -537,7 +1009,6 @@ impl HashThread for Bzm2ManagedThread { fn name(&self) -> &str { self.inner.name() } - fn capabilities(&self) -> &HashThreadCapabilities { self.inner.capabilities() } @@ -571,19 +1042,16 @@ impl HashThread for Bzm2ManagedThread { let (event_tx, event_rx) = mpsc::channel(64); let state_tx = self.state_tx.clone(); let thread_index = self.thread_index; - tokio::spawn(async move { while let Some(event) = inner_rx.recv().await { if let HashThreadEvent::StatusUpdate(ref status) = event { publish_thread_status(&state_tx, thread_index, status); } - if event_tx.send(event).await.is_err() { break; } } }); - Some(event_rx) } @@ -604,6 +1072,183 @@ fn publish_thread_status( } }); } +fn build_bus_layouts(serial_paths: &[String], asics_per_bus: &[u16]) -> Vec { + let mut next_asic = 0u16; + serial_paths + .iter() + .enumerate() + .map(|(index, path)| { + let asic_count = *asics_per_bus + .get(index) + .or_else(|| asics_per_bus.last()) + .unwrap_or(&1) + .max(&1); + let layout = Bzm2BusLayout { + serial_path: path.clone(), + asic_start: next_asic, + asic_count, + }; + next_asic = next_asic.saturating_add(asic_count); + layout + }) + .collect() +} + +fn build_voltage_domains( + total_asics: u16, + asics_per_domain: &[u16], + domain_voltage_offsets_mv: &[i32], +) -> (Vec, BTreeMap) { + let mut domains = Vec::new(); + let mut lookup = BTreeMap::new(); + let mut domain_id = 0u16; + let mut asic_start = 0u16; + while asic_start < total_asics { + let requested = *asics_per_domain + .get(domain_id as usize) + .or_else(|| asics_per_domain.last()) + .unwrap_or(&total_asics) + .max(&1); + let asic_end = (asic_start.saturating_add(requested)).min(total_asics); + let asic_ids = (asic_start..asic_end).collect::>(); + for asic_id in &asic_ids { + lookup.insert(*asic_id, domain_id); + } + domains.push(Bzm2VoltageDomain { + domain_id, + asic_ids, + voltage_offset_mv: *domain_voltage_offsets_mv + .get(domain_id as usize) + .or_else(|| domain_voltage_offsets_mv.last()) + .unwrap_or(&0), + max_power_w: None, + }); + domain_id = domain_id.saturating_add(1); + asic_start = asic_end; + } + (domains, lookup) +} + +fn build_topology( + bus_layouts: &[Bzm2BusLayout], + domain_lookup: &BTreeMap, +) -> Vec { + let mut asics = Vec::new(); + for layout in bus_layouts { + for asic_id in layout.asic_start..layout.asic_start + layout.asic_count { + asics.push(Bzm2AsicTopology { + asic_id, + domain_id: *domain_lookup.get(&asic_id).unwrap_or(&0), + pll_count: 2, + alive: true, + }); + } + } + asics +} + +fn load_saved_operating_point_profile( + path: Option<&Path>, +) -> Result, String> { + let Some(path) = path else { + return Ok(None); + }; + if !path.exists() { + return Ok(None); + } + let raw = fs::read_to_string(path).map_err(|err| { + format!( + "Failed to read calibration profile {}: {}", + path.display(), + err + ) + })?; + + if let Ok(profile) = serde_json::from_str::(&raw) { + return Ok(Some(Bzm2LoadedCalibrationProfile { + saved_state: profile.saved_state.clone(), + persisted: Some(profile), + })); + } + + serde_json::from_str::(&raw) + .map(|saved_state| { + Some(Bzm2LoadedCalibrationProfile { + persisted: None, + saved_state, + }) + }) + .map_err(|err| { + format!( + "Failed to parse calibration profile {}: {}", + path.display(), + err + ) + }) +} + +fn store_calibration_profile( + path: &Path, + profile: &Bzm2PersistedCalibrationProfile, +) -> Result<(), String> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|err| { + format!( + "Failed to create calibration profile directory {}: {}", + parent.display(), + err + ) + })?; + } + let raw = serde_json::to_string_pretty(profile) + .map_err(|err| format!("Failed to serialize calibration profile: {}", err))?; + fs::write(path, raw).map_err(|err| { + format!( + "Failed to write calibration profile {}: {}", + path.display(), + err + ) + }) +} + +fn estimate_planned_hashrate( + plan: &crate::asic::bzm2::Bzm2CalibrationPlan, + nominal_hashrate_ths: f32, + thread_count: usize, +) -> f32 { + let nominal_board_hashrate = nominal_hashrate_ths * thread_count.max(1) as f32; + let average_frequency_mhz = if plan.asic_plans.is_empty() { + plan.desired_clock_mhz + } else { + plan.asic_plans + .iter() + .map(|asic| (asic.pll_frequencies_mhz[0] + asic.pll_frequencies_mhz[1]) / 2.0) + .sum::() + / plan.asic_plans.len() as f32 + }; + let ratio = if plan.desired_clock_mhz > 0.0 { + average_frequency_mhz / plan.desired_clock_mhz + } else { + 1.0 + }; + nominal_board_hashrate * ratio.max(0.1) +} + +fn snapshot_temperature(snapshot: &Bzm2TelemetrySnapshot, name: &str) -> Option { + snapshot + .temperatures + .iter() + .find(|sensor| sensor.name == name) + .and_then(|sensor| sensor.temperature_c) +} + +fn snapshot_input_power(snapshot: &Bzm2TelemetrySnapshot) -> Option { + snapshot + .powers + .iter() + .find(|power| power.name == "input") + .and_then(|power| power.power_w) +} fn parse_scaled_sensor_value(raw: &str, scale: f32) -> Option { let trimmed = raw.trim(); @@ -613,8 +1258,153 @@ fn parse_scaled_sensor_value(raw: &str, scale: f32) -> Option { trimmed.parse::().ok().map(|value| value * scale) } +fn env_var_any(keys: &[&str]) -> Option { + keys.iter().find_map(|key| env::var(key).ok()) +} + +fn env_flag(key: &str) -> bool { + env_var_any(&[key]).as_deref().is_some_and(|value| { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) + }) +} + +fn env_flag_any(keys: &[&str]) -> bool { + env_var_any(keys).as_deref().is_some_and(|value| { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) + }) +} + +fn env_flag_default_any(keys: &[&str], default: bool) -> bool { + env_var_any(keys) + .map(|value| { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) + }) + .unwrap_or(default) +} + fn env_f32(key: &str) -> Option { - env::var(key).ok().and_then(|value| value.parse().ok()) + env_f32_any(&[key]) +} + +fn env_f32_any(keys: &[&str]) -> Option { + env_var_any(keys).and_then(|value| value.parse().ok()) +} + +fn parse_u32(value: &str) -> Option { + let trimmed = value.trim(); + if let Some(hex) = trimmed.strip_prefix("0x") { + u32::from_str_radix(hex, 16).ok() + } else { + trimmed.parse().ok() + } +} + +fn parse_csv_numbers(key: &str) -> Option> +where + T: std::str::FromStr, +{ + let value = env::var(key).ok()?; + let parsed = value + .split(',') + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.parse().ok()) + .collect::>>()?; + Some(parsed) +} + +fn parse_operating_class(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "generic" => Some(Bzm2OperatingClass::Generic), + "early-validation" | "early_validation" | "dvt1" => { + Some(Bzm2OperatingClass::EarlyValidation) + } + "production-validation" | "production_validation" | "pvt" => { + Some(Bzm2OperatingClass::ProductionValidation) + } + "stack-tuned-a" | "stack_tuned_a" | "dvt2-bin1" | "dvt2_bin1" | "dvt2bin1" | "bin1" => { + Some(Bzm2OperatingClass::StackTunedA) + } + "stack-tuned-b" | "stack_tuned_b" | "dvt2-bin2" | "dvt2_bin2" | "dvt2bin2" | "bin2" => { + Some(Bzm2OperatingClass::StackTunedB) + } + "extended-headroom" | "extended_headroom" | "plus" => { + Some(Bzm2OperatingClass::ExtendedHeadroom) + } + "extended-headroom-b" + | "extended_headroom_b" + | "plus-ebin2" + | "plus_ebin2" + | "plusebin2" + | "ebin2" => Some(Bzm2OperatingClass::ExtendedHeadroomB), + _ => None, + } +} + +fn parse_performance_mode(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "max-throughput" | "max_throughput" | "high" | "high-performance" | "high_performance" + | "performance" => Some(Bzm2PerformanceMode::MaxThroughput), + "standard" | "balanced" => Some(Bzm2PerformanceMode::Standard), + "efficiency" | "low" | "low-power" | "low_power" => Some(Bzm2PerformanceMode::Efficiency), + _ => None, + } +} + +fn average_u32(values: impl Iterator) -> Option { + let mut total = 0u64; + let mut count = 0u64; + for value in values { + total += value as u64; + count += 1; + } + (count > 0).then_some((total / count) as u32) +} + +fn average_f32(values: impl Iterator) -> Option { + let mut total = 0.0f32; + let mut count = 0usize; + for value in values { + total += value; + count += 1; + } + (count > 0).then_some(total / count as f32) +} + +fn operating_class_name(operating_class: Bzm2OperatingClass) -> &'static str { + match operating_class { + Bzm2OperatingClass::Generic => "generic", + Bzm2OperatingClass::EarlyValidation => "early-validation", + Bzm2OperatingClass::ProductionValidation => "production-validation", + Bzm2OperatingClass::StackTunedA => "stack-tuned-a", + Bzm2OperatingClass::StackTunedB => "stack-tuned-b", + Bzm2OperatingClass::ExtendedHeadroom => "extended-headroom", + Bzm2OperatingClass::ExtendedHeadroomB => "extended-headroom-b", + } +} + +fn performance_mode_name(performance_mode: Bzm2PerformanceMode) -> &'static str { + match performance_mode { + Bzm2PerformanceMode::MaxThroughput => "max-throughput", + Bzm2PerformanceMode::Standard => "standard", + Bzm2PerformanceMode::Efficiency => "efficiency", + } +} + +fn calibration_error(serial_path: &str, err: impl std::fmt::Display) -> BoardError { + BoardError::InitializationFailed(format!( + "BZM2 calibration failed on {}: {}", + serial_path, err + )) } async fn create_bzm2_board() @@ -644,7 +1434,6 @@ inventory::submit! { create_fn: || Box::pin(create_bzm2_board()), } } - #[cfg(test)] mod tests { use super::*; @@ -653,6 +1442,136 @@ mod tests { use std::os::fd::AsRawFd; use std::time::{SystemTime, UNIX_EPOCH}; + #[tokio::test] + async fn live_calibration_persists_profile() { + let pty = openpty(None, None).unwrap(); + let serial_path = fs::read_link(format!("/proc/self/fd/{}", pty.slave.as_raw_fd())) + .unwrap() + .to_string_lossy() + .into_owned(); + let profile_path = std::env::temp_dir().join(format!( + "bzm2-profile-{}-{}.json", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + + let config = Bzm2VirtualDeviceConfig { + serial_paths: vec![serial_path], + baud_rate: DEFAULT_BAUD_RATE, + timestamp_count: crate::asic::bzm2::protocol::DEFAULT_TIMESTAMP_COUNT, + nonce_gap: crate::asic::bzm2::protocol::DEFAULT_NONCE_GAP, + dispatch_interval: Duration::from_millis(50), + nominal_hashrate_ths: DEFAULT_NOMINAL_HASHRATE_THS, + dts_vs_generation: crate::asic::bzm2::protocol::DtsVsGeneration::Gen2, + telemetry: Bzm2TelemetryConfig::default(), + calibration: Bzm2CalibrationConfig { + enabled: true, + asics_per_bus: vec![2], + asics_per_domain: vec![1], + profile_path: Some(profile_path.clone()), + skip_lock_check: true, + ..Default::default() + }, + }; + let (state_tx, _state_rx) = watch::channel(BoardState { + name: "bzm2-test".into(), + model: "BZM2".into(), + serial: Some("bzm2-test".into()), + ..Default::default() + }); + let board = Bzm2Board::new(config, state_tx); + + board.execute_live_calibration().await.unwrap(); + + let profile = load_saved_operating_point_profile(Some(&profile_path)) + .unwrap() + .unwrap(); + assert_eq!(profile.saved_state.per_asic_pll_mhz.len(), 2); + assert!(profile.persisted.is_some()); + + let _ = fs::remove_file(profile_path); + drop(pty); + } + + #[tokio::test] + async fn stored_profile_replays_on_restart_without_rewrite() { + let pty = openpty(None, None).unwrap(); + let serial_path = fs::read_link(format!("/proc/self/fd/{}", pty.slave.as_raw_fd())) + .unwrap() + .to_string_lossy() + .into_owned(); + let profile_path = std::env::temp_dir().join(format!( + "bzm2-replay-{}-{}.json", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let persisted = Bzm2PersistedCalibrationProfile { + schema_version: Bzm2PersistedCalibrationProfile::SCHEMA_VERSION, + operating_class: operating_class_name(Bzm2OperatingClass::Generic).into(), + performance_mode: performance_mode_name(Bzm2PerformanceMode::Standard).into(), + asics_per_bus: vec![2], + pll_post1_divider: DEFAULT_CALIBRATION_POST1_DIVIDER, + saved_state: Bzm2SavedOperatingPoint { + board_voltage_mv: 17_500, + board_throughput_ths: 80.0, + per_asic_pll_mhz: BTreeMap::from([ + (0, [1_100.0, 1_125.0]), + (1, [1_150.0, 1_175.0]), + ]), + }, + }; + let original = serde_json::to_string_pretty(&persisted).unwrap(); + fs::write(&profile_path, &original).unwrap(); + + let config = Bzm2VirtualDeviceConfig { + serial_paths: vec![serial_path], + baud_rate: DEFAULT_BAUD_RATE, + timestamp_count: crate::asic::bzm2::protocol::DEFAULT_TIMESTAMP_COUNT, + nonce_gap: crate::asic::bzm2::protocol::DEFAULT_NONCE_GAP, + dispatch_interval: Duration::from_millis(50), + nominal_hashrate_ths: DEFAULT_NOMINAL_HASHRATE_THS, + dts_vs_generation: crate::asic::bzm2::protocol::DtsVsGeneration::Gen2, + telemetry: Bzm2TelemetryConfig::default(), + calibration: Bzm2CalibrationConfig { + enabled: true, + apply_saved_operating_point: true, + asics_per_bus: vec![2], + profile_path: Some(profile_path.clone()), + skip_lock_check: true, + ..Default::default() + }, + }; + let (state_tx, _state_rx) = watch::channel(BoardState { + name: "bzm2-test".into(), + model: "BZM2".into(), + serial: Some("bzm2-test".into()), + ..Default::default() + }); + let board = Bzm2Board::new(config, state_tx); + + board.execute_live_calibration().await.unwrap(); + + assert_eq!(fs::read_to_string(&profile_path).unwrap(), original); + + let _ = fs::remove_file(profile_path); + drop(pty); + } + + #[test] + fn build_bus_layouts_assigns_global_ranges() { + let layouts = build_bus_layouts(&["/dev/ttyUSB0".into(), "/dev/ttyUSB1".into()], &[4, 6]); + assert_eq!(layouts[0].asic_start, 0); + assert_eq!(layouts[0].asic_count, 4); + assert_eq!(layouts[1].asic_start, 4); + assert_eq!(layouts[1].asic_count, 6); + } + #[tokio::test] async fn board_safety_trip_closes_scheduler_event_stream() { let pty = openpty(None, None).unwrap(); @@ -688,6 +1607,7 @@ mod tests { max_asic_temp_c: Some(80.0), ..Default::default() }, + calibration: Bzm2CalibrationConfig::default(), }; let (state_tx, mut state_rx) = watch::channel(BoardState { name: "bzm2-test".into(), @@ -730,6 +1650,7 @@ mod tests { let _ = fs::remove_file(sensor_path); drop(pty); } + #[test] fn parse_scaled_sensor_value_applies_scale() { let parsed = parse_scaled_sensor_value("42500\n", 0.001).unwrap(); @@ -745,7 +1666,6 @@ mod tests { max_input_power_w: Some(1200.0), ..Default::default() }; - assert!( telemetry .trip_reason(Some(81.0), None, None) diff --git a/mujina-miner/src/board/mod.rs b/mujina-miner/src/board/mod.rs index 9b4d5140..6366ce81 100644 --- a/mujina-miner/src/board/mod.rs +++ b/mujina-miner/src/board/mod.rs @@ -1,5 +1,5 @@ -pub(crate) mod bzm2; pub(crate) mod bitaxe; +pub(crate) mod bzm2; pub mod cpu; pub(crate) mod emberone00; pub mod pattern; diff --git a/mujina-miner/src/daemon.rs b/mujina-miner/src/daemon.rs index 60c1237a..5e767b17 100644 --- a/mujina-miner/src/daemon.rs +++ b/mujina-miner/src/daemon.rs @@ -88,12 +88,12 @@ impl Daemon { baud = config.baud_rate, "BZM2 virtual board enabled" ); - let event = TransportEvent::Virtual(virtual_device::TransportEvent::VirtualDeviceConnected( - VirtualDeviceInfo { + let event = TransportEvent::Virtual( + virtual_device::TransportEvent::VirtualDeviceConnected(VirtualDeviceInfo { device_type: "bzm2".into(), device_id: config.device_id(), - }, - )); + }), + ); if let Err(e) = transport_tx.send(event).await { error!("Failed to send BZM2 virtual board event: {}", e); } From 537c04565c826172d3f58e865337fcf59b6ea482 Mon Sep 17 00:00:00 2001 From: Reckless Apotheosis <125509978+recklessnode@users.noreply.github.com> Date: Fri, 6 Mar 2026 15:57:03 -0800 Subject: [PATCH 06/24] feat(bzm2): expose DTS/VS telemetry through the API Surface BZM2 sensor telemetry through the existing API and board runtime. This commit adds: - passive DTS/VS telemetry publication into board state - explicit on-demand DTS/VS query support - the supporting API and debug-tooling integration for ASIC voltage and temperature reads The accompanying docs stay with this slice because they explain the new telemetry endpoints and sensor naming. --- mujina-miner/src/api/registry.rs | 72 +++++-- mujina-miner/src/api/server.rs | 171 ++++++++++++----- mujina-miner/src/api/v0.rs | 97 ++++++++-- mujina-miner/src/api_client/types.rs | 88 +++------ mujina-miner/src/asic/bzm2/mod.rs | 5 +- mujina-miner/src/asic/bzm2/thread.rs | 268 +++++++++++++++++++++++++- mujina-miner/src/asic/bzm2/uart.rs | 271 ++++++++++++++++++++++++++- mujina-miner/src/asic/hash_thread.rs | 50 +++++ mujina-miner/src/bin/bzm2-debug.rs | 129 ++++++++++++- mujina-miner/src/board/bitaxe.rs | 55 +++--- mujina-miner/src/board/bzm2.rs | 232 +++++++++++++++++++++-- mujina-miner/src/board/cpu.rs | 21 ++- mujina-miner/src/board/emberone.rs | 127 +++++++++++++ mujina-miner/src/board/mod.rs | 27 ++- mujina-miner/src/scheduler.rs | 4 + 15 files changed, 1408 insertions(+), 209 deletions(-) create mode 100644 mujina-miner/src/board/emberone.rs diff --git a/mujina-miner/src/api/registry.rs b/mujina-miner/src/api/registry.rs index d4704c52..ddf12208 100644 --- a/mujina-miner/src/api/registry.rs +++ b/mujina-miner/src/api/registry.rs @@ -1,7 +1,9 @@ //! Dynamic board registration tracking. -use crate::api_client::types::BoardTelemetry; -use crate::board::BoardRegistration; +use tokio::sync::mpsc; + +use crate::api_client::types::BoardState; +use crate::board::{BoardCommand, BoardRegistration}; /// Dynamic collection of board registrations. /// @@ -23,37 +25,64 @@ impl BoardRegistry { self.boards.push(reg); } + fn prune_disconnected(&mut self) { + self.boards.retain(|reg| reg.state_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()); + pub fn boards(&mut self) -> Vec { + self.prune_disconnected(); self.boards .iter() - .map(|reg| reg.telemetry_rx.borrow().clone()) + .map(|reg| reg.state_rx.borrow().clone()) .collect() } + + /// Snapshot one connected board by name. + pub fn board(&mut self, name: &str) -> Option { + self.prune_disconnected(); + self.boards + .iter() + .find(|reg| reg.state_rx.borrow().name == name) + .map(|reg| reg.state_rx.borrow().clone()) + } + + /// Clone a board command sender by board name if available. + pub fn command_tx(&mut self, name: &str) -> Option> { + self.prune_disconnected(); + self.boards + .iter() + .find(|reg| reg.state_rx.borrow().name == name) + .and_then(|reg| reg.command_tx.clone()) + } } #[cfg(test)] mod tests { - use tokio::sync::watch; + use tokio::sync::{mpsc, watch}; use super::*; use crate::board::BoardRegistration; /// Create a board registration with the given name, returning the /// state sender so the test can update or drop it. - fn make_board(name: &str) -> (watch::Sender, BoardRegistration) { - let telemetry = BoardTelemetry { + fn make_board(name: &str) -> (watch::Sender, BoardRegistration) { + let state = BoardState { name: name.into(), model: "Test".into(), ..Default::default() }; - let (tx, rx) = watch::channel(telemetry); - (tx, BoardRegistration { telemetry_rx: rx }) + let (tx, rx) = watch::channel(state); + ( + tx, + BoardRegistration { + state_rx: rx, + command_tx: None, + }, + ) } #[test] @@ -80,16 +109,13 @@ mod tests { registry.push(reg_a); registry.push(reg_b); - // Both present initially assert_eq!(registry.boards().len(), 2); - // Drop the sender for board B -- simulates board disconnect drop(drop_me); let boards = registry.boards(); assert_eq!(boards.len(), 1); assert_eq!(boards[0].name, "stays"); - // Sender A still alive drop(keep); } @@ -105,4 +131,22 @@ mod tests { tx.send_modify(|s| s.model = "Updated".into()); assert_eq!(registry.boards()[0].model, "Updated"); } + + #[test] + fn returns_command_sender_for_named_board() { + let mut registry = BoardRegistry::new(); + let (_state_tx, state_rx) = watch::channel(BoardState { + name: "board-a".into(), + model: "Test".into(), + ..Default::default() + }); + let (command_tx, _command_rx) = mpsc::channel(1); + registry.push(BoardRegistration { + state_rx, + command_tx: Some(command_tx.clone()), + }); + + let cloned = registry.command_tx("board-a"); + assert!(cloned.is_some()); + } } diff --git a/mujina-miner/src/api/server.rs b/mujina-miner/src/api/server.rs index bb0237cb..fa90de32 100644 --- a/mujina-miner/src/api/server.rs +++ b/mujina-miner/src/api/server.rs @@ -8,14 +8,12 @@ use tokio::net::TcpListener; use tokio::sync::{mpsc, watch}; use tokio_util::sync::CancellationToken; use tower_http::trace::{DefaultMakeSpan, DefaultOnResponse, TraceLayer}; -use tracing::Level; - -use crate::tracing::prelude::*; +use tracing::{Level, info, warn}; use utoipa_axum::router::OpenApiRouter; use utoipa_swagger_ui::SwaggerUi; use super::{commands::SchedulerCommand, registry::BoardRegistry, v0}; -use crate::api_client::types::MinerTelemetry; +use crate::api_client::types::MinerState; use crate::board::BoardRegistration; /// API server configuration. @@ -28,22 +26,22 @@ pub struct ApiConfig { /// Shared application state available to all handlers. #[derive(Clone)] pub(crate) struct SharedState { - pub miner_telemetry_rx: watch::Receiver, + pub miner_state_rx: watch::Receiver, pub board_registry: Arc>, pub scheduler_cmd_tx: mpsc::Sender, } impl SharedState { - /// Build a complete MinerTelemetry by combining scheduler data with board + /// Build a complete MinerState by combining scheduler data with board /// snapshots from the registry. - pub fn miner_telemetry(&self) -> MinerTelemetry { - let mut telemetry = self.miner_telemetry_rx.borrow().clone(); - telemetry.boards = self + pub fn miner_state(&self) -> MinerState { + let mut state = self.miner_state_rx.borrow().clone(); + state.boards = self .board_registry .lock() .unwrap_or_else(|e| e.into_inner()) .boards(); - telemetry + state } } @@ -59,7 +57,7 @@ impl SharedState { pub async fn serve( config: ApiConfig, shutdown: CancellationToken, - miner_telemetry_rx: watch::Receiver, + miner_state_rx: watch::Receiver, mut board_reg_rx: mpsc::Receiver, scheduler_cmd_tx: mpsc::Sender, ) -> Result<()> { @@ -76,7 +74,7 @@ pub async fn serve( } }); - let app = build_router(miner_telemetry_rx, board_registry, scheduler_cmd_tx); + let app = build_router(miner_state_rx, board_registry, scheduler_cmd_tx); let listener = TcpListener::bind(&config.bind_addr).await?; let actual_addr = listener.local_addr()?; @@ -104,12 +102,12 @@ pub async fn serve( /// Build the application router with all API routes. pub(crate) fn build_router( - miner_telemetry_rx: watch::Receiver, + miner_state_rx: watch::Receiver, board_registry: Arc>, scheduler_cmd_tx: mpsc::Sender, ) -> Router { let state = SharedState { - miner_telemetry_rx, + miner_state_rx, board_registry, scheduler_cmd_tx, }; @@ -138,24 +136,23 @@ mod tests { use super::*; use crate::api::commands::SchedulerCommand; - use crate::api_client::types::{BoardTelemetry, SourceTelemetry}; - use crate::board::BoardRegistration; + use crate::api_client::types::{ + BoardState, Bzm2DtsVsQueryRequest, SourceState, TemperatureSensor, + }; + use crate::board::{BoardCommand, BoardRegistration}; /// Test fixtures returned by the router builder. struct TestFixtures { router: Router, /// Keep alive to prevent board watch channels from closing. - _board_senders: Vec>, - /// Publish updated miner telemetry (e.g. after handling a command). - _miner_tx: watch::Sender, + _board_senders: Vec>, + /// Publish updated miner state (e.g. after handling a command). + _miner_tx: watch::Sender, /// Receives commands sent by PATCH handlers. _cmd_rx: mpsc::Receiver, } - fn build_test_router( - miner_state: MinerTelemetry, - board_states: Vec, - ) -> TestFixtures { + fn build_test_router(miner_state: MinerState, board_states: Vec) -> TestFixtures { let (miner_tx, miner_rx) = watch::channel(miner_state); let (cmd_tx, cmd_rx) = mpsc::channel::(16); @@ -163,7 +160,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 { + state_rx: rx, + command_tx: None, + }); board_senders.push(tx); } @@ -186,9 +186,26 @@ mod tests { (status, String::from_utf8(body.to_vec()).unwrap()) } + async fn post_json( + app: Router, + uri: &str, + body: &T, + ) -> (http::StatusCode, String) { + let req = Request::builder() + .method("POST") + .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 health_returns_ok() { - let fixtures = build_test_router(MinerTelemetry::default(), vec![]); + let fixtures = build_test_router(MinerState::default(), vec![]); let (status, body) = get(fixtures.router.clone(), "/api/v0/health").await; assert_eq!(status, 200); assert_eq!(body, "OK"); @@ -196,18 +213,18 @@ mod tests { #[tokio::test] async fn miner_includes_boards_and_sources() { - let miner_state = MinerTelemetry { + let miner_state = MinerState { uptime_secs: 42, hashrate: 1_000_000, shares_submitted: 5, - sources: vec![SourceTelemetry { + sources: vec![SourceState { name: "pool".into(), url: Some("stratum+tcp://localhost:3333".into()), ..Default::default() }], ..Default::default() }; - let board = BoardTelemetry { + let board = BoardState { name: "test-board".into(), model: "TestModel".into(), ..Default::default() @@ -217,7 +234,7 @@ mod tests { let (status, body) = get(fixtures.router.clone(), "/api/v0/miner").await; assert_eq!(status, 200); - let state: MinerTelemetry = serde_json::from_str(&body).unwrap(); + let state: MinerState = serde_json::from_str(&body).unwrap(); assert_eq!(state.uptime_secs, 42); assert_eq!(state.hashrate, 1_000_000); assert_eq!(state.shares_submitted, 5); @@ -230,23 +247,23 @@ mod tests { #[tokio::test] async fn boards_returns_list() { let boards = vec![ - BoardTelemetry { + BoardState { name: "board-a".into(), model: "A".into(), ..Default::default() }, - BoardTelemetry { + BoardState { name: "board-b".into(), model: "B".into(), ..Default::default() }, ]; - let fixtures = build_test_router(MinerTelemetry::default(), boards); + let fixtures = build_test_router(MinerState::default(), boards); let (status, body) = get(fixtures.router.clone(), "/api/v0/boards").await; assert_eq!(status, 200); - let boards: Vec = serde_json::from_str(&body).unwrap(); + let boards: Vec = serde_json::from_str(&body).unwrap(); assert_eq!(boards.len(), 2); assert_eq!(boards[0].name, "board-a"); assert_eq!(boards[1].name, "board-b"); @@ -254,39 +271,95 @@ mod tests { #[tokio::test] async fn board_by_name_returns_match() { - let board = BoardTelemetry { + let board = BoardState { name: "bitaxe-abc123".into(), model: "Bitaxe".into(), serial: Some("abc123".into()), ..Default::default() }; - let fixtures = build_test_router(MinerTelemetry::default(), vec![board]); + let fixtures = build_test_router(MinerState::default(), vec![board]); let (status, body) = get(fixtures.router.clone(), "/api/v0/boards/bitaxe-abc123").await; assert_eq!(status, 200); - let board: BoardTelemetry = serde_json::from_str(&body).unwrap(); + let board: BoardState = serde_json::from_str(&body).unwrap(); assert_eq!(board.name, "bitaxe-abc123"); assert_eq!(board.serial, Some("abc123".into())); } #[tokio::test] async fn board_by_name_returns_404_when_missing() { - let fixtures = build_test_router(MinerTelemetry::default(), vec![]); + let fixtures = build_test_router(MinerState::default(), vec![]); let (status, _body) = get(fixtures.router.clone(), "/api/v0/boards/nonexistent").await; assert_eq!(status, 404); } + #[tokio::test] + async fn bzm2_query_endpoint_returns_refreshed_board_state() { + let (miner_tx, miner_rx) = watch::channel(MinerState::default()); + let (cmd_tx, cmd_rx) = mpsc::channel::(16); + let mut registry = BoardRegistry::new(); + let (state_tx, state_rx) = watch::channel(BoardState { + name: "bzm2-test".into(), + model: "BZM2".into(), + ..Default::default() + }); + let state_tx_for_command = state_tx.clone(); + let (board_cmd_tx, mut board_cmd_rx) = mpsc::channel(1); + registry.push(BoardRegistration { + state_rx, + command_tx: Some(board_cmd_tx), + }); + let router = build_router(miner_rx, Arc::new(Mutex::new(registry)), cmd_tx); + + tokio::spawn(async move { + if let Some(BoardCommand::QueryBzm2DtsVs { + thread_index, + asic, + reply, + }) = board_cmd_rx.recv().await + { + assert_eq!(thread_index, 0); + assert_eq!(asic, 2); + state_tx_for_command.send_modify(|state| { + state.temperatures.push(TemperatureSensor { + name: "ttyUSB0-asic-2-dts".into(), + temperature_c: Some(64.5), + }); + }); + let _ = reply.send(Ok(())); + } + }); + + let (status, body) = post_json( + router, + "/api/v0/boards/bzm2-test/bzm2/dts-vs-query", + &Bzm2DtsVsQueryRequest { + thread_index: 0, + asic: 2, + }, + ) + .await; + + assert_eq!(status, 200); + let board: BoardState = serde_json::from_str(&body).unwrap(); + assert!(board.temperatures.iter().any( + |sensor| sensor.name == "ttyUSB0-asic-2-dts" && sensor.temperature_c == Some(64.5) + )); + drop(miner_tx); + drop(cmd_rx); + } + #[tokio::test] async fn sources_returns_list() { - let miner_state = MinerTelemetry { + let miner_state = MinerState { sources: vec![ - SourceTelemetry { + SourceState { name: "pool-a".into(), url: Some("stratum+tcp://a:3333".into()), ..Default::default() }, - SourceTelemetry { + SourceState { name: "pool-b".into(), url: None, ..Default::default() @@ -299,7 +372,7 @@ mod tests { let (status, body) = get(fixtures.router.clone(), "/api/v0/sources").await; assert_eq!(status, 200); - let sources: Vec = serde_json::from_str(&body).unwrap(); + let sources: Vec = serde_json::from_str(&body).unwrap(); assert_eq!(sources.len(), 2); assert_eq!(sources[0].name, "pool-a"); assert_eq!(sources[0].url.as_deref(), Some("stratum+tcp://a:3333")); @@ -309,8 +382,8 @@ mod tests { #[tokio::test] async fn source_by_name_returns_match() { - let miner_state = MinerTelemetry { - sources: vec![SourceTelemetry { + let miner_state = MinerState { + sources: vec![SourceState { name: "my-pool".into(), url: Some("stratum+tcp://pool:3333".into()), ..Default::default() @@ -322,22 +395,22 @@ mod tests { let (status, body) = get(fixtures.router.clone(), "/api/v0/sources/my-pool").await; assert_eq!(status, 200); - let source: SourceTelemetry = serde_json::from_str(&body).unwrap(); + let source: SourceState = serde_json::from_str(&body).unwrap(); assert_eq!(source.name, "my-pool"); assert_eq!(source.url.as_deref(), Some("stratum+tcp://pool:3333")); } #[tokio::test] async fn source_by_name_returns_404_when_missing() { - let fixtures = build_test_router(MinerTelemetry::default(), vec![]); + let fixtures = build_test_router(MinerState::default(), vec![]); let (status, _body) = get(fixtures.router.clone(), "/api/v0/sources/nonexistent").await; assert_eq!(status, 404); } #[tokio::test] async fn source_difficulty_serializes_as_f64() { - let miner_state = MinerTelemetry { - sources: vec![SourceTelemetry { + let miner_state = MinerState { + sources: vec![SourceState { name: "pool".into(), difficulty: Some(2048.5), ..Default::default() @@ -349,13 +422,13 @@ mod tests { let (status, body) = get(fixtures.router.clone(), "/api/v0/sources/pool").await; assert_eq!(status, 200); - let source: SourceTelemetry = serde_json::from_str(&body).unwrap(); + let source: SourceState = serde_json::from_str(&body).unwrap(); assert_eq!(source.difficulty, Some(2048.5)); } #[tokio::test] async fn unknown_route_returns_404() { - let fixtures = build_test_router(MinerTelemetry::default(), vec![]); + let fixtures = build_test_router(MinerState::default(), vec![]); let (status, _body) = get(fixtures.router.clone(), "/api/v0/nope").await; assert_eq!(status, 404); } diff --git a/mujina-miner/src/api/v0.rs b/mujina-miner/src/api/v0.rs index 5778fe5d..0b9cdcef 100644 --- a/mujina-miner/src/api/v0.rs +++ b/mujina-miner/src/api/v0.rs @@ -16,8 +16,9 @@ use utoipa_axum::{router::OpenApiRouter, routes}; use super::commands::SchedulerCommand; use super::server::SharedState; use crate::api_client::types::{ - BoardTelemetry, MinerPatchRequest, MinerTelemetry, SourceTelemetry, + BoardState, Bzm2DtsVsQueryRequest, MinerPatchRequest, MinerState, SourceState, }; +use crate::board::BoardCommand; /// Build the v0 API routes with OpenAPI metadata. pub fn routes() -> OpenApiRouter { @@ -26,6 +27,7 @@ pub fn routes() -> OpenApiRouter { .routes(routes!(get_miner, patch_miner)) .routes(routes!(get_boards)) .routes(routes!(get_board)) + .routes(routes!(query_bzm2_dts_vs)) .routes(routes!(get_sources)) .routes(routes!(get_source)) } @@ -49,11 +51,11 @@ async fn health() -> &'static str { path = "/miner", tag = "miner", responses( - (status = OK, description = "Current miner telemetry", body = MinerTelemetry), + (status = OK, description = "Current miner state", body = MinerState), ), )] -async fn get_miner(State(state): State) -> Json { - Json(state.miner_telemetry()) +async fn get_miner(State(state): State) -> Json { + Json(state.miner_state()) } /// Apply partial updates to the miner configuration. @@ -63,14 +65,14 @@ async fn get_miner(State(state): State) -> Json { tag = "miner", request_body = MinerPatchRequest, responses( - (status = OK, description = "Updated miner telemetry", body = MinerTelemetry), + (status = OK, description = "Updated miner state", body = MinerState), (status = INTERNAL_SERVER_ERROR, description = "Command channel error"), ), )] async fn patch_miner( State(state): State, Json(req): Json, -) -> Result, StatusCode> { +) -> Result, StatusCode> { if let Some(paused) = req.paused { let (tx, rx) = oneshot::channel(); let cmd = if paused { @@ -89,7 +91,7 @@ async fn patch_miner( }; } - Ok(Json(state.miner_telemetry())) + Ok(Json(state.miner_state())) } /// Return all connected boards. @@ -98,10 +100,10 @@ async fn patch_miner( path = "/boards", tag = "boards", responses( - (status = OK, description = "List of connected boards", body = Vec), + (status = OK, description = "List of connected boards", body = Vec), ), )] -async fn get_boards(State(state): State) -> Json> { +async fn get_boards(State(state): State) -> Json> { Json( state .board_registry @@ -120,14 +122,14 @@ async fn get_boards(State(state): State) -> Json, Path(name): Path, -) -> Result, StatusCode> { +) -> Result, StatusCode> { state .board_registry .lock() @@ -139,17 +141,74 @@ async fn get_board( .ok_or(StatusCode::NOT_FOUND) } +/// Trigger an explicit BZM2 DTS/VS query and return the refreshed board state. +#[utoipa::path( + post, + path = "/boards/{name}/bzm2/dts-vs-query", + tag = "boards", + params( + ("name" = String, Path, description = "Board name"), + ), + request_body = Bzm2DtsVsQueryRequest, + responses( + (status = OK, description = "Refreshed board details", body = BoardState), + (status = BAD_REQUEST, description = "Board does not support BZM2 telemetry queries"), + (status = NOT_FOUND, description = "Board not found"), + (status = INTERNAL_SERVER_ERROR, description = "Board command failed"), + ), +)] +async fn query_bzm2_dts_vs( + State(state): State, + Path(name): Path, + 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::QueryBzm2DtsVs { + thread_index: req.thread_index, + asic: req.asic, + reply: tx, + }) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_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) +} + /// Return all registered job sources. #[utoipa::path( get, path = "/sources", tag = "sources", responses( - (status = OK, description = "List of job sources", body = Vec), + (status = OK, description = "List of job sources", body = Vec), ), )] -async fn get_sources(State(state): State) -> Json> { - Json(state.miner_telemetry_rx.borrow().sources.clone()) +async fn get_sources(State(state): State) -> Json> { + Json(state.miner_state().sources) } /// Return a single source by name, or 404 if not found. @@ -161,21 +220,19 @@ async fn get_sources(State(state): State) -> Json, Path(name): Path, -) -> Result, StatusCode> { +) -> Result, StatusCode> { state - .miner_telemetry_rx - .borrow() + .miner_state() .sources - .iter() + .into_iter() .find(|s| s.name == name) - .cloned() .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..8dbc53fd 100644 --- a/mujina-miner/src/api_client/types.rs +++ b/mujina-miner/src/api_client/types.rs @@ -8,23 +8,21 @@ use serde::{Deserialize, Serialize}; use utoipa::ToSchema; -use crate::types::Temperature; - -/// Full miner telemetry snapshot. +/// Full miner state snapshot. #[derive(Clone, Debug, Default, Deserialize, Serialize, ToSchema)] -pub struct MinerTelemetry { +pub struct MinerState { pub uptime_secs: u64, /// Aggregate hashrate in hashes per second. pub hashrate: u64, pub shares_submitted: u64, pub paused: bool, - pub boards: Vec, - pub sources: Vec, + pub boards: Vec, + pub sources: Vec, } -/// Board telemetry snapshot. +/// Board status. #[derive(Clone, Debug, Default, Deserialize, Serialize, ToSchema)] -pub struct BoardTelemetry { +pub struct BoardState { /// URL-friendly identifier (e.g. "bitaxe-e2f56f9b"). pub name: String, pub model: String, @@ -32,7 +30,7 @@ pub struct BoardTelemetry { pub fans: Vec, pub temperatures: Vec, pub powers: Vec, - pub threads: Vec, + pub threads: Vec, } /// Fan status. @@ -51,9 +49,7 @@ pub struct Fan { #[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] pub struct TemperatureSensor { pub name: String, - #[serde(rename = "temperature_c")] - #[schema(value_type = Option)] - pub temperature: Option, + pub temperature_c: Option, } /// Voltage, current, and power from a single measurement point. @@ -65,9 +61,9 @@ pub struct PowerMeasurement { pub power_w: Option, } -/// Per-thread telemetry. +/// Per-thread runtime status. #[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] -pub struct ThreadTelemetry { +pub struct ThreadState { pub name: String, /// Hashrate in hashes per second. pub hashrate: u64, @@ -92,63 +88,23 @@ pub struct SetFanTargetRequest { pub target_percent: Option, } -/// Job source telemetry. +/// Request body for an explicit BZM2 ASIC DTS/VS query. +#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] +pub struct Bzm2DtsVsQueryRequest { + /// Index of the BZM2 UART thread/bus to query. + pub thread_index: usize, + /// ASIC id on that UART bus. + pub asic: u8, +} + +/// Job source status. #[derive(Clone, Debug, Default, Deserialize, Serialize, ToSchema)] -pub struct SourceTelemetry { +pub struct SourceState { pub name: String, /// Connection URL (e.g. "stratum+tcp://pool:3333"), if applicable. #[serde(skip_serializing_if = "Option::is_none")] pub url: Option, /// Current share difficulty set by the source. - #[serde( - skip_serializing_if = "Option::is_none", - serialize_with = "serialize_opt_f64_as_integer_when_whole" - )] + #[serde(skip_serializing_if = "Option::is_none")] pub difficulty: Option, } - -/// Serialize an `Option` so that whole numbers appear without a -/// fractional part (e.g. `2328` instead of `2328.0`). -fn serialize_opt_f64_as_integer_when_whole( - value: &Option, - serializer: S, -) -> Result { - match value { - None => serializer.serialize_none(), - Some(v) if v.fract() == 0.0 && v.is_finite() => serializer.serialize_i64(*v as i64), - Some(v) => serializer.serialize_f64(*v), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn whole_difficulty_serializes_as_integer() { - let source = SourceTelemetry { - difficulty: Some(2048.0), - ..Default::default() - }; - let json: serde_json::Value = serde_json::to_value(&source).unwrap(); - assert!( - json["difficulty"].is_u64(), - "expected integer, got {}", - json["difficulty"] - ); - } - - #[test] - fn fractional_difficulty_serializes_as_float() { - let source = SourceTelemetry { - difficulty: Some(2048.5), - ..Default::default() - }; - let json: serde_json::Value = serde_json::to_value(&source).unwrap(); - assert!( - json["difficulty"].is_f64(), - "expected float, got {}", - json["difficulty"] - ); - } -} diff --git a/mujina-miner/src/asic/bzm2/mod.rs b/mujina-miner/src/asic/bzm2/mod.rs index bcdb6e04..0276ec6a 100644 --- a/mujina-miner/src/asic/bzm2/mod.rs +++ b/mujina-miner/src/asic/bzm2/mod.rs @@ -16,4 +16,7 @@ pub use pnp::{ Bzm2PerformanceMode, Bzm2SavedOperatingPoint, Bzm2VoltageDomain, }; pub use thread::{Bzm2Thread, Bzm2ThreadConfig, Bzm2ThreadHandle}; -pub use uart::{BROADCAST_GROUP_ASIC, Bzm2UartController, Bzm2UartError, NOTCH_REG}; +pub use uart::{ + BROADCAST_GROUP_ASIC, Bzm2DtsVsConfig, Bzm2UartController, Bzm2UartError, + DEFAULT_DTS_VS_QUERY_TIMEOUT, NOTCH_REG, +}; diff --git a/mujina-miner/src/asic/bzm2/thread.rs b/mujina-miner/src/asic/bzm2/thread.rs index 264d6654..92507487 100644 --- a/mujina-miner/src/asic/bzm2/thread.rs +++ b/mujina-miner/src/asic/bzm2/thread.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::path::Path; use std::sync::{Arc, RwLock}; use std::time::Duration; @@ -11,7 +12,8 @@ use tokio::sync::{mpsc, oneshot}; use crate::asic::hash_thread::{ HashTask, HashThread, HashThreadCapabilities, HashThreadError, HashThreadEvent, - HashThreadStatus, Share, + HashThreadPowerReading, HashThreadStatus, HashThreadTelemetryUpdate, + HashThreadTemperatureReading, Share, }; use crate::job_source::{GeneralPurposeBits, MerkleRootKind}; use crate::tracing::prelude::*; @@ -24,6 +26,7 @@ use super::protocol::{ TdmFrame, TdmFrameParser, default_engine_coordinates, encode_write_job, encode_write_register, leading_zero_threshold, logical_engine_address, }; +use super::uart::{Bzm2DtsVsConfig, DEFAULT_DTS_VS_QUERY_TIMEOUT, configure_dts_vs_stream}; #[derive(Debug, Clone)] pub struct Bzm2ThreadConfig { @@ -59,6 +62,20 @@ impl Bzm2ThreadHandle { pub fn shutdown(&self) { let _ = self.command_tx.try_send(ThreadCommand::Shutdown); } + + pub async fn query_dts_vs( + &self, + asic: u8, + ) -> Result { + let (response_tx, response_rx) = oneshot::channel(); + self.command_tx + .send(ThreadCommand::QueryDtsVs { asic, response_tx }) + .await + .map_err(|_| HashThreadError::ChannelClosed("command channel closed".into()))?; + response_rx + .await + .map_err(|_| HashThreadError::TelemetryQueryFailed("thread dropped response".into()))? + } } #[derive(Debug)] @@ -74,6 +91,10 @@ enum ThreadCommand { GoIdle { response_tx: oneshot::Sender, HashThreadError>>, }, + QueryDtsVs { + asic: u8, + response_tx: oneshot::Sender>, + }, Shutdown, } @@ -231,6 +252,7 @@ async fn bzm2_thread_actor( let mut status_tick = tokio::time::interval(Duration::from_secs(5)); status_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); let mut read_buf = [0u8; 4096]; + let mut dts_vs_configured = false; loop { tokio::select! { @@ -284,6 +306,20 @@ async fn bzm2_thread_actor( let _ = event_tx.send(HashThreadEvent::StatusUpdate(snapshot_status(&status))).await; let _ = response_tx.send(Ok(old)); } + ThreadCommand::QueryDtsVs { asic, response_tx } => { + let result = query_dts_vs_telemetry( + asic, + &mut reader, + &mut writer, + &mut parser, + &engine_dispatches, + &config, + &status, + &event_tx, + &mut dts_vs_configured, + ).await; + let _ = response_tx.send(result); + } ThreadCommand::Shutdown => break, } } @@ -305,7 +341,7 @@ async fn bzm2_thread_actor( .await; } TdmFrame::DtsVs(frame) => { - should_shutdown = handle_dts_vs_frame(&frame, &config, &status).await; + should_shutdown = handle_dts_vs_frame(&frame, &config, &status, &event_tx).await; if should_shutdown { break; } @@ -365,7 +401,17 @@ async fn handle_dts_vs_frame( frame: &TdmDtsVsFrame, config: &Bzm2ThreadConfig, status: &Arc>, + event_tx: &mpsc::Sender, ) -> bool { + if let Some(update) = build_dts_vs_telemetry_update(frame, config) { + if let Some(reading) = update.temperatures.first() { + set_temperature(status, reading.temperature_c); + } + let _ = event_tx + .send(HashThreadEvent::TelemetryUpdate(update)) + .await; + } + match frame { TdmDtsVsFrame::Gen1(frame) => { trace!( @@ -388,6 +434,10 @@ async fn handle_dts_vs_frame( thermal_fault = frame.thermal_fault, voltage_fault = frame.voltage_fault, voltage_shutdown = frame.voltage_shutdown_status, + thermal_tune_code = frame.thermal_tune_code, + ch0_voltage = frame.ch0_voltage, + ch1_voltage = frame.ch1_voltage, + ch2_voltage = frame.ch2_voltage, "BZM2 DTS/VS gen2 telemetry frame" ); @@ -413,6 +463,80 @@ async fn handle_dts_vs_frame( } } +async fn query_dts_vs_telemetry( + asic: u8, + reader: &mut SerialReader, + writer: &mut SerialWriter, + parser: &mut TdmFrameParser, + engine_dispatches: &HashMap, + config: &Bzm2ThreadConfig, + status: &Arc>, + event_tx: &mpsc::Sender, + dts_vs_configured: &mut bool, +) -> Result { + if !*dts_vs_configured { + configure_dts_vs_stream(writer, reader, &Bzm2DtsVsConfig::default()) + .await + .map_err(|err| HashThreadError::TelemetryQueryFailed(err.to_string()))?; + *dts_vs_configured = true; + } + + let deadline = tokio::time::Instant::now() + DEFAULT_DTS_VS_QUERY_TIMEOUT; + let mut read_buf = [0u8; 512]; + loop { + let now = tokio::time::Instant::now(); + if now >= deadline { + return Err(HashThreadError::TelemetryQueryFailed(format!( + "timed out waiting for DTS/VS frame from ASIC {asic:#x}" + ))); + } + let remaining = deadline.saturating_duration_since(now); + let read = tokio::time::timeout(remaining, reader.read(&mut read_buf)) + .await + .map_err(|_| { + HashThreadError::TelemetryQueryFailed(format!( + "timed out waiting for DTS/VS frame from ASIC {asic:#x}" + )) + }) + .and_then(|result| { + result.map_err(|err| HashThreadError::TelemetryQueryFailed(err.to_string())) + })?; + if read == 0 { + return Err(HashThreadError::TelemetryQueryFailed( + "serial stream closed while waiting for DTS/VS data".into(), + )); + } + + for frame in parser.push(&read_buf[..read]) { + match frame { + TdmFrame::Result(frame) => { + handle_result_frame(&frame, engine_dispatches, config, status, event_tx).await; + } + TdmFrame::DtsVs(frame) => { + let frame_asic = dts_vs_frame_asic(&frame); + let update = + build_dts_vs_telemetry_update(&frame, config).ok_or_else(|| { + HashThreadError::TelemetryQueryFailed( + "failed to build DTS/VS telemetry update".into(), + ) + })?; + let should_shutdown = + handle_dts_vs_frame(&frame, config, status, event_tx).await; + if should_shutdown { + return Err(HashThreadError::TelemetryQueryFailed( + "DTS/VS query observed a hardware fault".into(), + )); + } + if frame_asic == asic { + return Ok(update); + } + } + TdmFrame::Register(_) | TdmFrame::Noop(_) => {} + } + } + } +} + async fn dispatch_task_to_board( writer: &mut SerialWriter, task: &HashTask, @@ -671,6 +795,92 @@ fn record_hardware_error(status: &Arc>) { lock.hardware_errors = lock.hardware_errors.saturating_add(1); } +fn set_temperature(status: &Arc>, temperature_c: Option) { + let mut lock = status.write().unwrap(); + lock.temperature_c = temperature_c; +} + +fn build_dts_vs_telemetry_update( + frame: &TdmDtsVsFrame, + config: &Bzm2ThreadConfig, +) -> Option { + let prefix = sensor_prefix(&config.serial_path); + match frame { + TdmDtsVsFrame::Gen1(frame) => Some(HashThreadTelemetryUpdate { + temperatures: Vec::new(), + powers: vec![HashThreadPowerReading { + name: format!("{prefix}-asic-{}-vs", frame.asic), + voltage_v: frame + .voltage_enabled + .then(|| legacy_tune_code_to_voltage_v(frame.voltage)), + current_a: None, + power_w: None, + }], + }), + TdmDtsVsFrame::Gen2(frame) => Some(HashThreadTelemetryUpdate { + temperatures: vec![HashThreadTemperatureReading { + name: format!("{prefix}-asic-{}-dts", frame.asic), + temperature_c: (frame.thermal_enabled && frame.thermal_validity) + .then(|| legacy_tune_code_to_temperature_c(frame.thermal_tune_code)), + }], + powers: vec![ + HashThreadPowerReading { + name: format!("{prefix}-asic-{}-vs-ch0", frame.asic), + voltage_v: frame + .voltage_enabled + .then(|| legacy_tune_code_to_voltage_v(frame.ch0_voltage)), + current_a: None, + power_w: None, + }, + HashThreadPowerReading { + name: format!("{prefix}-asic-{}-vs-ch1", frame.asic), + voltage_v: frame + .voltage_enabled + .then(|| legacy_tune_code_to_voltage_v(frame.ch1_voltage)), + current_a: None, + power_w: None, + }, + HashThreadPowerReading { + name: format!("{prefix}-asic-{}-vs-ch2", frame.asic), + voltage_v: frame + .voltage_enabled + .then(|| legacy_tune_code_to_voltage_v(frame.ch2_voltage)), + current_a: None, + power_w: None, + }, + ], + }), + } +} + +fn dts_vs_frame_asic(frame: &TdmDtsVsFrame) -> u8 { + match frame { + TdmDtsVsFrame::Gen1(frame) => frame.asic, + TdmDtsVsFrame::Gen2(frame) => frame.asic, + } +} + +fn sensor_prefix(serial_path: &str) -> String { + Path::new(serial_path) + .file_name() + .and_then(|value| value.to_str()) + .filter(|value| !value.is_empty()) + .unwrap_or(serial_path) + .chars() + .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '-' }) + .collect() +} + +fn legacy_tune_code_to_temperature_c(tune_code: u16) -> f32 { + let resolution_power = 4096.0_f32; + -293.8 + 631.8 * ((tune_code as f32) - (2048.0 / resolution_power)) / 4096.0 +} + +fn legacy_tune_code_to_voltage_v(tune_code: u16) -> f32 { + let resolution_power = 16384.0_f32; + 0.4 * 0.7067 * (6.0 * (tune_code as f32) / 16384.0 - 3.0 / resolution_power - 1.0) +} + #[cfg(test)] mod tests { use super::*; @@ -851,6 +1061,60 @@ mod tests { other => panic!("unexpected event: {:?}", other), } } + #[tokio::test] + async fn gen2_dts_vs_emits_api_telemetry() { + let config = Bzm2ThreadConfig::new("/dev/ttyUSB0".into(), 5_000_000); + let status = Arc::new(RwLock::new(HashThreadStatus::default())); + let (event_tx, mut event_rx) = tokio_mpsc::channel(4); + let frame = TdmDtsVsFrame::Gen2(protocol::TdmDtsVsGen2Frame { + asic: 2, + ch0_voltage: 0x1645, + ch1_voltage: 0x04B4, + ch2_voltage: 0x16AC, + voltage_shutdown_status: false, + voltage_enabled: true, + thermal_tune_code: 0x07A9, + thermal_trip_status: false, + thermal_fault: false, + thermal_validity: true, + thermal_enabled: true, + voltage_fault: false, + dll0_lock: false, + dll1_lock: true, + pll_lock: true, + }); + + let should_shutdown = handle_dts_vs_frame(&frame, &config, &status, &event_tx).await; + assert!(!should_shutdown); + + let update = event_rx.recv().await.unwrap(); + match update { + HashThreadEvent::TelemetryUpdate(update) => { + assert_eq!(update.temperatures.len(), 1); + assert_eq!(update.temperatures[0].name, "ttyUSB0-asic-2-dts"); + let temp = update.temperatures[0].temperature_c.unwrap(); + assert!((temp - legacy_tune_code_to_temperature_c(0x07A9)).abs() < 0.01); + + assert_eq!(update.powers.len(), 3); + assert_eq!(update.powers[0].name, "ttyUSB0-asic-2-vs-ch0"); + assert!( + (update.powers[0].voltage_v.unwrap() - legacy_tune_code_to_voltage_v(0x1645)) + .abs() + < 0.0001 + ); + assert_eq!(update.powers[1].name, "ttyUSB0-asic-2-vs-ch1"); + assert_eq!(update.powers[2].name, "ttyUSB0-asic-2-vs-ch2"); + } + other => panic!("unexpected event: {other:?}"), + } + + let snapshot = status.read().unwrap().clone(); + assert!( + (snapshot.temperature_c.unwrap() - legacy_tune_code_to_temperature_c(0x07A9)).abs() + < 0.01 + ); + } + #[tokio::test] async fn gen2_dts_vs_fault_shuts_down_live_thread() { let pty = openpty(None, None).unwrap(); diff --git a/mujina-miner/src/asic/bzm2/uart.rs b/mujina-miner/src/asic/bzm2/uart.rs index b2425911..c6c8ae34 100644 --- a/mujina-miner/src/asic/bzm2/uart.rs +++ b/mujina-miner/src/asic/bzm2/uart.rs @@ -1,15 +1,56 @@ +use std::time::Duration; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; use crate::transport::{SerialReader, SerialWriter}; use super::protocol::{ - BROADCAST_ASIC, OPCODE_UART_LOOPBACK, OPCODE_UART_NOOP, OPCODE_UART_READREG, encode_loopback, - encode_multicast_write, encode_noop, encode_read_register, encode_write_job, - encode_write_register, + BROADCAST_ASIC, DtsVsGeneration, OPCODE_UART_LOOPBACK, OPCODE_UART_NOOP, OPCODE_UART_READREG, + TdmDtsVsFrame, TdmFrame, TdmFrameParser, encode_loopback, encode_multicast_write, encode_noop, + encode_read_register, encode_write_job, encode_write_register, }; pub const NOTCH_REG: u16 = 0x0fff; pub const BROADCAST_GROUP_ASIC: u8 = BROADCAST_ASIC; +pub const DEFAULT_DTS_VS_QUERY_TIMEOUT: Duration = Duration::from_secs(2); + +const LOCAL_REG_SLOW_CLK_DIV: u8 = 0x08; +const LOCAL_REG_UART_TX: u8 = 0x0a; +const LOCAL_REG_SENS_TDM_GAP_CNT: u8 = 0x2d; +const LOCAL_REG_DTS_SRST_PD: u8 = 0x2e; +const LOCAL_REG_DTS_CFG: u8 = 0x2f; +const LOCAL_REG_TEMPSENSOR_TUNE_CODE: u8 = 0x30; +const LOCAL_REG_SENSOR_THRS_CNT: u8 = 0x3c; +const LOCAL_REG_SENSOR_CLK_DIV: u8 = 0x3d; +const LOCAL_REG_VSENSOR_SRST_PD: u8 = 0x3e; +const LOCAL_REG_VSENSOR_CFG: u8 = 0x3f; +const LOCAL_REG_VOLTAGE_SENSOR_ENABLE: u8 = 0x40; +const LOCAL_REG_BANDGAP: u8 = 0x45; + +const THERMAL_SENSOR_RESOLUTION: u8 = 12; +const THERMAL_SENSOR_MODE: u8 = 0; +const VOLTAGE_SENSOR_RESOLUTION: u8 = 14; +const VOLTAGE_SENSOR_CONVERSION_MODE: u8 = 1; +const VOLTAGE_SENSOR_MODE: u8 = 0; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Bzm2DtsVsConfig { + pub tdm_interval: u8, + pub thermal_trip_c: i32, + pub voltage_ch0_shutdown_mv: u32, + pub voltage_ch1_shutdown_mv: u32, +} + +impl Default for Bzm2DtsVsConfig { + fn default() -> Self { + Self { + tdm_interval: 1, + thermal_trip_c: 115, + voltage_ch0_shutdown_mv: 500, + voltage_ch1_shutdown_mv: 500, + } + } +} #[derive(Debug, thiserror::Error)] pub enum Bzm2UartError { @@ -28,6 +69,9 @@ pub enum Bzm2UartError { actual_asic: u8, actual_opcode: u8, }, + + #[error("timed out waiting for DTS/VS frame from ASIC {asic:#x}")] + DtsVsTimeout { asic: u8 }, } /// Low-level BZM2 UART control surface. @@ -255,6 +299,217 @@ impl Bzm2UartController { self.writer.flush().await?; Ok(()) } + + /// Enable DTS/VS reporting using the legacy local-register sequence. + pub async fn enable_dts_vs(&mut self, config: Bzm2DtsVsConfig) -> Result<(), Bzm2UartError> { + configure_dts_vs_stream(&mut self.writer, &mut self.reader, &config).await + } + + /// Read the next DTS/VS frame for a specific ASIC after ensuring DTS/VS is enabled. + pub async fn query_dts_vs( + &mut self, + asic: u8, + generation: DtsVsGeneration, + config: Bzm2DtsVsConfig, + timeout: Duration, + ) -> Result { + self.enable_dts_vs(config).await?; + read_dts_vs_frame_stream(&mut self.reader, generation, asic, timeout).await + } +} + +pub async fn configure_dts_vs_stream( + writer: &mut SerialWriter, + reader: &mut SerialReader, + config: &Bzm2DtsVsConfig, +) -> Result<(), Bzm2UartError> { + // Enable thermal and voltage sensor messages on the UART TDM path. + write_local_reg_u32_raw(writer, BROADCAST_ASIC, LOCAL_REG_UART_TX, 0x0f).await?; + + // Legacy reference clock setup: 50 MHz reference, 6.25 MHz sensor clocks. + let slow_clk_div = 2u32; + let sensor_clk_div = 8u32; + write_local_reg_u32_raw(writer, BROADCAST_ASIC, LOCAL_REG_SLOW_CLK_DIV, slow_clk_div).await?; + write_local_reg_u32_raw( + writer, + BROADCAST_ASIC, + LOCAL_REG_SENSOR_CLK_DIV, + (sensor_clk_div << 5) | sensor_clk_div, + ) + .await?; + write_local_reg_u32_raw(writer, BROADCAST_ASIC, LOCAL_REG_DTS_SRST_PD, 1 << 8).await?; + write_local_reg_u32_raw( + writer, + BROADCAST_ASIC, + LOCAL_REG_SENS_TDM_GAP_CNT, + config.tdm_interval as u32, + ) + .await?; + + let cfg0_ts_resolution = match THERMAL_SENSOR_RESOLUTION { + 10 => 1, + 8 => 2, + _ => 0, + }; + write_local_reg_u32_raw( + writer, + BROADCAST_ASIC, + LOCAL_REG_DTS_CFG, + ((cfg0_ts_resolution as u32) << 5) | THERMAL_SENSOR_MODE as u32, + ) + .await?; + + let thermal_threshold_cnt = 10u32; + let voltage_ch0_threshold_cnt = 10u32; + write_local_reg_u32_raw( + writer, + BROADCAST_ASIC, + LOCAL_REG_SENSOR_THRS_CNT, + (thermal_threshold_cnt << 16) | voltage_ch0_threshold_cnt, + ) + .await?; + + let thermal_trip_code = legacy_temperature_c_to_tune_code(config.thermal_trip_c); + write_local_reg_u32_raw( + writer, + BROADCAST_ASIC, + LOCAL_REG_TEMPSENSOR_TUNE_CODE, + 0x8001 | (thermal_trip_code << 1), + ) + .await?; + + let bandgap = read_local_reg_u32_raw(reader, writer, BROADCAST_ASIC, LOCAL_REG_BANDGAP).await?; + write_local_reg_u32_raw( + writer, + BROADCAST_ASIC, + LOCAL_REG_BANDGAP, + (bandgap & !0x0f) | 0x03, + ) + .await?; + + write_local_reg_u32_raw(writer, BROADCAST_ASIC, LOCAL_REG_VSENSOR_SRST_PD, 1 << 8).await?; + + let cfg0_vs_resolution = match VOLTAGE_SENSOR_RESOLUTION { + 12 => 1, + 10 => 2, + 8 => 3, + _ => 0, + }; + let gap_cnt = 8u32; + write_local_reg_u32_raw( + writer, + BROADCAST_ASIC, + LOCAL_REG_VSENSOR_CFG, + (gap_cnt << 28) + | ((VOLTAGE_SENSOR_CONVERSION_MODE as u32) << 24) + | ((cfg0_vs_resolution as u32) << 5) + | VOLTAGE_SENSOR_MODE as u32, + ) + .await?; + + write_local_reg_u32_raw( + writer, + BROADCAST_ASIC, + LOCAL_REG_VOLTAGE_SENSOR_ENABLE, + (legacy_voltage_mv_to_tune_code(config.voltage_ch1_shutdown_mv) << 16) + | (legacy_voltage_mv_to_tune_code(config.voltage_ch0_shutdown_mv) << 1) + | 1, + ) + .await?; + + Ok(()) +} + +pub async fn read_dts_vs_frame_stream( + reader: &mut SerialReader, + generation: DtsVsGeneration, + asic: u8, + timeout: Duration, +) -> Result { + let deadline = tokio::time::Instant::now() + timeout; + let mut parser = TdmFrameParser::new(generation); + let mut read_buf = [0u8; 256]; + + loop { + let now = tokio::time::Instant::now(); + if now >= deadline { + return Err(Bzm2UartError::DtsVsTimeout { asic }); + } + let remaining = deadline.saturating_duration_since(now); + let read = tokio::time::timeout(remaining, reader.read(&mut read_buf)) + .await + .map_err(|_| Bzm2UartError::DtsVsTimeout { asic })??; + if read == 0 { + return Err(Bzm2UartError::ShortResponse { + expected: 1, + actual: 0, + }); + } + for frame in parser.push(&read_buf[..read]) { + if let TdmFrame::DtsVs(frame) = frame { + let frame_asic = match frame { + TdmDtsVsFrame::Gen1(gen1) => gen1.asic, + TdmDtsVsFrame::Gen2(gen2) => gen2.asic, + }; + if frame_asic == asic { + return Ok(frame); + } + } + } + } +} + +async fn write_local_reg_u32_raw( + writer: &mut SerialWriter, + asic: u8, + offset: u8, + value: u32, +) -> Result<(), Bzm2UartError> { + writer + .write_all(&encode_write_register( + asic, + NOTCH_REG, + offset, + &value.to_le_bytes(), + )) + .await?; + writer.flush().await?; + Ok(()) +} + +async fn read_local_reg_u32_raw( + reader: &mut SerialReader, + writer: &mut SerialWriter, + asic: u8, + offset: u8, +) -> Result { + let request = encode_read_register(asic, NOTCH_REG, offset, 4); + writer.write_all(&request).await?; + writer.flush().await?; + + let mut response = [0u8; 6]; + reader.read_exact(&mut response).await?; + validate_response_header(asic, OPCODE_UART_READREG, &response)?; + Ok(u32::from_le_bytes(response[2..6].try_into().unwrap())) +} + +fn legacy_temperature_c_to_tune_code(temperature_c: i32) -> u32 { + let resolution_power = match THERMAL_SENSOR_RESOLUTION { + 10 => 1024.0_f32, + 8 => 256.0_f32, + _ => 4096.0_f32, + }; + (2048.0 / resolution_power + 4096.0 * (temperature_c as f32 + 293.8) / 631.8) as u32 +} + +fn legacy_voltage_mv_to_tune_code(voltage_mv: u32) -> u32 { + let resolution_power = match VOLTAGE_SENSOR_RESOLUTION { + 12 => 4096.0_f32, + 10 => 1024.0_f32, + 8 => 256.0_f32, + _ => 16384.0_f32, + }; + ((16384.0 / 6.0) * (2.5 * voltage_mv as f32 / 706.7 + 3.0 / resolution_power + 1.0)) as u32 } fn validate_response_header( @@ -306,4 +561,14 @@ mod tests { } )); } + + #[test] + fn legacy_temperature_query_threshold_matches_legacy_formula_family() { + assert_eq!(legacy_temperature_c_to_tune_code(115), 2650); + } + + #[test] + fn legacy_voltage_query_threshold_matches_legacy_formula_family() { + assert_eq!(legacy_voltage_mv_to_tune_code(500), 7561); + } } diff --git a/mujina-miner/src/asic/hash_thread.rs b/mujina-miner/src/asic/hash_thread.rs index e30c9377..0d3ccfbb 100644 --- a/mujina-miner/src/asic/hash_thread.rs +++ b/mujina-miner/src/asic/hash_thread.rs @@ -75,6 +75,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 @@ -100,8 +123,35 @@ pub enum HashThreadEvent { /// Periodic status update StatusUpdate(HashThreadStatus), + + /// 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("Shutdown timeout")] + ShutdownTimeout, + + #[error("Chip initialization failed: {0}")] + InitializationFailed(String), +} // --------------------------------------------------------------------------- // Hardware abstraction traits for hash threads // --------------------------------------------------------------------------- diff --git a/mujina-miner/src/bin/bzm2-debug.rs b/mujina-miner/src/bin/bzm2-debug.rs index 40086df8..49876cab 100644 --- a/mujina-miner/src/bin/bzm2-debug.rs +++ b/mujina-miner/src/bin/bzm2-debug.rs @@ -10,7 +10,8 @@ use mujina_miner::asic::bzm2::protocol::{ encode_read_result_command, logical_engine_address, }; use mujina_miner::asic::bzm2::{ - BROADCAST_GROUP_ASIC, Bzm2ClockController, Bzm2Dll, Bzm2Pll, Bzm2UartController, NOTCH_REG, + BROADCAST_GROUP_ASIC, Bzm2ClockController, Bzm2Dll, Bzm2DtsVsConfig, Bzm2Pll, + Bzm2UartController, DEFAULT_DTS_VS_QUERY_TIMEOUT, NOTCH_REG, }; use mujina_miner::transport::{SerialReader, SerialStream, SerialWriter}; use sha2::{Digest, Sha256}; @@ -38,6 +39,8 @@ async fn main() -> Result<()> { "uart-noop" => cmd_uart_noop(&args[2..]).await, "uart-loopback" => cmd_uart_loopback(&args[2..]).await, "uart-read-result" => cmd_uart_read_result(&args[2..]).await, + "dts-vs-query" => cmd_dts_vs_query(&args[2..]).await, + "dts-vs-scan" => cmd_dts_vs_scan(&args[2..]).await, "noop-scan" => cmd_noop_scan(&args[2..]).await, "loopback-scan" => cmd_loopback_scan(&args[2..]).await, "tdm-enable" => cmd_tdm_enable(&args[2..]).await, @@ -71,6 +74,8 @@ fn print_usage() { eprintln!(" uart-noop [baud]"); eprintln!(" uart-loopback [baud]"); eprintln!(" uart-read-result [baud]"); + eprintln!(" dts-vs-query [timeout-ms] [baud]"); + eprintln!(" dts-vs-scan [timeout-ms] [baud]"); eprintln!(" noop-scan [baud]"); eprintln!(" loopback-scan [baud]"); eprintln!(); @@ -129,6 +134,19 @@ fn parse_watch_duration(raw: &str) -> Result { Ok(Duration::from_secs_f32(seconds)) } +fn parse_timeout_duration(raw: Option<&String>) -> Result { + match raw { + Some(raw) => { + let millis = parse_u32(raw)?; + if millis == 0 { + bail!("timeout-ms must be greater than zero"); + } + Ok(Duration::from_millis(millis as u64)) + } + None => Ok(DEFAULT_DTS_VS_QUERY_TIMEOUT), + } +} + fn parse_dts_generation(raw: &str) -> Result { DtsVsGeneration::from_env_value(raw) .ok_or_else(|| anyhow::anyhow!("invalid DTS/VS generation: {raw}")) @@ -348,6 +366,42 @@ async fn cmd_uart_read_result(args: &[String]) -> Result<()> { Ok(()) } +async fn cmd_dts_vs_query(args: &[String]) -> Result<()> { + if args.len() < 3 || args.len() > 5 { + bail!("usage: dts-vs-query [timeout-ms] [baud]"); + } + + let mut uart = open_uart(&args[0], parse_baud(args.get(4))?)?; + let asic = parse_u8(&args[1])?; + let generation = parse_dts_generation(&args[2])?; + let timeout = parse_timeout_duration(args.get(3))?; + let frame = uart + .query_dts_vs(asic, generation, Bzm2DtsVsConfig::default(), timeout) + .await?; + print_dts_vs_query_frame(&frame); + Ok(()) +} + +async fn cmd_dts_vs_scan(args: &[String]) -> Result<()> { + if args.len() < 4 || args.len() > 6 { + bail!("usage: dts-vs-scan [timeout-ms] [baud]"); + } + + let mut uart = open_uart(&args[0], parse_baud(args.get(5))?)?; + let asics = parse_asic_range(&args[1], &args[2])?; + let generation = parse_dts_generation(&args[3])?; + let timeout = parse_timeout_duration(args.get(4))?; + + for asic in asics { + let frame = uart + .query_dts_vs(asic, generation, Bzm2DtsVsConfig::default(), timeout) + .await?; + print_dts_vs_query_frame(&frame); + } + + Ok(()) +} + async fn cmd_noop_scan(args: &[String]) -> Result<()> { if args.len() < 3 || args.len() > 4 { bail!("usage: noop-scan [baud]"); @@ -1035,6 +1089,79 @@ fn print_tdm_frame(frame: &TdmFrame) { } } +fn print_dts_vs_query_frame(frame: &mujina_miner::asic::bzm2::protocol::TdmDtsVsFrame) { + match frame { + mujina_miner::asic::bzm2::protocol::TdmDtsVsFrame::Gen1(frame) => { + println!( + "asic={} gen=1 voltage_v={} temperature_c={} thermal_valid={} voltage_enabled={}", + frame.asic, + frame + .voltage_enabled + .then(|| format!("{:.4}", legacy_tune_code_to_voltage_v(frame.voltage))) + .unwrap_or_else(|| "n/a".into()), + if frame.thermal_enabled && frame.thermal_validity { + format!( + "{:.2}", + legacy_gen1_tune_code_to_temperature_c(frame.thermal_tune_code) + ) + } else { + "n/a".into() + }, + frame.thermal_validity, + frame.voltage_enabled, + ); + } + mujina_miner::asic::bzm2::protocol::TdmDtsVsFrame::Gen2(frame) => { + let temperature = if frame.thermal_enabled && frame.thermal_validity { + format!( + "{:.2}", + legacy_tune_code_to_temperature_c(frame.thermal_tune_code) + ) + } else { + "n/a".into() + }; + let ch0 = frame + .voltage_enabled + .then(|| format!("{:.4}", legacy_tune_code_to_voltage_v(frame.ch0_voltage))) + .unwrap_or_else(|| "n/a".into()); + let ch1 = frame + .voltage_enabled + .then(|| format!("{:.4}", legacy_tune_code_to_voltage_v(frame.ch1_voltage))) + .unwrap_or_else(|| "n/a".into()); + let ch2 = frame + .voltage_enabled + .then(|| format!("{:.4}", legacy_tune_code_to_voltage_v(frame.ch2_voltage))) + .unwrap_or_else(|| "n/a".into()); + println!( + "asic={} gen=2 temperature_c={} voltage_ch0_v={} voltage_ch1_v={} voltage_ch2_v={} thermal_trip={} thermal_fault={} voltage_fault={} pll_lock={} dll0_lock={} dll1_lock={}", + frame.asic, + temperature, + ch0, + ch1, + ch2, + frame.thermal_trip_status, + frame.thermal_fault, + frame.voltage_fault, + frame.pll_lock, + frame.dll0_lock, + frame.dll1_lock, + ); + } + } +} + +fn legacy_gen1_tune_code_to_temperature_c(tune_code: u8) -> f32 { + -293.8 + 631.8 * ((tune_code as f32) - (2048.0 / 4096.0)) / 4096.0 +} + +fn legacy_tune_code_to_temperature_c(tune_code: u16) -> f32 { + -293.8 + 631.8 * ((tune_code as f32) - (2048.0 / 4096.0)) / 4096.0 +} + +fn legacy_tune_code_to_voltage_v(tune_code: u16) -> f32 { + 0.4 * 0.7067 * (6.0 * (tune_code as f32) / 16384.0 - 3.0 / 16384.0 - 1.0) +} + fn synthetic_job_material( asic: u8, engine_address: u16, diff --git a/mujina-miner/src/board/bitaxe.rs b/mujina-miner/src/board/bitaxe.rs index 82c26c0c..d3e38f9d 100644 --- a/mujina-miner/src/board/bitaxe.rs +++ b/mujina-miner/src/board/bitaxe.rs @@ -16,7 +16,7 @@ use tokio_stream::StreamExt; use tokio_util::codec::{FramedRead, FramedWrite}; use crate::{ - api_client::types::{BoardTelemetry, Fan, PowerMeasurement, TemperatureSensor}, + api_client::types::{BoardState, Fan, PowerMeasurement, TemperatureSensor}, asic::{ ChipInfo, bm13xx::{self, BM13xxProtocol, protocol::Command, thread::BM13xxThread}, @@ -29,7 +29,6 @@ use crate::{ mgmt_protocol::{ ControlChannel, bitaxe_raw::{ - ResponseFormat, gpio::{BitaxeRawGpioController, BitaxeRawGpioPin}, i2c::BitaxeRawI2c, }, @@ -40,7 +39,6 @@ use crate::{ }, tracing::prelude::*, transport::serial::{SerialControl, SerialReader, SerialStream, SerialWriter}, - types::Temperature, }; use super::{ @@ -144,9 +142,9 @@ pub struct BitaxeBoard { stats_task_handle: Option>, /// Serial number from USB device info serial_number: Option, - /// Channel for publishing board telemetry to the API server. + /// Channel for publishing board state to the API server. /// Taken by `spawn_stats_monitor` which publishes periodic snapshots. - telemetry_tx: Option>, + state_tx: Option>, } impl BitaxeBoard { @@ -177,10 +175,10 @@ impl BitaxeBoard { control: tokio_serial::SerialStream, data_path: &str, serial_number: Option, - telemetry_tx: watch::Sender, + state_tx: watch::Sender, ) -> Result { - // Bitaxe runs original bitaxe-raw firmware (v0 response format) - let control_channel = ControlChannel::new(control, ResponseFormat::V0); + // Create control channel and I2C controller + let control_channel = ControlChannel::new(control); let i2c = BitaxeRawI2c::new(control_channel.clone()); // Create SerialStream for data channel at initial baud rate @@ -204,7 +202,7 @@ impl BitaxeBoard { thread_shutdown: None, stats_task_handle: None, serial_number, - telemetry_tx: Some(telemetry_tx), + state_tx: Some(state_tx), }) } @@ -700,10 +698,10 @@ impl BitaxeBoard { let board_serial = board_info.serial_number.clone(); // Take the state sender so this task owns publishing - let telemetry_tx = self - .telemetry_tx + let state_tx = self + .state_tx .take() - .expect("telemetry_tx must be present when spawning stats monitor"); + .expect("state_tx must be present when spawning stats monitor"); let handle = tokio::spawn(async move { const STATS_INTERVAL: Duration = Duration::from_secs(5); @@ -761,9 +759,9 @@ impl BitaxeBoard { } } - // -- Publish BoardTelemetry -- + // -- Publish BoardState -- - let _ = telemetry_tx.send(BoardTelemetry { + let _ = state_tx.send(BoardState { name: board_name.clone(), model: board_model.clone(), serial: board_serial.clone(), @@ -776,11 +774,11 @@ impl BitaxeBoard { temperatures: vec![ TemperatureSensor { name: "asic".into(), - temperature: asic_temp.map(Temperature::from_celsius), + temperature_c: asic_temp, }, TemperatureSensor { name: "vr".into(), - temperature: vr_temp.map(|t| Temperature::from_celsius(t as f32)), + temperature_c: vr_temp.map(|t| t as f32), }, ], powers: vec![ @@ -931,7 +929,16 @@ async fn create_from_usb( ) -> Result<(Box, super::BoardRegistration)> { use tokio_serial::SerialPortBuilderExt; - let serial_ports = device.get_serial_ports(2).await?; + // Get serial ports + let serial_ports = device.serial_ports()?; + + // Bitaxe Gamma requires exactly 2 serial ports + if serial_ports.len() != 2 { + bail!( + "Bitaxe Gamma requires exactly 2 serial ports, found {}", + serial_ports.len() + ); + } debug!( serial = ?device.serial_number, @@ -943,22 +950,22 @@ async fn create_from_usb( // Open control port at 115200 baud let control_port = tokio_serial::new(&serial_ports[0], 115200).open_native_async()?; - // Create watch channel for board telemetry, seeded with identity + // Create watch channel for board state, seeded with identity let serial = device.serial_number.clone(); - let initial_state = BoardTelemetry { + let initial_state = BoardState { name: format!("bitaxe-{}", serial.as_deref().unwrap_or("unknown")), model: "Bitaxe Gamma".into(), serial, ..Default::default() }; - let (telemetry_tx, telemetry_rx) = watch::channel(initial_state); + let (state_tx, state_rx) = watch::channel(initial_state); // Create the board with the control port and data port path let mut board = BitaxeBoard::new( control_port, &serial_ports[1], device.serial_number.clone(), - telemetry_tx, + state_tx, ) .context("failed to create board")?; @@ -973,7 +980,10 @@ async fn create_from_usb( board.chip_count() ); - let registration = super::BoardRegistration { telemetry_rx }; + let registration = super::BoardRegistration { + state_rx, + command_tx: None, + }; Ok((Box::new(board), registration)) } @@ -983,7 +993,6 @@ inventory::submit! { pattern: crate::board::pattern::BoardPattern { vid: Match::Any, pid: Match::Any, - bcd_device: Match::Any, manufacturer: Match::Specific(StringMatch::Exact("OSMU")), product: Match::Specific(StringMatch::Exact("Bitaxe")), serial_pattern: Match::Any, diff --git a/mujina-miner/src/board/bzm2.rs b/mujina-miner/src/board/bzm2.rs index 40ae9474..c2b11361 100644 --- a/mujina-miner/src/board/bzm2.rs +++ b/mujina-miner/src/board/bzm2.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; use tokio::sync::{mpsc, watch}; use tokio::task::JoinHandle; -use super::{Board, BoardError, BoardInfo, VirtualBoardDescriptor}; +use super::{Board, BoardCommand, BoardError, BoardInfo, VirtualBoardDescriptor}; use crate::{ api_client::types::{BoardState, Fan, PowerMeasurement, TemperatureSensor, ThreadState}, asic::{ @@ -22,7 +22,7 @@ use crate::{ }, hash_thread::{ HashTask, HashThread, HashThreadCapabilities, HashThreadError, HashThreadEvent, - HashThreadStatus, + HashThreadStatus, HashThreadTelemetryUpdate, }, }, tracing::prelude::*, @@ -548,19 +548,29 @@ pub struct Bzm2Board { shutdown_handles: Vec, serial_controls: Vec, state_tx: watch::Sender, + command_rx: Option>, monitor_shutdown: Option>, monitor_task: Option>, + command_shutdown: Option>, + command_task: Option>, } impl Bzm2Board { - pub fn new(config: Bzm2VirtualDeviceConfig, state_tx: watch::Sender) -> Self { + pub fn new( + config: Bzm2VirtualDeviceConfig, + state_tx: watch::Sender, + command_rx: mpsc::Receiver, + ) -> Self { Self { config, shutdown_handles: Vec::new(), serial_controls: Vec::new(), state_tx, + command_rx: Some(command_rx), monitor_shutdown: None, monitor_task: None, + command_shutdown: None, + command_task: None, } } @@ -590,8 +600,8 @@ impl Bzm2Board { }); let _ = state_tx.send_modify(|state| { state.fans = snapshot.fans.clone(); - state.temperatures = snapshot.temperatures.clone(); - state.powers = snapshot.powers.clone(); + merge_temperature_readings(&mut state.temperatures, &snapshot.temperatures); + merge_power_readings(&mut state.powers, &snapshot.powers); }); trace!(board = %board_name, bytes_read = total_stats.0, bytes_written = total_stats.1, "BZM2 board telemetry updated"); if let Some(reason) = snapshot.trip_reason.clone() { @@ -618,6 +628,57 @@ impl Bzm2Board { })); } + fn spawn_command_loop(&mut self) { + if self.command_task.is_some() { + return; + } + let Some(mut command_rx) = self.command_rx.take() else { + return; + }; + + let state_tx = self.state_tx.clone(); + let shutdown_handles = self.shutdown_handles.clone(); + let board_name = self.config.device_id(); + let (shutdown_tx, mut shutdown_rx) = watch::channel(false); + self.command_shutdown = Some(shutdown_tx); + + self.command_task = Some(tokio::spawn(async move { + loop { + tokio::select! { + command = command_rx.recv() => { + let Some(command) = command else { + break; + }; + match command { + BoardCommand::QueryBzm2DtsVs { thread_index, asic, reply } => { + let result = async { + let handle = shutdown_handles.get(thread_index).ok_or_else(|| { + BoardError::HardwareControl(format!( + "invalid BZM2 thread index {thread_index} for board {board_name}" + )) + })?; + let update = handle + .query_dts_vs(asic) + .await + .map_err(|err| BoardError::HardwareControl(err.to_string()))?; + publish_thread_telemetry(&state_tx, &update); + Ok(()) + } + .await; + let _ = reply.send(result); + } + } + } + changed = shutdown_rx.changed() => { + if changed.is_err() || *shutdown_rx.borrow() { + break; + } + } + } + } + })); + } + async fn execute_live_calibration(&self) -> Result<(), BoardError> { let calibration = &self.config.calibration; if !calibration.enabled { @@ -911,14 +972,21 @@ impl Board for Bzm2Board { if let Some(tx) = self.monitor_shutdown.take() { let _ = tx.send(true); } + if let Some(tx) = self.command_shutdown.take() { + let _ = tx.send(true); + } if let Some(handle) = self.monitor_task.take() { let _ = handle.await; } + if let Some(handle) = self.command_task.take() { + let _ = handle.await; + } for handle in &self.shutdown_handles { handle.shutdown(); } self.shutdown_handles.clear(); self.serial_controls.clear(); + self.command_rx = None; let _ = self.state_tx.send_modify(|state| { for thread in &mut state.threads { thread.is_active = false; @@ -934,8 +1002,8 @@ impl Board for Bzm2Board { let initial_snapshot = self.config.telemetry.snapshot(); let _ = self.state_tx.send_modify(|state| { state.fans = initial_snapshot.fans.clone(); - state.temperatures = initial_snapshot.temperatures.clone(); - state.powers = initial_snapshot.powers.clone(); + merge_temperature_readings(&mut state.temperatures, &initial_snapshot.temperatures); + merge_power_readings(&mut state.powers, &initial_snapshot.powers); }); self.execute_live_calibration().await?; @@ -976,6 +1044,7 @@ impl Board for Bzm2Board { }); self.spawn_monitor(); + self.spawn_command_loop(); Ok(threads) } } @@ -1044,8 +1113,14 @@ impl HashThread for Bzm2ManagedThread { let thread_index = self.thread_index; tokio::spawn(async move { while let Some(event) = inner_rx.recv().await { - if let HashThreadEvent::StatusUpdate(ref status) = event { - publish_thread_status(&state_tx, thread_index, status); + match &event { + HashThreadEvent::StatusUpdate(status) => { + publish_thread_status(&state_tx, thread_index, status); + } + HashThreadEvent::TelemetryUpdate(update) => { + publish_thread_telemetry(&state_tx, update); + } + _ => {} } if event_tx.send(event).await.is_err() { break; @@ -1072,6 +1147,70 @@ fn publish_thread_status( } }); } + +fn publish_thread_telemetry( + state_tx: &watch::Sender, + update: &HashThreadTelemetryUpdate, +) { + let _ = state_tx.send_modify(|state| { + merge_temperature_readings( + &mut state.temperatures, + &update + .temperatures + .iter() + .map(|reading| TemperatureSensor { + name: reading.name.clone(), + temperature_c: reading.temperature_c, + }) + .collect::>(), + ); + merge_power_readings( + &mut state.powers, + &update + .powers + .iter() + .map(|reading| PowerMeasurement { + name: reading.name.clone(), + voltage_v: reading.voltage_v, + current_a: reading.current_a, + power_w: reading.power_w, + }) + .collect::>(), + ); + }); +} + +fn merge_temperature_readings( + existing: &mut Vec, + updates: &[TemperatureSensor], +) { + for update in updates { + if let Some(sensor) = existing + .iter_mut() + .find(|sensor| sensor.name == update.name) + { + sensor.temperature_c = update.temperature_c; + } else { + existing.push(update.clone()); + } + } +} + +fn merge_power_readings(existing: &mut Vec, updates: &[PowerMeasurement]) { + for update in updates { + if let Some(sensor) = existing + .iter_mut() + .find(|sensor| sensor.name == update.name) + { + sensor.voltage_v = update.voltage_v; + sensor.current_a = update.current_a; + sensor.power_w = update.power_w; + } else { + existing.push(update.clone()); + } + } +} + fn build_bus_layouts(serial_paths: &[String], asics_per_bus: &[u16]) -> Vec { let mut next_asic = 0u16; serial_paths @@ -1421,9 +1560,13 @@ async fn create_bzm2_board() ..Default::default() }; let (state_tx, state_rx) = watch::channel(initial_state); + let (command_tx, command_rx) = mpsc::channel(16); - let board = Bzm2Board::new(config, state_tx); - let registration = super::BoardRegistration { state_rx }; + let board = Bzm2Board::new(config, state_tx, command_rx); + let registration = super::BoardRegistration { + state_rx, + command_tx: Some(command_tx), + }; Ok((Box::new(board), registration)) } @@ -1482,7 +1625,7 @@ mod tests { serial: Some("bzm2-test".into()), ..Default::default() }); - let board = Bzm2Board::new(config, state_tx); + let board = Bzm2Board::new(config, state_tx, mpsc::channel(1).1); board.execute_live_calibration().await.unwrap(); @@ -1553,7 +1696,7 @@ mod tests { serial: Some("bzm2-test".into()), ..Default::default() }); - let board = Bzm2Board::new(config, state_tx); + let board = Bzm2Board::new(config, state_tx, mpsc::channel(1).1); board.execute_live_calibration().await.unwrap(); @@ -1615,7 +1758,7 @@ mod tests { serial: Some("bzm2-test".into()), ..Default::default() }); - let mut board = Bzm2Board::new(config, state_tx); + let mut board = Bzm2Board::new(config, state_tx, mpsc::channel(1).1); let mut threads = board.create_hash_threads().await.unwrap(); let mut event_rx = threads[0].take_event_receiver().unwrap(); @@ -1680,6 +1823,67 @@ mod tests { ); } + #[test] + fn publish_thread_telemetry_updates_board_state() { + let (state_tx, state_rx) = watch::channel(BoardState { + name: "bzm2-test".into(), + model: "BZM2".into(), + serial: Some("bzm2-test".into()), + temperatures: vec![TemperatureSensor { + name: "host-board-temp".into(), + temperature_c: Some(52.0), + }], + powers: vec![PowerMeasurement { + name: "host-input".into(), + voltage_v: Some(12.0), + current_a: Some(10.0), + power_w: Some(120.0), + }], + ..Default::default() + }); + + publish_thread_telemetry( + &state_tx, + &HashThreadTelemetryUpdate { + temperatures: vec![crate::asic::hash_thread::HashThreadTemperatureReading { + name: "ttyUSB0-asic-2-dts".into(), + temperature_c: Some(64.5), + }], + powers: vec![crate::asic::hash_thread::HashThreadPowerReading { + name: "ttyUSB0-asic-2-vs-ch0".into(), + voltage_v: Some(0.78), + current_a: None, + power_w: None, + }], + }, + ); + + let state = state_rx.borrow().clone(); + assert_eq!(state.temperatures.len(), 2); + assert!( + state.temperatures.iter().any( + |sensor| sensor.name == "host-board-temp" && sensor.temperature_c == Some(52.0) + ) + ); + assert!(state.temperatures.iter().any( + |sensor| sensor.name == "ttyUSB0-asic-2-dts" && sensor.temperature_c == Some(64.5) + )); + assert_eq!(state.powers.len(), 2); + assert!( + state + .powers + .iter() + .any(|sensor| sensor.name == "host-input" && sensor.voltage_v == Some(12.0)) + ); + assert!( + state + .powers + .iter() + .any(|sensor| sensor.name == "ttyUSB0-asic-2-vs-ch0" + && sensor.voltage_v == Some(0.78)) + ); + } + #[test] fn publish_thread_status_updates_state_slot() { let (state_tx, state_rx) = watch::channel(BoardState { diff --git a/mujina-miner/src/board/cpu.rs b/mujina-miner/src/board/cpu.rs index 28e1a27a..20a4729c 100644 --- a/mujina-miner/src/board/cpu.rs +++ b/mujina-miner/src/board/cpu.rs @@ -9,7 +9,7 @@ use tokio::sync::watch; use super::{Board, BoardInfo, VirtualBoardDescriptor}; use crate::{ - api_client::types::BoardTelemetry, + api_client::types::BoardState, asic::hash_thread::HashThread, cpu_miner::{CpuHashThread, CpuMinerConfig}, }; @@ -26,18 +26,18 @@ pub struct CpuBoard { /// Threads created by this board (kept for shutdown). threads: Vec, - /// Channel for publishing board telemetry to the API server. + /// Channel for publishing board state to the API server. #[expect(dead_code, reason = "will publish telemetry in a follow-up commit")] - telemetry_tx: watch::Sender, + state_tx: watch::Sender, } impl CpuBoard { /// Create a new CPU mining board from environment configuration. - pub fn new(config: CpuMinerConfig, telemetry_tx: watch::Sender) -> Self { + pub fn new(config: CpuMinerConfig, state_tx: watch::Sender) -> Self { Self { config, threads: Vec::new(), - telemetry_tx, + state_tx, } } } @@ -86,16 +86,19 @@ async fn create_cpu_board() -> Result<(Box, super::BoardRegist .ok_or_else(|| anyhow!("cpu miner not configured (MUJINA_CPU_MINER not set)"))?; let serial = format!("cpu-{}x{}%", config.thread_count, config.duty_percent); - let initial_state = BoardTelemetry { + let initial_state = BoardState { name: serial.clone(), model: "CPU Miner".into(), serial: Some(serial), ..Default::default() }; - let (telemetry_tx, telemetry_rx) = watch::channel(initial_state); + let (state_tx, state_rx) = watch::channel(initial_state); - let board = CpuBoard::new(config, telemetry_tx); - let registration = super::BoardRegistration { telemetry_rx }; + let board = CpuBoard::new(config, state_tx); + let registration = super::BoardRegistration { + state_rx, + command_tx: None, + }; Ok((Box::new(board), registration)) } diff --git a/mujina-miner/src/board/emberone.rs b/mujina-miner/src/board/emberone.rs new file mode 100644 index 00000000..1ec2c362 --- /dev/null +++ b/mujina-miner/src/board/emberone.rs @@ -0,0 +1,127 @@ +//! EmberOne mining board support (stub). +//! +//! The EmberOne is a mining board with 12 BM1362 ASIC chips, communicating via +//! USB using the bitaxe-raw protocol (same as Bitaxe boards). +//! +//! This is currently a stub implementation pending full support. + +use anyhow::{Result, bail}; +use async_trait::async_trait; +use tokio::sync::watch; + +use super::{ + Board, BoardDescriptor, BoardInfo, + pattern::{BoardPattern, Match, StringMatch}, +}; +use crate::{ + api_client::types::BoardState, asic::hash_thread::HashThread, transport::UsbDeviceInfo, +}; + +/// EmberOne mining board (stub). +pub struct EmberOne { + device_info: UsbDeviceInfo, + + /// Channel for publishing board state to the API server. + #[expect(dead_code, reason = "will publish telemetry in a follow-up commit")] + state_tx: watch::Sender, +} + +impl EmberOne { + /// Create a new EmberOne board instance. + pub fn new(device_info: UsbDeviceInfo, state_tx: watch::Sender) -> Result { + Ok(Self { + device_info, + state_tx, + }) + } +} + +#[async_trait] +impl Board for EmberOne { + fn board_info(&self) -> BoardInfo { + BoardInfo { + model: "EmberOne".to_string(), + firmware_version: None, + serial_number: self.device_info.serial_number.clone(), + } + } + + async fn shutdown(&mut self) -> Result<()> { + tracing::info!("EmberOne stub shutdown (no-op)"); + Ok(()) + } + + async fn create_hash_threads(&mut self) -> Result>> { + bail!("EmberOne hash threads not yet implemented") + } +} + +// Factory function to create EmberOne board from USB device info +async fn create_from_usb( + device: UsbDeviceInfo, +) -> Result<(Box, super::BoardRegistration)> { + let serial = device.serial_number.clone(); + let initial_state = BoardState { + name: format!("emberone-{}", serial.as_deref().unwrap_or("unknown")), + model: "EmberOne".into(), + serial, + ..Default::default() + }; + let (state_tx, state_rx) = watch::channel(initial_state); + + let board = EmberOne::new(device, state_tx)?; + + let registration = super::BoardRegistration { + state_rx, + command_tx: None, + }; + Ok((Box::new(board), registration)) +} + +// Register this board type with the inventory system +inventory::submit! { + BoardDescriptor { + pattern: BoardPattern { + vid: Match::Any, + pid: Match::Any, + manufacturer: Match::Specific(StringMatch::Exact("256F")), + product: Match::Specific(StringMatch::Exact("EmberOne00")), + serial_pattern: Match::Any, + }, + name: "EmberOne", + create_fn: |device| Box::pin(create_from_usb(device)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_board_creation() { + let device = UsbDeviceInfo::new_for_test( + 0xc0de, + 0xcafe, + Some("TEST001".to_string()), + Some("EmberOne".to_string()), + Some("Mining Board".to_string()), + "/sys/devices/test".to_string(), + ); + + let (state_tx, _state_rx) = watch::channel(BoardState { + name: format!( + "emberone-{}", + device.serial_number.as_deref().unwrap_or("unknown") + ), + model: "EmberOne".into(), + serial: device.serial_number.clone(), + ..Default::default() + }); + + let board = EmberOne::new(device, state_tx); + assert!(board.is_ok()); + + let board = board.unwrap(); + assert_eq!(board.board_info().model, "EmberOne"); + } +} diff --git a/mujina-miner/src/board/mod.rs b/mujina-miner/src/board/mod.rs index 6366ce81..aba6d90b 100644 --- a/mujina-miner/src/board/mod.rs +++ b/mujina-miner/src/board/mod.rs @@ -1,16 +1,16 @@ pub(crate) mod bitaxe; pub(crate) mod bzm2; pub mod cpu; -pub(crate) mod emberone00; +pub(crate) mod emberone; pub mod pattern; use anyhow::Result; use async_trait::async_trait; -use std::{future::Future, pin::Pin}; -use tokio::sync::watch; +use std::{error::Error, fmt, future::Future, pin::Pin}; +use tokio::sync::{mpsc, oneshot, watch}; use crate::{ - api_client::types::BoardTelemetry, asic::hash_thread::HashThread, transport::UsbDeviceInfo, + api_client::types::BoardState, asic::hash_thread::HashThread, transport::UsbDeviceInfo, }; /// Represents a mining board containing one or more ASIC chips. @@ -57,8 +57,21 @@ pub struct BoardInfo { /// with a board. The backplane forwards this to the API server after /// creating a board. pub struct BoardRegistration { - /// Watch receiver for the board's telemetry. - pub telemetry_rx: watch::Receiver, + /// Watch receiver for the board's current state. + pub state_rx: watch::Receiver, + + /// Optional command sender for board-specific control and queries. + pub command_tx: Option>, +} + +/// Board-specific control and query commands exposed to higher layers. +pub enum BoardCommand { + /// Query one BZM2 ASIC's internal DTS/VS telemetry through a live hash thread. + QueryBzm2DtsVs { + thread_index: usize, + asic: u8, + reply: oneshot::Sender>, + }, } /// Helper type for async board factory functions @@ -69,7 +82,7 @@ type BoxFuture<'a, T> = Pin + Send + 'a>>; /// The factory is responsible for: /// /// 1. Opening hardware resources (serial ports, etc.) -/// 2. Creating a `watch::channel` seeded with the board's +/// 2. Creating a `watch::channel` seeded with the board's /// identity (model, serial) and storing the sender in the board /// 3. Initializing the board hardware /// 4. Returning the board and a [`BoardRegistration`] containing the diff --git a/mujina-miner/src/scheduler.rs b/mujina-miner/src/scheduler.rs index 795a40e1..691ff2e8 100644 --- a/mujina-miner/src/scheduler.rs +++ b/mujina-miner/src/scheduler.rs @@ -559,6 +559,10 @@ impl Scheduler { "Thread status" ); } + + HashThreadEvent::TelemetryUpdate(_) => { + trace!(thread = %thread_name, "Thread telemetry update"); + } } } From 44662ba6e28312bb4cae1c824ddbf7beba766bc7 Mon Sep 17 00:00:00 2001 From: Reckless Apotheosis <125509978+recklessnode@users.noreply.github.com> Date: Fri, 6 Mar 2026 15:57:52 -0800 Subject: [PATCH 07/24] feat(bzm2): add chain enumeration and roadmap Add the first generic chain-enumeration tooling for BZM2 and document the remaining reference-implementation gaps. This commit adds: - default-ID chain walk helpers - the debug CLI command for serial chain enumeration - the initial Blockscale/BZM2 roadmap document - README links for the growing BZM2 documentation set The conversation log from the local integration repo is intentionally excluded from the upstream branch. --- README.md | 16 ++ docs/blockscale-reference-roadmap.md | 225 +++++++++++++++++++++++++++ docs/bzm2-uart-debug.md | 12 ++ mujina-miner/src/asic/bzm2/mod.rs | 2 +- mujina-miner/src/asic/bzm2/uart.rs | 45 ++++++ mujina-miner/src/bin/bzm2-debug.rs | 38 ++++- 6 files changed, 336 insertions(+), 2 deletions(-) create mode 100644 docs/blockscale-reference-roadmap.md diff --git a/README.md b/README.md index 75d03996..615b7732 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,22 @@ Planned support: - [Architecture Overview](docs/architecture.md) - System design and component interaction - [REST API](docs/api.md) - API contract, conventions, and endpoints +- [BZM2 Port Note](docs/bzm2-port.md) - Architecture, implemented behavior, + telemetry, and current scope boundaries for the BZM2 port +- [BZM2 UART Debug Guide](docs/bzm2-uart-debug.md) - CLI usage for UART, + telemetry queries, TDM observation, clock diagnostics, and validation flows +- [BZM2 Tuning Planner](docs/bzm2-pnp.md) - Tuning-planner behavior and current + calibration scope +- [BZM2 Opcode Grounding](docs/bzm2-opcode-grounding.md) - Source-grounded UART + opcode behavior and the current JTAG evidence boundary +- [Blockscale ASIC Integration Guide](docs/blockscale-asic-integration-guide.md) - + Generic hardware design guidance for building a custom solution around the + Blockscale / BZM2 ASIC +- [Blockscale UART And TDM Reference](docs/blockscale-uart-protocol-reference.md) - + ASIC-facing UART, TDM, opcode, and job-programming reference +- [Blockscale Reference Roadmap](docs/blockscale-reference-roadmap.md) - + Ordered implementation plan for closing the remaining bring-up, tuning, and + diagnostics gaps - [CPU Mining](docs/cpu-mining.md) - Run without hardware for development and testing - [Container Image](docs/container.md) - Build and run as a container diff --git a/docs/blockscale-reference-roadmap.md b/docs/blockscale-reference-roadmap.md new file mode 100644 index 00000000..e143e4a4 --- /dev/null +++ b/docs/blockscale-reference-roadmap.md @@ -0,0 +1,225 @@ +# Blockscale / BZM2 Reference Implementation Roadmap + +## Goal + +Close the remaining gap between: + +- a strong ASIC-facing Rust port with solid debug tooling + +and + +- a comprehensive, reusable reference implementation for custom Blockscale / + BZM2 hardware. + +This roadmap is ordered by dependency and practical value. + +## Scope + +In scope: + +- generic ASIC bring-up +- generic chain discovery +- reusable domain-aware power and tuning control +- board/API diagnostics +- runtime retune + +Out of scope for this plan: + +- vendor reference-board reproduction +- carrier-specific MCU protocols unless a target board actually needs them +- Gen1 telemetry completion +- speculative JTAG implementation not grounded in concrete protocol evidence + +## Current Gap Summary + +The current repo already has: + +- UART opcode support +- TDM parsing +- mining dispatch and result handling +- PLL and DLL diagnostics +- DTS/VS telemetry and query tooling +- startup tuning planning and saved operating-point replay +- a strong silicon-validation CLI + +The biggest missing pieces are: + +1. actual rail application for planned domain voltages +2. hardware chain discovery and `ASIC_ID` assignment +3. runtime engine/topology discovery instead of fixed assumptions +4. closed-loop calibration and retune +5. board/API diagnostics parity with the CLI + +## Phase 1: Discoverable Bring-Up + +Objective: + +- eliminate the assumption that ASIC count and identity are fully preconfigured + +Deliverables: + +1. Add low-level UART helpers for: + - writing `ASIC_ID` + - enumerating a chain starting from default `0xFA` + - verifying assigned IDs with `NOOP` +2. Add debug CLI support for: + - chain enumeration + - ID assignment validation +3. Add optional board startup enumeration mode so `Bzm2Board` can populate bus + layout from hardware rather than only from `MUJINA_BZM2_ASICS_PER_BUS` + +Exit criteria: + +- a powered chain can be discovered from software with no hard-coded ASIC count +- the discovered count can seed board topology and saved operating-point + compatibility checks + +## Phase 2: Applied Rail And Reset Control + +Objective: + +- move the existing control abstractions from library-only status into real + board startup and shutdown flows + +Deliverables: + +1. Wire `Bzm2BringupPlan` into `Bzm2Board` +2. Add a concrete board-facing rail bundle abstraction: + - one or more rails + - optional reset line + - optional rail telemetry +3. Apply safe startup and shutdown sequencing through the board runtime +4. Expose rail telemetry into board state where available + +Exit criteria: + +- board startup can perform reset and rail sequencing without external manual + steps +- board shutdown returns the hardware to a safe state + +## Phase 3: Domain Voltage Application + +Objective: + +- make the tuning planner’s voltage-domain outputs real rather than advisory + +Deliverables: + +1. Map planned domain voltages onto configured rails +2. Apply coarse domain voltages before clock ramp +3. Use rail telemetry and ASIC `DTS_VS` readings to verify applied state +4. Persist replay metadata that distinguishes: + - clock-only replay + - full voltage-plus-clock replay + +Exit criteria: + +- `Bzm2Board` can apply multi-domain operating points, not just PLL maps + +## Phase 4: Topology And Defect Discovery + +Objective: + +- stop assuming the default logical engine map is always the real map + +Deliverables: + +1. Add engine/topology probing helpers +2. Detect unavailable or disabled engines per ASIC +3. Feed the discovered engine map into: + - work dispatch + - validation helpers + - tuning calculations + +Exit criteria: + +- systems with missing or disabled engines do not need a code rebuild or static + exclusion map edit + +## Phase 5: Closed-Loop Calibration And Retune + +Objective: + +- turn the startup planner into a true operating-point controller + +Deliverables: + +1. Measure and store real: + - pass rate + - throughput + - per-PLL behavior + - per-domain power +2. Feed those measurements back into the tuning planner +3. Add runtime retune triggers for: + - throughput regression + - thermal drift + - persistent voltage imbalance +4. Revalidate or invalidate saved operating points automatically + +Exit criteria: + +- tuning decisions are based on measured runtime behavior, not just startup + heuristics and persisted estimates + +## Phase 6: Diagnostics And API Parity + +Objective: + +- expose the most useful silicon-validation operations without requiring the + standalone CLI + +Deliverables: + +1. Board/API commands for: + - `NOOP` + - loopback + - register read/write + - clock report + - chain enumeration summary +2. Board-state visibility for: + - discovered ASIC count + - discovered engine count / disabled-engine map + - saved operating-point replay path + - current calibration and safety status + +Exit criteria: + +- operators can perform high-value diagnostics through the board/API surface + without dropping to raw serial tooling + +## Phase 7: JTAG, Only If Grounded + +Objective: + +- add JTAG only if enough packet-level evidence exists to do it correctly + +Deliverables: + +1. protocol-evidence review +2. minimal IR/DR helpers only if grounded +3. debug-only tooling for validated use cases + +Exit criteria: + +- no guessed JTAG semantics enter the codebase + +## Immediate Execution Order + +The next concrete work items should be: + +1. implement generic chain enumeration and `ASIC_ID` assignment helpers +2. add a debug CLI command that enumerates a live chain +3. add optional board startup auto-enumeration using that helper +4. then wire generic rail/reset sequencing into `Bzm2Board` + +## Current Execution Status + +Started: + +- Phase 1 step 1 and step 2 + +Reason: + +- enumeration removes a major assumption from the current board runtime +- it is ASIC-generic +- it is directly grounded in documented and legacy UART behavior diff --git a/docs/bzm2-uart-debug.md b/docs/bzm2-uart-debug.md index 065de36e..2a38fe00 100644 --- a/docs/bzm2-uart-debug.md +++ b/docs/bzm2-uart-debug.md @@ -6,6 +6,7 @@ This guide documents the direct BZM2 UART and silicon-validation interface added The current CLI folds in the portable parts of the legacy `silicon validation` BZM2 validation surface: +- chain enumeration and `ASIC_ID` assignment from the default bus id - ASIC discovery and liveness scans - loopback data-path validation - explicit TDM enable and disable control @@ -72,6 +73,17 @@ cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ noop-scan /dev/ttyUSB0 0 15 5000000 ``` +Enumerate a powered chain from the default `ASIC_ID` (`0xFA`) and assign +incrementing ids starting at `0`: + +```text +cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ + enumerate-chain /dev/ttyUSB0 32 0 5000000 +``` + +This is the first generic bring-up step for a fresh chain where every ASIC is +still on the default id. + Run deterministic loopback validation across an ASIC range: ```text diff --git a/mujina-miner/src/asic/bzm2/mod.rs b/mujina-miner/src/asic/bzm2/mod.rs index 0276ec6a..46fdef3d 100644 --- a/mujina-miner/src/asic/bzm2/mod.rs +++ b/mujina-miner/src/asic/bzm2/mod.rs @@ -17,6 +17,6 @@ pub use pnp::{ }; pub use thread::{Bzm2Thread, Bzm2ThreadConfig, Bzm2ThreadHandle}; pub use uart::{ - BROADCAST_GROUP_ASIC, Bzm2DtsVsConfig, Bzm2UartController, Bzm2UartError, + BROADCAST_GROUP_ASIC, Bzm2DtsVsConfig, Bzm2UartController, Bzm2UartError, DEFAULT_ASIC_ID, DEFAULT_DTS_VS_QUERY_TIMEOUT, NOTCH_REG, }; diff --git a/mujina-miner/src/asic/bzm2/uart.rs b/mujina-miner/src/asic/bzm2/uart.rs index c6c8ae34..5612543c 100644 --- a/mujina-miner/src/asic/bzm2/uart.rs +++ b/mujina-miner/src/asic/bzm2/uart.rs @@ -13,7 +13,9 @@ use super::protocol::{ pub const NOTCH_REG: u16 = 0x0fff; pub const BROADCAST_GROUP_ASIC: u8 = BROADCAST_ASIC; pub const DEFAULT_DTS_VS_QUERY_TIMEOUT: Duration = Duration::from_secs(2); +pub const DEFAULT_ASIC_ID: u8 = 0xfa; +const LOCAL_REG_ASIC_ID: u8 = 0x0b; const LOCAL_REG_SLOW_CLK_DIV: u8 = 0x08; const LOCAL_REG_UART_TX: u8 = 0x0a; const LOCAL_REG_SENS_TDM_GAP_CNT: u8 = 0x2d; @@ -70,6 +72,9 @@ pub enum Bzm2UartError { actual_opcode: u8, }, + #[error("unexpected NOOP payload from ASIC {asic:#x}: {data:02x?}")] + UnexpectedNoopPayload { asic: u8, data: [u8; 3] }, + #[error("timed out waiting for DTS/VS frame from ASIC {asic:#x}")] DtsVsTimeout { asic: u8 }, } @@ -182,6 +187,12 @@ impl Bzm2UartController { .await } + /// Program the next ASIC still responding on the default chain id. + pub async fn assign_default_asic_id(&mut self, new_id: u8) -> Result<(), Bzm2UartError> { + self.write_local_reg_u32(DEFAULT_ASIC_ID, LOCAL_REG_ASIC_ID, new_id as u32) + .await + } + pub async fn multicast_write_register( &mut self, asic: u8, @@ -263,6 +274,35 @@ impl Bzm2UartController { Ok(response[2..5].try_into().unwrap()) } + pub async fn verify_noop_bz2(&mut self, asic: u8) -> Result<(), Bzm2UartError> { + let data = self.noop(asic).await?; + if data == *b"BZ2" { + Ok(()) + } else { + Err(Bzm2UartError::UnexpectedNoopPayload { asic, data }) + } + } + + /// Enumerate a fresh chain by assigning ids to devices that still answer on + /// the documented default ASIC id `0xFA`. + pub async fn enumerate_chain( + &mut self, + max_asics: u8, + start_id: u8, + ) -> Result, Bzm2UartError> { + let mut assigned = Vec::new(); + for offset in 0..max_asics { + let next_id = start_id.saturating_add(offset); + if self.verify_noop_bz2(DEFAULT_ASIC_ID).await.is_err() { + break; + } + self.assign_default_asic_id(next_id).await?; + self.verify_noop_bz2(next_id).await?; + assigned.push(next_id); + } + Ok(assigned) + } + pub async fn loopback(&mut self, asic: u8, payload: &[u8]) -> Result, Bzm2UartError> { let request = encode_loopback(asic, payload); self.writer.write_all(&request).await?; @@ -571,4 +611,9 @@ mod tests { fn legacy_voltage_query_threshold_matches_legacy_formula_family() { assert_eq!(legacy_voltage_mv_to_tune_code(500), 7561); } + + #[test] + fn default_asic_id_matches_legacy_value() { + assert_eq!(DEFAULT_ASIC_ID, 0xfa); + } } diff --git a/mujina-miner/src/bin/bzm2-debug.rs b/mujina-miner/src/bin/bzm2-debug.rs index 49876cab..9e156b95 100644 --- a/mujina-miner/src/bin/bzm2-debug.rs +++ b/mujina-miner/src/bin/bzm2-debug.rs @@ -11,7 +11,7 @@ use mujina_miner::asic::bzm2::protocol::{ }; use mujina_miner::asic::bzm2::{ BROADCAST_GROUP_ASIC, Bzm2ClockController, Bzm2Dll, Bzm2DtsVsConfig, Bzm2Pll, - Bzm2UartController, DEFAULT_DTS_VS_QUERY_TIMEOUT, NOTCH_REG, + Bzm2UartController, DEFAULT_ASIC_ID, DEFAULT_DTS_VS_QUERY_TIMEOUT, NOTCH_REG, }; use mujina_miner::transport::{SerialReader, SerialStream, SerialWriter}; use sha2::{Digest, Sha256}; @@ -41,6 +41,7 @@ async fn main() -> Result<()> { "uart-read-result" => cmd_uart_read_result(&args[2..]).await, "dts-vs-query" => cmd_dts_vs_query(&args[2..]).await, "dts-vs-scan" => cmd_dts_vs_scan(&args[2..]).await, + "enumerate-chain" => cmd_enumerate_chain(&args[2..]).await, "noop-scan" => cmd_noop_scan(&args[2..]).await, "loopback-scan" => cmd_loopback_scan(&args[2..]).await, "tdm-enable" => cmd_tdm_enable(&args[2..]).await, @@ -76,6 +77,7 @@ fn print_usage() { eprintln!(" uart-read-result [baud]"); eprintln!(" dts-vs-query [timeout-ms] [baud]"); eprintln!(" dts-vs-scan [timeout-ms] [baud]"); + eprintln!(" enumerate-chain [start-id] [baud]"); eprintln!(" noop-scan [baud]"); eprintln!(" loopback-scan [baud]"); eprintln!(); @@ -114,6 +116,7 @@ fn print_usage() { eprintln!(" unicast : uart-write /dev/ttyUSB0 2 notch 0x12 01000000"); eprintln!(" broadcast : uart-write /dev/ttyUSB0 broadcast notch 0x12 01000000"); eprintln!(" multicast : uart-multicast-write /dev/ttyUSB0 2 7 0x49 3c"); + eprintln!(" enumerate : enumerate-chain /dev/ttyUSB0 32 0"); eprintln!(" scan : noop-scan /dev/ttyUSB0 0 15"); eprintln!(" TDM watch : tdm-watch /dev/ttyUSB0 gen2 5"); eprintln!(" grid job : job-grid-watch /dev/ttyUSB0 2 0 1700000000 60 5 gen2"); @@ -402,6 +405,39 @@ async fn cmd_dts_vs_scan(args: &[String]) -> Result<()> { Ok(()) } +async fn cmd_enumerate_chain(args: &[String]) -> Result<()> { + if args.len() < 2 || args.len() > 4 { + bail!("usage: enumerate-chain [start-id] [baud]"); + } + + let max_asics = parse_u8(&args[1])?; + if max_asics == 0 { + bail!("max-asics must be greater than zero"); + } + + let (start_id, baud_arg) = match args.len() { + 2 => (0, None), + 3 => (parse_u8(&args[2])?, None), + 4 => (parse_u8(&args[2])?, args.get(3)), + _ => unreachable!(), + }; + let mut uart = open_uart(&args[0], parse_baud(baud_arg)?)?; + + let assigned = uart.enumerate_chain(max_asics, start_id).await?; + println!( + "enumerated {} ASIC(s) from default id 0x{DEFAULT_ASIC_ID:02x}", + assigned.len() + ); + for asic in &assigned { + println!(" assigned asic id {}", asic); + } + if assigned.is_empty() { + println!(" no ASIC responded on the default id"); + } + + Ok(()) +} + async fn cmd_noop_scan(args: &[String]) -> Result<()> { if args.len() < 3 || args.len() > 4 { bail!("usage: noop-scan [baud]"); From a97092ff75237e5098cca26a27d79a07374c42e2 Mon Sep 17 00:00:00 2001 From: Reckless Apotheosis <125509978+recklessnode@users.noreply.github.com> Date: Fri, 6 Mar 2026 15:58:14 -0800 Subject: [PATCH 08/24] feat(bzm2): add startup auto-enumeration Teach the BZM2 board runtime to enumerate chains at startup instead of relying entirely on static ASIC-count configuration. This commit adds: - startup bus enumeration from the default ASIC ID - fallback handling when the runtime must use configured counts instead - the related operator documentation updates The local conversation log is carried temporarily so later local commits apply cleanly; it will be removed from the final PR branch before completion. --- docs/blockscale-reference-roadmap.md | 8 + docs/bzm2-port.md | 17 ++ docs/bzm2-uart-debug.md | 10 + mujina-miner/src/asic/bzm2/uart.rs | 61 ++++- mujina-miner/src/board/bzm2.rs | 324 ++++++++++++++++++++++++++- 5 files changed, 411 insertions(+), 9 deletions(-) diff --git a/docs/blockscale-reference-roadmap.md b/docs/blockscale-reference-roadmap.md index e143e4a4..1b46a9ff 100644 --- a/docs/blockscale-reference-roadmap.md +++ b/docs/blockscale-reference-roadmap.md @@ -68,6 +68,14 @@ Deliverables: 3. Add optional board startup enumeration mode so `Bzm2Board` can populate bus layout from hardware rather than only from `MUJINA_BZM2_ASICS_PER_BUS` +Status: + +- completed: low-level default-`ASIC_ID` enumeration helpers +- completed: `enumerate-chain` CLI support +- completed: opt-in `Bzm2Board` startup enumeration with fallback to + configured topology when no default-id ASICs are present +- next: Phase 2, applied rail and reset control + Exit criteria: - a powered chain can be discovered from software with no hard-coded ASIC count diff --git a/docs/bzm2-port.md b/docs/bzm2-port.md index 4327913e..a6b1e444 100644 --- a/docs/bzm2-port.md +++ b/docs/bzm2-port.md @@ -63,6 +63,23 @@ Supported environment variables: - `MUJINA_BZM2_DISPATCH_MS`: redispatch interval in milliseconds, default `500` - `MUJINA_BZM2_HASHRATE_THS`: nominal per-thread hashrate estimate, default `40` - `MUJINA_BZM2_DTS_VS_GEN`: DTS/VS payload generation, `1` or `2`, default `2` +- `MUJINA_BZM2_ENUMERATE_CHAIN`: enable startup chain enumeration from the + documented default `ASIC_ID` +- `MUJINA_BZM2_AUTO_ENUMERATE`: alternate name for the same setting +- `MUJINA_BZM2_ENUM_START_ID`: first assigned runtime `ASIC_ID`, default `0` +- `MUJINA_BZM2_ENUM_MAX_ASICS_PER_BUS`: comma-separated per-bus enumeration + ceilings, default `100` per bus unless calibration topology already provides + a larger configured count + +Startup enumeration notes: + +- this mode is intended for fresh chains where ASICs still answer on the + default `ASIC_ID` +- enumeration uses a bounded `NOOP` probe so the chain walk terminates cleanly + at the end of the bus +- if no default-id ASIC responds on startup, Mujina falls back to the configured + `MUJINA_BZM2_ASICS_PER_BUS` topology so warm-restart cases do not collapse to + zero ASICs ## Design Boundary diff --git a/docs/bzm2-uart-debug.md b/docs/bzm2-uart-debug.md index 2a38fe00..77682ef9 100644 --- a/docs/bzm2-uart-debug.md +++ b/docs/bzm2-uart-debug.md @@ -84,6 +84,16 @@ cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ This is the first generic bring-up step for a fresh chain where every ASIC is still on the default id. +The chain walk now uses a bounded `NOOP` probe internally, so the command stops +cleanly when no additional default-id ASIC is present instead of hanging at the +end of the bus. + +The same discovery flow can be enabled during Mujina board startup with: + +- `MUJINA_BZM2_ENUMERATE_CHAIN=true` +- optional `MUJINA_BZM2_ENUM_START_ID` +- optional `MUJINA_BZM2_ENUM_MAX_ASICS_PER_BUS` + Run deterministic loopback validation across an ASIC range: ```text diff --git a/mujina-miner/src/asic/bzm2/uart.rs b/mujina-miner/src/asic/bzm2/uart.rs index 5612543c..8e606a64 100644 --- a/mujina-miner/src/asic/bzm2/uart.rs +++ b/mujina-miner/src/asic/bzm2/uart.rs @@ -1,6 +1,7 @@ use std::time::Duration; use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::time::timeout; use crate::transport::{SerialReader, SerialWriter}; @@ -14,6 +15,7 @@ pub const NOTCH_REG: u16 = 0x0fff; pub const BROADCAST_GROUP_ASIC: u8 = BROADCAST_ASIC; pub const DEFAULT_DTS_VS_QUERY_TIMEOUT: Duration = Duration::from_secs(2); pub const DEFAULT_ASIC_ID: u8 = 0xfa; +pub const DEFAULT_NOOP_PROBE_TIMEOUT: Duration = Duration::from_millis(100); const LOCAL_REG_ASIC_ID: u8 = 0x0b; const LOCAL_REG_SLOW_CLK_DIV: u8 = 0x08; @@ -75,6 +77,9 @@ pub enum Bzm2UartError { #[error("unexpected NOOP payload from ASIC {asic:#x}: {data:02x?}")] UnexpectedNoopPayload { asic: u8, data: [u8; 3] }, + #[error("timed out waiting for NOOP response from ASIC {asic:#x} after {timeout_ms} ms")] + NoopTimeout { asic: u8, timeout_ms: u64 }, + #[error("timed out waiting for DTS/VS frame from ASIC {asic:#x}")] DtsVsTimeout { asic: u8 }, } @@ -274,6 +279,26 @@ impl Bzm2UartController { Ok(response[2..5].try_into().unwrap()) } + pub async fn noop_with_timeout( + &mut self, + asic: u8, + wait: Duration, + ) -> Result<[u8; 3], Bzm2UartError> { + let request = encode_noop(asic); + self.writer.write_all(&request).await?; + self.writer.flush().await?; + + let mut response = [0u8; 5]; + timeout(wait, self.reader.read_exact(&mut response)) + .await + .map_err(|_| Bzm2UartError::NoopTimeout { + asic, + timeout_ms: wait.as_millis().min(u128::from(u64::MAX)) as u64, + })??; + validate_response_header(asic, OPCODE_UART_NOOP, &response)?; + Ok(response[2..5].try_into().unwrap()) + } + pub async fn verify_noop_bz2(&mut self, asic: u8) -> Result<(), Bzm2UartError> { let data = self.noop(asic).await?; if data == *b"BZ2" { @@ -283,17 +308,46 @@ impl Bzm2UartController { } } + pub async fn verify_noop_bz2_with_timeout( + &mut self, + asic: u8, + wait: Duration, + ) -> Result<(), Bzm2UartError> { + let data = self.noop_with_timeout(asic, wait).await?; + if data == *b"BZ2" { + Ok(()) + } else { + Err(Bzm2UartError::UnexpectedNoopPayload { asic, data }) + } + } + /// Enumerate a fresh chain by assigning ids to devices that still answer on /// the documented default ASIC id `0xFA`. pub async fn enumerate_chain( &mut self, max_asics: u8, start_id: u8, + ) -> Result, Bzm2UartError> { + self.enumerate_chain_with_timeout(max_asics, start_id, DEFAULT_NOOP_PROBE_TIMEOUT) + .await + } + + /// Enumerate a fresh chain using a bounded NOOP probe so the walk can stop + /// cleanly when the last default-id device has been assigned. + pub async fn enumerate_chain_with_timeout( + &mut self, + max_asics: u8, + start_id: u8, + probe_timeout: Duration, ) -> Result, Bzm2UartError> { let mut assigned = Vec::new(); for offset in 0..max_asics { let next_id = start_id.saturating_add(offset); - if self.verify_noop_bz2(DEFAULT_ASIC_ID).await.is_err() { + if self + .verify_noop_bz2_with_timeout(DEFAULT_ASIC_ID, probe_timeout) + .await + .is_err() + { break; } self.assign_default_asic_id(next_id).await?; @@ -616,4 +670,9 @@ mod tests { fn default_asic_id_matches_legacy_value() { assert_eq!(DEFAULT_ASIC_ID, 0xfa); } + + #[test] + fn default_noop_probe_timeout_is_bounded() { + assert_eq!(DEFAULT_NOOP_PROBE_TIMEOUT, Duration::from_millis(100)); + } } diff --git a/mujina-miner/src/board/bzm2.rs b/mujina-miner/src/board/bzm2.rs index c2b11361..29c86250 100644 --- a/mujina-miner/src/board/bzm2.rs +++ b/mujina-miner/src/board/bzm2.rs @@ -18,7 +18,7 @@ use crate::{ Bzm2CalibrationConstraints, Bzm2CalibrationMode, Bzm2CalibrationPlanner, Bzm2ClockController, Bzm2DomainMeasurement, Bzm2OperatingClass, Bzm2PerformanceMode, Bzm2Pll, Bzm2SavedOperatingPoint, Bzm2Thread, Bzm2ThreadConfig, Bzm2ThreadHandle, - Bzm2VoltageDomain, + Bzm2UartController, Bzm2VoltageDomain, }, hash_thread::{ HashTask, HashThread, HashThreadCapabilities, HashThreadError, HashThreadEvent, @@ -44,6 +44,7 @@ const DEFAULT_CALIBRATION_SITE_TEMP_C: f32 = 20.0; const DEFAULT_CALIBRATION_POST1_DIVIDER: u8 = 0; const DEFAULT_CALIBRATION_LOCK_TIMEOUT_MS: u64 = 1_000; const DEFAULT_CALIBRATION_LOCK_POLL_MS: u64 = 100; +const DEFAULT_ENUMERATION_MAX_ASICS_PER_BUS: u16 = 100; #[derive(Debug, Clone)] pub struct Bzm2VirtualDeviceConfig { @@ -56,6 +57,7 @@ pub struct Bzm2VirtualDeviceConfig { pub dts_vs_generation: crate::asic::bzm2::protocol::DtsVsGeneration, pub telemetry: Bzm2TelemetryConfig, pub calibration: Bzm2CalibrationConfig, + pub enumeration: Bzm2EnumerationConfig, } impl Bzm2VirtualDeviceConfig { @@ -101,6 +103,7 @@ impl Bzm2VirtualDeviceConfig { .as_deref() .and_then(crate::asic::bzm2::protocol::DtsVsGeneration::from_env_value) .unwrap_or(crate::asic::bzm2::protocol::DtsVsGeneration::Gen2); + let calibration = Bzm2CalibrationConfig::from_env(serial_paths.len()); Some(Self { serial_paths: serial_paths.clone(), @@ -111,7 +114,8 @@ impl Bzm2VirtualDeviceConfig { nominal_hashrate_ths, dts_vs_generation, telemetry: Bzm2TelemetryConfig::from_env(), - calibration: Bzm2CalibrationConfig::from_env(serial_paths.len()), + enumeration: Bzm2EnumerationConfig::from_env(serial_paths.len(), &calibration), + calibration, }) } @@ -134,6 +138,50 @@ impl Bzm2VirtualDeviceConfig { } } +#[derive(Debug, Clone)] +pub struct Bzm2EnumerationConfig { + pub enabled: bool, + pub start_id: u8, + pub max_asics_per_bus: Vec, +} + +impl Default for Bzm2EnumerationConfig { + fn default() -> Self { + Self { + enabled: false, + start_id: 0, + max_asics_per_bus: vec![DEFAULT_ENUMERATION_MAX_ASICS_PER_BUS], + } + } +} + +impl Bzm2EnumerationConfig { + fn from_env(serial_count: usize, calibration: &Bzm2CalibrationConfig) -> Self { + let mut max_asics_per_bus = parse_csv_numbers::("MUJINA_BZM2_ENUM_MAX_ASICS_PER_BUS") + .unwrap_or_else(|| { + if calibration.asics_per_bus.iter().any(|count| *count > 1) { + calibration.asics_per_bus.clone() + } else if serial_count == 0 { + Vec::new() + } else { + vec![DEFAULT_ENUMERATION_MAX_ASICS_PER_BUS; serial_count] + } + }); + if max_asics_per_bus.is_empty() && serial_count > 0 { + max_asics_per_bus = vec![DEFAULT_ENUMERATION_MAX_ASICS_PER_BUS; serial_count]; + } + + Self { + enabled: env_flag_any(&["MUJINA_BZM2_ENUMERATE_CHAIN", "MUJINA_BZM2_AUTO_ENUMERATE"]), + start_id: env::var("MUJINA_BZM2_ENUM_START_ID") + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(0), + max_asics_per_bus, + } + } +} + #[derive(Debug, Clone)] pub struct Bzm2CalibrationConfig { pub enabled: bool, @@ -679,13 +727,81 @@ impl Bzm2Board { })); } - async fn execute_live_calibration(&self) -> Result<(), BoardError> { + async fn resolve_bus_layouts(&self) -> Result, BoardError> { + let configured = build_bus_layouts( + &self.config.serial_paths, + &self.config.calibration.asics_per_bus, + ); + if !self.config.enumeration.enabled { + return Ok(configured); + } + + let discovered = self.enumerate_bus_layouts().await?; + if should_fallback_to_configured_bus_layouts(&discovered, &configured) { + warn!( + board = %self.config.device_id(), + "BZM2 startup enumeration found no ASICs on the default id; falling back to configured bus topology" + ); + return Ok(configured); + } + + Ok(discovered) + } + + async fn enumerate_bus_layouts(&self) -> Result, BoardError> { + let mut counts = Vec::with_capacity(self.config.serial_paths.len()); + + for (index, serial_path) in self.config.serial_paths.iter().enumerate() { + let max_asics = *self + .config + .enumeration + .max_asics_per_bus + .get(index) + .or_else(|| self.config.enumeration.max_asics_per_bus.last()) + .unwrap_or(&DEFAULT_ENUMERATION_MAX_ASICS_PER_BUS); + let max_asics = max_asics.min(u8::MAX as u16) as u8; + + let stream = SerialStream::new(serial_path, self.config.baud_rate).map_err(|err| { + BoardError::InitializationFailed(format!( + "Failed to open BZM2 enumeration transport {}: {}", + serial_path, err + )) + })?; + let (reader, writer, _control) = stream.split(); + let mut uart = Bzm2UartController::new(reader, writer); + let assigned = uart + .enumerate_chain(max_asics, self.config.enumeration.start_id) + .await + .map_err(|err| { + BoardError::InitializationFailed(format!( + "BZM2 startup enumeration failed on {}: {}", + serial_path, err + )) + })?; + counts.push(assigned.len() as u16); + info!( + board = %self.config.device_id(), + serial_path, + asic_count = assigned.len(), + "BZM2 startup enumeration completed" + ); + } + + Ok(build_discovered_bus_layouts( + &self.config.serial_paths, + &counts, + )) + } + + async fn execute_live_calibration( + &self, + bus_layouts: &[Bzm2BusLayout], + ) -> Result<(), BoardError> { let calibration = &self.config.calibration; if !calibration.enabled { return Ok(()); } - let bus_layouts = build_bus_layouts(&self.config.serial_paths, &calibration.asics_per_bus); let total_asics = bus_layouts .iter() .map(|layout| layout.asic_count as usize) @@ -834,6 +950,9 @@ impl Bzm2Board { profile: &Bzm2PersistedCalibrationProfile, ) -> Result<(), BoardError> { for bus in bus_layouts { + if bus.asic_count == 0 { + continue; + } let initial_frequencies = [0usize, 1usize].map(|pll_index| { average_f32( (bus.asic_start..bus.asic_start + bus.asic_count) @@ -871,6 +990,9 @@ impl Bzm2Board { initial_frequencies_mhz: [f32; 2], per_asic_pll_mhz: &BTreeMap, ) -> Result<(), BoardError> { + if bus.asic_count == 0 { + return Ok(()); + } let stream = SerialStream::new(&bus.serial_path, self.config.baud_rate).map_err(|err| { BoardError::InitializationFailed(format!( "Failed to open BZM2 calibration transport {}: {}", @@ -999,6 +1121,7 @@ impl Board for Bzm2Board { async fn create_hash_threads(&mut self) -> Result>, BoardError> { let mut threads: Vec> = Vec::new(); let mut thread_states = Vec::new(); + let bus_layouts = self.resolve_bus_layouts().await?; let initial_snapshot = self.config.telemetry.snapshot(); let _ = self.state_tx.send_modify(|state| { state.fans = initial_snapshot.fans.clone(); @@ -1006,7 +1129,7 @@ impl Board for Bzm2Board { merge_power_readings(&mut state.powers, &initial_snapshot.powers); }); - self.execute_live_calibration().await?; + self.execute_live_calibration(&bus_layouts).await?; for (index, serial_path) in self.config.serial_paths.iter().enumerate() { let stream = SerialStream::new(serial_path, self.config.baud_rate).map_err(|err| { @@ -1212,6 +1335,21 @@ fn merge_power_readings(existing: &mut Vec, updates: &[PowerMe } fn build_bus_layouts(serial_paths: &[String], asics_per_bus: &[u16]) -> Vec { + build_bus_layouts_with_minimum(serial_paths, asics_per_bus, 1) +} + +fn build_discovered_bus_layouts( + serial_paths: &[String], + asics_per_bus: &[u16], +) -> Vec { + build_bus_layouts_with_minimum(serial_paths, asics_per_bus, 0) +} + +fn build_bus_layouts_with_minimum( + serial_paths: &[String], + asics_per_bus: &[u16], + minimum_asic_count: u16, +) -> Vec { let mut next_asic = 0u16; serial_paths .iter() @@ -1221,7 +1359,7 @@ fn build_bus_layouts(serial_paths: &[String], asics_per_bus: &[u16]) -> Vec Vec bool { + let discovered_total = discovered + .iter() + .map(|layout| layout.asic_count as usize) + .sum::(); + let configured_total = configured + .iter() + .map(|layout| layout.asic_count as usize) + .sum::(); + discovered_total == 0 && configured_total > 0 +} + fn build_voltage_domains( total_asics: u16, asics_per_domain: &[u16], @@ -1580,11 +1733,61 @@ inventory::submit! { #[cfg(test)] mod tests { use super::*; + use crate::asic::bzm2::protocol::{OPCODE_UART_NOOP, encode_noop, encode_write_register}; use nix::pty::openpty; use std::fs; + use std::io::{Read, Write}; use std::os::fd::AsRawFd; use std::time::{SystemTime, UNIX_EPOCH}; + fn spawn_chain_emulator( + master: std::os::fd::OwnedFd, + chain_len: u8, + start_id: u8, + ) -> std::thread::JoinHandle<()> { + std::thread::spawn(move || { + let mut file = fs::File::from(master); + for offset in 0..chain_len { + let mut noop_request = + vec![0u8; encode_noop(crate::asic::bzm2::DEFAULT_ASIC_ID).len()]; + file.read_exact(&mut noop_request).unwrap(); + assert_eq!( + noop_request, + encode_noop(crate::asic::bzm2::DEFAULT_ASIC_ID) + ); + file.write_all(&[ + crate::asic::bzm2::DEFAULT_ASIC_ID, + OPCODE_UART_NOOP, + b'B', + b'Z', + b'2', + ]) + .unwrap(); + + let assigned = start_id.saturating_add(offset); + let expected_write = encode_write_register( + crate::asic::bzm2::DEFAULT_ASIC_ID, + crate::asic::bzm2::NOTCH_REG, + 0x0b, + &(assigned as u32).to_le_bytes(), + ); + let mut write_request = vec![0u8; expected_write.len()]; + file.read_exact(&mut write_request).unwrap(); + assert_eq!(write_request, expected_write); + + let mut assigned_noop = vec![0u8; encode_noop(assigned).len()]; + file.read_exact(&mut assigned_noop).unwrap(); + assert_eq!(assigned_noop, encode_noop(assigned)); + file.write_all(&[assigned, OPCODE_UART_NOOP, b'B', b'Z', b'2']) + .unwrap(); + } + + let mut final_probe = vec![0u8; encode_noop(crate::asic::bzm2::DEFAULT_ASIC_ID).len()]; + file.read_exact(&mut final_probe).unwrap(); + assert_eq!(final_probe, encode_noop(crate::asic::bzm2::DEFAULT_ASIC_ID)); + }) + } + #[tokio::test] async fn live_calibration_persists_profile() { let pty = openpty(None, None).unwrap(); @@ -1610,6 +1813,7 @@ mod tests { nominal_hashrate_ths: DEFAULT_NOMINAL_HASHRATE_THS, dts_vs_generation: crate::asic::bzm2::protocol::DtsVsGeneration::Gen2, telemetry: Bzm2TelemetryConfig::default(), + enumeration: Bzm2EnumerationConfig::default(), calibration: Bzm2CalibrationConfig { enabled: true, asics_per_bus: vec![2], @@ -1626,8 +1830,9 @@ mod tests { ..Default::default() }); let board = Bzm2Board::new(config, state_tx, mpsc::channel(1).1); + let bus_layouts = board.resolve_bus_layouts().await.unwrap(); - board.execute_live_calibration().await.unwrap(); + board.execute_live_calibration(&bus_layouts).await.unwrap(); let profile = load_saved_operating_point_profile(Some(&profile_path)) .unwrap() @@ -1681,6 +1886,7 @@ mod tests { nominal_hashrate_ths: DEFAULT_NOMINAL_HASHRATE_THS, dts_vs_generation: crate::asic::bzm2::protocol::DtsVsGeneration::Gen2, telemetry: Bzm2TelemetryConfig::default(), + enumeration: Bzm2EnumerationConfig::default(), calibration: Bzm2CalibrationConfig { enabled: true, apply_saved_operating_point: true, @@ -1697,8 +1903,9 @@ mod tests { ..Default::default() }); let board = Bzm2Board::new(config, state_tx, mpsc::channel(1).1); + let bus_layouts = board.resolve_bus_layouts().await.unwrap(); - board.execute_live_calibration().await.unwrap(); + board.execute_live_calibration(&bus_layouts).await.unwrap(); assert_eq!(fs::read_to_string(&profile_path).unwrap(), original); @@ -1715,6 +1922,106 @@ mod tests { assert_eq!(layouts[1].asic_count, 6); } + #[tokio::test] + async fn resolve_bus_layouts_uses_startup_enumeration_counts() { + let pty = openpty(None, None).unwrap(); + let master = pty.master; + let slave = pty.slave; + let serial_path = fs::read_link(format!("/proc/self/fd/{}", slave.as_raw_fd())) + .unwrap() + .to_string_lossy() + .into_owned(); + let emulator = spawn_chain_emulator(master, 2, 0); + + let config = Bzm2VirtualDeviceConfig { + serial_paths: vec![serial_path], + baud_rate: DEFAULT_BAUD_RATE, + timestamp_count: crate::asic::bzm2::protocol::DEFAULT_TIMESTAMP_COUNT, + nonce_gap: crate::asic::bzm2::protocol::DEFAULT_NONCE_GAP, + dispatch_interval: Duration::from_millis(50), + nominal_hashrate_ths: DEFAULT_NOMINAL_HASHRATE_THS, + dts_vs_generation: crate::asic::bzm2::protocol::DtsVsGeneration::Gen2, + telemetry: Bzm2TelemetryConfig::default(), + enumeration: Bzm2EnumerationConfig { + enabled: true, + start_id: 0, + max_asics_per_bus: vec![4], + }, + calibration: Bzm2CalibrationConfig::default(), + }; + let (state_tx, _state_rx) = watch::channel(BoardState { + name: "bzm2-test".into(), + model: "BZM2".into(), + serial: Some("bzm2-test".into()), + ..Default::default() + }); + let board = Bzm2Board::new(config, state_tx, mpsc::channel(1).1); + + let layouts = board.resolve_bus_layouts().await.unwrap(); + assert_eq!(layouts.len(), 1); + assert_eq!(layouts[0].asic_count, 2); + + emulator.join().unwrap(); + } + + #[tokio::test] + async fn resolve_bus_layouts_falls_back_to_configured_counts_when_default_id_is_silent() { + let pty = openpty(None, None).unwrap(); + let master = pty.master; + let slave = pty.slave; + let serial_path = fs::read_link(format!("/proc/self/fd/{}", slave.as_raw_fd())) + .unwrap() + .to_string_lossy() + .into_owned(); + let emulator = std::thread::spawn(move || { + let mut file = fs::File::from(master); + let mut probe = vec![0u8; encode_noop(crate::asic::bzm2::DEFAULT_ASIC_ID).len()]; + file.read_exact(&mut probe).unwrap(); + assert_eq!(probe, encode_noop(crate::asic::bzm2::DEFAULT_ASIC_ID)); + file.write_all(&[ + crate::asic::bzm2::DEFAULT_ASIC_ID, + OPCODE_UART_NOOP, + b'N', + b'O', + b'P', + ]) + .unwrap(); + }); + + let config = Bzm2VirtualDeviceConfig { + serial_paths: vec![serial_path], + baud_rate: DEFAULT_BAUD_RATE, + timestamp_count: crate::asic::bzm2::protocol::DEFAULT_TIMESTAMP_COUNT, + nonce_gap: crate::asic::bzm2::protocol::DEFAULT_NONCE_GAP, + dispatch_interval: Duration::from_millis(50), + nominal_hashrate_ths: DEFAULT_NOMINAL_HASHRATE_THS, + dts_vs_generation: crate::asic::bzm2::protocol::DtsVsGeneration::Gen2, + telemetry: Bzm2TelemetryConfig::default(), + enumeration: Bzm2EnumerationConfig { + enabled: true, + start_id: 0, + max_asics_per_bus: vec![4], + }, + calibration: Bzm2CalibrationConfig { + asics_per_bus: vec![3], + ..Default::default() + }, + }; + let (state_tx, _state_rx) = watch::channel(BoardState { + name: "bzm2-test".into(), + model: "BZM2".into(), + serial: Some("bzm2-test".into()), + ..Default::default() + }); + let board = Bzm2Board::new(config, state_tx, mpsc::channel(1).1); + + let layouts = board.resolve_bus_layouts().await.unwrap(); + assert_eq!(layouts.len(), 1); + assert_eq!(layouts[0].asic_count, 3); + + emulator.join().unwrap(); + } + #[tokio::test] async fn board_safety_trip_closes_scheduler_event_stream() { let pty = openpty(None, None).unwrap(); @@ -1750,6 +2057,7 @@ mod tests { max_asic_temp_c: Some(80.0), ..Default::default() }, + enumeration: Bzm2EnumerationConfig::default(), calibration: Bzm2CalibrationConfig::default(), }; let (state_tx, mut state_rx) = watch::channel(BoardState { From 2ab89d0f8cfb1065e3f3871099fcbaa5d00c485d Mon Sep 17 00:00:00 2001 From: Reckless Apotheosis <125509978+recklessnode@users.noreply.github.com> Date: Fri, 6 Mar 2026 15:58:44 -0800 Subject: [PATCH 09/24] feat(bzm2): wire board bring-up into the lifecycle Apply the generic BZM2 rail and reset sequencing plan as part of board startup and shutdown. This moves the runtime closer to a reusable hardware reference implementation by: - running the configured bring-up plan before discovery and calibration - reversing the same plan during shutdown - documenting the boundary between generic sequencing and board-specific glue --- docs/blockscale-reference-roadmap.md | 7 + docs/bzm2-port.md | 106 +++++++++ mujina-miner/src/asic/bzm2/control.rs | 112 +++++++++ mujina-miner/src/asic/bzm2/mod.rs | 4 + mujina-miner/src/asic/bzm2/thread.rs | 27 ++- mujina-miner/src/board/bzm2.rs | 326 +++++++++++++++++++++++++- 6 files changed, 573 insertions(+), 9 deletions(-) diff --git a/docs/blockscale-reference-roadmap.md b/docs/blockscale-reference-roadmap.md index 1b46a9ff..cf16c273 100644 --- a/docs/blockscale-reference-roadmap.md +++ b/docs/blockscale-reference-roadmap.md @@ -99,6 +99,13 @@ Deliverables: 3. Apply safe startup and shutdown sequencing through the board runtime 4. Expose rail telemetry into board state where available +Status: + +- completed: `Bzm2BringupPlan` is now wired into `Bzm2Board` startup and + shutdown through generic file-backed rail and reset adapters +- next: map planned domain voltages and richer rail telemetry onto those + startup/shutdown hooks + Exit criteria: - board startup can perform reset and rail sequencing without external manual diff --git a/docs/bzm2-port.md b/docs/bzm2-port.md index a6b1e444..5b1ca1dc 100644 --- a/docs/bzm2-port.md +++ b/docs/bzm2-port.md @@ -19,6 +19,20 @@ In Mujina, those responsibilities map cleanly onto existing abstractions: A standalone Rust daemon is therefore not required for the mining path. +## Bring-Up And Shutdown + +`Bzm2Board` now supports optional board-level power and reset sequencing through +the existing `Bzm2BringupPlan`. + +The current generic integration path uses file-backed adapters: + +- rail setpoint files for coarse voltage application +- optional rail enable files for regulator enable or precharge control +- an optional reset file for ASIC reset assertion and release + +This keeps the implementation generic across custom Linux-based carriers without +hard-coding one vendor board layout or management MCU protocol. + ## Implemented Behavior The BZM2 Mujina thread now reimplements the core legacy data path and the generally reusable portions of the control path: @@ -70,6 +84,28 @@ Supported environment variables: - `MUJINA_BZM2_ENUM_MAX_ASICS_PER_BUS`: comma-separated per-bus enumeration ceilings, default `100` per bus unless calibration topology already provides a larger configured count +- `MUJINA_BZM2_ENABLE_BRINGUP`: enable startup and shutdown rail/reset + sequencing +- `MUJINA_BZM2_BRINGUP_ENABLE`: alternate name for the same setting +- `MUJINA_BZM2_RAIL_SET_PATHS`: comma-separated rail-control file paths +- `MUJINA_BZM2_RAIL_TARGET_VOLTS`: comma-separated target rail voltages for the + bring-up plan +- `MUJINA_BZM2_RAIL_WRITE_SCALES`: optional comma-separated scale factors used + when converting volts into the raw file value, for example `1000` for mV or + `1000000` for uV +- `MUJINA_BZM2_RAIL_ENABLE_PATHS`: optional comma-separated enable/control file + paths paired with the rail list +- `MUJINA_BZM2_RAIL_ENABLE_VALUES`: optional comma-separated values written to + the rail enable paths during rail initialization +- `MUJINA_BZM2_RESET_PATH`: optional reset-control file path +- `MUJINA_BZM2_RESET_ACTIVE_LOW`: whether the reset path is active-low, default + `true` +- `MUJINA_BZM2_BRINGUP_PRE_POWER_MS`: delay before rail initialization, default + `10` +- `MUJINA_BZM2_BRINGUP_POST_POWER_MS`: delay after the configured rail steps, + default `25` +- `MUJINA_BZM2_BRINGUP_RELEASE_RESET_MS`: delay after reset release, default + `25` Startup enumeration notes: @@ -81,6 +117,76 @@ Startup enumeration notes: `MUJINA_BZM2_ASICS_PER_BUS` topology so warm-restart cases do not collapse to zero ASICs +<<<<<<< HEAD +Bring-up notes: + +- if bring-up is enabled, `Bzm2Board` applies the configured rail/reset + sequence before chain discovery, calibration, and hash-thread creation +- on board shutdown, the same plan is used in reverse order to assert reset and + drive the configured rails back to `0` +- the current implementation is intentionally coarse-grained: it provides real + board lifecycle sequencing now, while richer rail telemetry and domain-aware + voltage application remain the next steps + +## API Telemetry + +When Gen2 `DTS_VS` frames are present on the UART path, Mujina now surfaces ASIC-internal telemetry through the normal board API state: + +- `BoardState.temperatures` +- `BoardState.powers` + +The values are named per serial bus and per ASIC so they can coexist with host-side sensor files: + +- temperature: `ttyUSB0-asic-2-dts` +- voltage channels: `ttyUSB0-asic-2-vs-ch0`, `ttyUSB0-asic-2-vs-ch1`, `ttyUSB0-asic-2-vs-ch2` + +Example JSON fragment: + +```json +{ + "temperatures": [ + { "name": "ttyUSB0-asic-2-dts", "temperature_c": 64.5 } + ], + "powers": [ + { "name": "ttyUSB0-asic-2-vs-ch0", "voltage_v": 0.78, "current_a": null, "power_w": null }, + { "name": "ttyUSB0-asic-2-vs-ch1", "voltage_v": 0.79, "current_a": null, "power_w": null }, + { "name": "ttyUSB0-asic-2-vs-ch2", "voltage_v": 0.77, "current_a": null, "power_w": null } + ] +} +``` + +Notes: + +- these ASIC-originated entries are merged into board state and do not replace host-file telemetry +- Celsius and voltage scaling follow the legacy `bzmd` DTS/VS conversion formulas +- Gen1 currently exposes voltage through this path, but not a Celsius temperature reading + +## On-Demand ASIC Sensor Queries + +Mujina now supports explicit DTS/VS query operations in addition to passive frame reporting. + +This is useful when: + +- the miner is idle and no passive DTS/VS traffic is arriving +- one ASIC is misbehaving and needs targeted inspection +- developers want a direct sensor read without enabling a full TDM watch session + +Two access paths are implemented: + +- CLI: `mujina-bzm2-debug dts-vs-query` and `mujina-bzm2-debug dts-vs-scan` +- HTTP API: `POST /api/v0/boards/{name}/bzm2/dts-vs-query` + +The query path runs through the live BZM2 hash-thread actor so UART ownership remains correct. Queried frames are converted through the same telemetry code path used for passive DTS/VS reporting, so the returned values land in normal `BoardState` telemetry. + +Example HTTP request: + +```bash +curl -X POST http://127.0.0.1:3000/api/v0/boards/bzm2-0/bzm2/dts-vs-query \ + -H "Content-Type: application/json" \ + -d '{"thread_index":0,"asic":2}' +``` + +See [bzm2-uart-debug.md](bzm2-uart-debug.md) for CLI usage examples and expected output shape. ## Design Boundary The legacy `bzmd` board-power path mixes three different concerns: diff --git a/mujina-miner/src/asic/bzm2/control.rs b/mujina-miner/src/asic/bzm2/control.rs index a5dd4f01..2b96e693 100644 --- a/mujina-miner/src/asic/bzm2/control.rs +++ b/mujina-miner/src/asic/bzm2/control.rs @@ -2,6 +2,7 @@ use std::time::Duration; use anyhow::Result; use async_trait::async_trait; +use tokio::fs; use tokio::time::sleep; use crate::{ @@ -110,6 +111,55 @@ impl GpioResetLine { } } +#[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<()> { @@ -137,6 +187,68 @@ pub struct Bzm2BringupPlan { 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 Bzm2PowerRail 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 Bzm2BringupPlan { fn default() -> Self { Self { diff --git a/mujina-miner/src/asic/bzm2/mod.rs b/mujina-miner/src/asic/bzm2/mod.rs index 46fdef3d..b6333af0 100644 --- a/mujina-miner/src/asic/bzm2/mod.rs +++ b/mujina-miner/src/asic/bzm2/mod.rs @@ -9,6 +9,10 @@ pub use clock::{ Bzm2ClockController, Bzm2ClockDebugReport, Bzm2ClockError, Bzm2Dll, Bzm2DllConfig, Bzm2DllStatus, Bzm2Pll, Bzm2PllConfig, Bzm2PllStatus, }; +pub use control::{ + Bzm2BringupPlan, FileGpioPin, FilePowerRail, GpioResetLine, PowerRailTelemetry, + Tps546PowerRail, VoltageStackStep, +}; pub use pnp::{ Bzm2AsicMeasurement, Bzm2AsicPlan, Bzm2AsicTopology, Bzm2BoardCalibrationInput, Bzm2CalibrationConstraints, Bzm2CalibrationMode, Bzm2CalibrationPlan, Bzm2CalibrationPlanner, diff --git a/mujina-miner/src/asic/bzm2/thread.rs b/mujina-miner/src/asic/bzm2/thread.rs index 92507487..b4bdf731 100644 --- a/mujina-miner/src/asic/bzm2/thread.rs +++ b/mujina-miner/src/asic/bzm2/thread.rs @@ -959,15 +959,28 @@ mod tests { .await .unwrap(); + let expected_bytes_per_engine = 8 + 8 + 11 + (48 * 4); + let expected_total = expected_bytes_per_engine * engine_coords.len(); + let deadline = tokio::time::Instant::now() + Duration::from_millis(250); let mut buf = vec![0u8; 512]; - let n = tokio::time::timeout(Duration::from_millis(250), reader.read(&mut buf)) - .await - .unwrap() - .unwrap(); - let bytes = &buf[..n]; + let mut bytes = Vec::with_capacity(expected_total); + while bytes.len() < expected_total { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + assert!( + !remaining.is_zero(), + "timed out before collecting the full dispatch stream" + ); + let n = tokio::time::timeout(remaining, reader.read(&mut buf)) + .await + .unwrap() + .unwrap(); + if n == 0 { + break; + } + bytes.extend_from_slice(&buf[..n]); + } - let expected_bytes_per_engine = 8 + 8 + 11 + (48 * 4); - assert_eq!(bytes.len(), expected_bytes_per_engine * engine_coords.len()); + assert_eq!(bytes.len(), expected_total); assert_eq!(engine_dispatches.len(), engine_coords.len()); let first_packet_len = u16::from_le_bytes([bytes[0], bytes[1]]) as usize; diff --git a/mujina-miner/src/board/bzm2.rs b/mujina-miner/src/board/bzm2.rs index 29c86250..befa2fc5 100644 --- a/mujina-miner/src/board/bzm2.rs +++ b/mujina-miner/src/board/bzm2.rs @@ -14,11 +14,12 @@ use crate::{ api_client::types::{BoardState, Fan, PowerMeasurement, TemperatureSensor, ThreadState}, asic::{ bzm2::{ - Bzm2AsicMeasurement, Bzm2AsicTopology, Bzm2BoardCalibrationInput, + Bzm2AsicMeasurement, Bzm2AsicTopology, Bzm2BoardCalibrationInput, Bzm2BringupPlan, Bzm2CalibrationConstraints, Bzm2CalibrationMode, Bzm2CalibrationPlanner, Bzm2ClockController, Bzm2DomainMeasurement, Bzm2OperatingClass, Bzm2PerformanceMode, Bzm2Pll, Bzm2SavedOperatingPoint, Bzm2Thread, Bzm2ThreadConfig, Bzm2ThreadHandle, - Bzm2UartController, Bzm2VoltageDomain, + Bzm2UartController, Bzm2VoltageDomain, FileGpioPin, FilePowerRail, GpioResetLine, + VoltageStackStep, }, hash_thread::{ HashTask, HashThread, HashThreadCapabilities, HashThreadError, HashThreadEvent, @@ -45,6 +46,9 @@ const DEFAULT_CALIBRATION_POST1_DIVIDER: u8 = 0; const DEFAULT_CALIBRATION_LOCK_TIMEOUT_MS: u64 = 1_000; const DEFAULT_CALIBRATION_LOCK_POLL_MS: u64 = 100; const DEFAULT_ENUMERATION_MAX_ASICS_PER_BUS: u16 = 100; +const DEFAULT_BRINGUP_PRE_POWER_MS: u64 = 10; +const DEFAULT_BRINGUP_POST_POWER_MS: u64 = 25; +const DEFAULT_BRINGUP_RELEASE_RESET_MS: u64 = 25; #[derive(Debug, Clone)] pub struct Bzm2VirtualDeviceConfig { @@ -58,6 +62,7 @@ pub struct Bzm2VirtualDeviceConfig { pub telemetry: Bzm2TelemetryConfig, pub calibration: Bzm2CalibrationConfig, pub enumeration: Bzm2EnumerationConfig, + pub bringup: Bzm2BringupConfig, } impl Bzm2VirtualDeviceConfig { @@ -104,6 +109,7 @@ impl Bzm2VirtualDeviceConfig { .and_then(crate::asic::bzm2::protocol::DtsVsGeneration::from_env_value) .unwrap_or(crate::asic::bzm2::protocol::DtsVsGeneration::Gen2); let calibration = Bzm2CalibrationConfig::from_env(serial_paths.len()); + let bringup = Bzm2BringupConfig::from_env(); Some(Self { serial_paths: serial_paths.clone(), @@ -115,6 +121,7 @@ impl Bzm2VirtualDeviceConfig { dts_vs_generation, telemetry: Bzm2TelemetryConfig::from_env(), enumeration: Bzm2EnumerationConfig::from_env(serial_paths.len(), &calibration), + bringup, calibration, }) } @@ -182,6 +189,182 @@ impl Bzm2EnumerationConfig { } } +#[derive(Debug, Clone)] +pub struct Bzm2BringupConfig { + pub enabled: bool, + pub rail_set_paths: Vec, + pub rail_write_scales: Vec, + pub rail_enable_paths: Vec, + pub rail_enable_values: Vec, + pub reset_path: Option, + pub reset_active_low: bool, + pub plan: Bzm2BringupPlan, +} + +impl Default for Bzm2BringupConfig { + fn default() -> Self { + Self { + enabled: false, + rail_set_paths: Vec::new(), + rail_write_scales: Vec::new(), + rail_enable_paths: Vec::new(), + rail_enable_values: Vec::new(), + reset_path: None, + reset_active_low: true, + plan: Bzm2BringupPlan { + pre_power_delay: Duration::from_millis(DEFAULT_BRINGUP_PRE_POWER_MS), + post_power_delay: Duration::from_millis(DEFAULT_BRINGUP_POST_POWER_MS), + release_reset_delay: Duration::from_millis(DEFAULT_BRINGUP_RELEASE_RESET_MS), + ..Default::default() + }, + } + } +} + +impl Bzm2BringupConfig { + fn from_env() -> Self { + let rail_set_paths = env_var_any(&[ + "MUJINA_BZM2_RAIL_SET_PATHS", + "MUJINA_BZM2_BRINGUP_RAIL_SET_PATHS", + ]) + .map(|value| { + value + .split(',') + .map(str::trim) + .filter(|path| !path.is_empty()) + .map(ToOwned::to_owned) + .collect::>() + }) + .unwrap_or_default(); + let rail_target_volts = parse_csv_numbers::("MUJINA_BZM2_RAIL_TARGET_VOLTS") + .or_else(|| parse_csv_numbers::("MUJINA_BZM2_BRINGUP_RAIL_TARGET_VOLTS")) + .unwrap_or_default(); + let rail_write_scales = parse_csv_numbers::("MUJINA_BZM2_RAIL_WRITE_SCALES") + .or_else(|| parse_csv_numbers::("MUJINA_BZM2_BRINGUP_RAIL_WRITE_SCALES")) + .unwrap_or_default(); + let rail_enable_paths = env_var_any(&[ + "MUJINA_BZM2_RAIL_ENABLE_PATHS", + "MUJINA_BZM2_BRINGUP_RAIL_ENABLE_PATHS", + ]) + .map(|value| { + value + .split(',') + .map(str::trim) + .filter(|path| !path.is_empty()) + .map(ToOwned::to_owned) + .collect::>() + }) + .unwrap_or_default(); + let rail_enable_values = env_var_any(&[ + "MUJINA_BZM2_RAIL_ENABLE_VALUES", + "MUJINA_BZM2_BRINGUP_RAIL_ENABLE_VALUES", + ]) + .map(|value| { + value + .split(',') + .map(str::trim) + .filter(|entry| !entry.is_empty()) + .map(ToOwned::to_owned) + .collect::>() + }) + .unwrap_or_default(); + let reset_path = env_var_any(&["MUJINA_BZM2_RESET_PATH", "MUJINA_BZM2_BRINGUP_RESET_PATH"]); + let enabled = env_flag_any(&["MUJINA_BZM2_ENABLE_BRINGUP", "MUJINA_BZM2_BRINGUP_ENABLE"]) + || !rail_set_paths.is_empty() + || reset_path.is_some(); + + let mut plan = Bzm2BringupPlan { + assert_reset_before_power: env_flag_default_any( + &["MUJINA_BZM2_ASSERT_RESET_BEFORE_POWER"], + true, + ), + pre_power_delay: Duration::from_millis( + env::var("MUJINA_BZM2_BRINGUP_PRE_POWER_MS") + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(DEFAULT_BRINGUP_PRE_POWER_MS), + ), + post_power_delay: Duration::from_millis( + env::var("MUJINA_BZM2_BRINGUP_POST_POWER_MS") + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(DEFAULT_BRINGUP_POST_POWER_MS), + ), + release_reset_delay: Duration::from_millis( + env::var("MUJINA_BZM2_BRINGUP_RELEASE_RESET_MS") + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(DEFAULT_BRINGUP_RELEASE_RESET_MS), + ), + ..Default::default() + }; + plan.steps = rail_set_paths + .iter() + .enumerate() + .filter_map(|(index, _)| { + rail_target_volts + .get(index) + .or_else(|| rail_target_volts.last()) + .copied() + .map(|voltage| VoltageStackStep { + rail_index: index, + voltage, + settle_for: Duration::ZERO, + }) + }) + .collect(); + + Self { + enabled, + rail_set_paths, + rail_write_scales, + rail_enable_paths, + rail_enable_values, + reset_path, + reset_active_low: env_flag_default_any(&["MUJINA_BZM2_RESET_ACTIVE_LOW"], true), + plan, + } + } + + fn build_rails(&self) -> Vec { + self.rail_set_paths + .iter() + .enumerate() + .map(|(index, path)| { + let write_scale = *self + .rail_write_scales + .get(index) + .or_else(|| self.rail_write_scales.last()) + .unwrap_or(&1.0); + let mut rail = FilePowerRail::new(path.clone(), write_scale); + if let Some(enable_path) = self + .rail_enable_paths + .get(index) + .or_else(|| self.rail_enable_paths.last()) + { + let enable_value = self + .rail_enable_values + .get(index) + .or_else(|| self.rail_enable_values.last()) + .cloned() + .unwrap_or_else(|| "1".into()); + rail = rail.with_enable(enable_path.clone(), enable_value); + } + rail + }) + .collect() + } + + fn build_reset_line(&self) -> Option> { + self.reset_path.as_ref().map(|path| { + GpioResetLine::new( + FileGpioPin::new(path.clone(), "1", "0"), + self.reset_active_low, + ) + }) + } +} + #[derive(Debug, Clone)] pub struct Bzm2CalibrationConfig { pub enabled: bool, @@ -593,6 +776,7 @@ struct Bzm2LoadedCalibrationProfile { pub struct Bzm2Board { config: Bzm2VirtualDeviceConfig, + bringup_applied: bool, shutdown_handles: Vec, serial_controls: Vec, state_tx: watch::Sender, @@ -611,6 +795,7 @@ impl Bzm2Board { ) -> Self { Self { config, + bringup_applied: false, shutdown_handles: Vec::new(), serial_controls: Vec::new(), state_tx, @@ -622,6 +807,44 @@ impl Bzm2Board { } } + async fn apply_bringup_sequence(&mut self) -> Result<(), BoardError> { + if self.bringup_applied || !self.config.bringup.enabled { + return Ok(()); + } + + let mut rails = self.config.bringup.build_rails(); + let mut reset_line = self.config.bringup.build_reset_line(); + self.config + .bringup + .plan + .apply(&mut rails, reset_line.as_mut()) + .await + .map_err(|err| { + BoardError::InitializationFailed(format!("BZM2 bring-up sequence failed: {err}")) + })?; + self.bringup_applied = true; + Ok(()) + } + + async fn apply_shutdown_sequence(&mut self) -> Result<(), BoardError> { + if !self.bringup_applied || !self.config.bringup.enabled { + return Ok(()); + } + + let mut rails = self.config.bringup.build_rails(); + let mut reset_line = self.config.bringup.build_reset_line(); + self.config + .bringup + .plan + .shutdown(&mut rails, reset_line.as_mut()) + .await + .map_err(|err| { + BoardError::HardwareControl(format!("BZM2 shutdown sequence failed: {err}")) + })?; + self.bringup_applied = false; + Ok(()) + } + fn spawn_monitor(&mut self) { if !self.config.telemetry.is_enabled() || self.monitor_task.is_some() { return; @@ -1115,12 +1338,14 @@ impl Board for Bzm2Board { thread.hashrate = 0; } }); + self.apply_shutdown_sequence().await?; Ok(()) } async fn create_hash_threads(&mut self) -> Result>, BoardError> { let mut threads: Vec> = Vec::new(); let mut thread_states = Vec::new(); + self.apply_bringup_sequence().await?; let bus_layouts = self.resolve_bus_layouts().await?; let initial_snapshot = self.config.telemetry.snapshot(); let _ = self.state_tx.send_modify(|state| { @@ -1814,6 +2039,7 @@ mod tests { dts_vs_generation: crate::asic::bzm2::protocol::DtsVsGeneration::Gen2, telemetry: Bzm2TelemetryConfig::default(), enumeration: Bzm2EnumerationConfig::default(), + bringup: Bzm2BringupConfig::default(), calibration: Bzm2CalibrationConfig { enabled: true, asics_per_bus: vec![2], @@ -1887,6 +2113,7 @@ mod tests { dts_vs_generation: crate::asic::bzm2::protocol::DtsVsGeneration::Gen2, telemetry: Bzm2TelemetryConfig::default(), enumeration: Bzm2EnumerationConfig::default(), + bringup: Bzm2BringupConfig::default(), calibration: Bzm2CalibrationConfig { enabled: true, apply_saved_operating_point: true, @@ -1947,6 +2174,7 @@ mod tests { start_id: 0, max_asics_per_bus: vec![4], }, + bringup: Bzm2BringupConfig::default(), calibration: Bzm2CalibrationConfig::default(), }; let (state_tx, _state_rx) = watch::channel(BoardState { @@ -2002,6 +2230,7 @@ mod tests { start_id: 0, max_asics_per_bus: vec![4], }, + bringup: Bzm2BringupConfig::default(), calibration: Bzm2CalibrationConfig { asics_per_bus: vec![3], ..Default::default() @@ -2022,6 +2251,98 @@ mod tests { emulator.join().unwrap(); } + #[tokio::test] + async fn create_hash_threads_applies_bringup_and_shutdown_sequences() { + let pty = openpty(None, None).unwrap(); + let serial_path = fs::read_link(format!("/proc/self/fd/{}", pty.slave.as_raw_fd())) + .unwrap() + .to_string_lossy() + .into_owned(); + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let rail0_path = std::env::temp_dir().join(format!("bzm2-rail0-{unique}.txt")); + let rail1_path = std::env::temp_dir().join(format!("bzm2-rail1-{unique}.txt")); + let enable0_path = std::env::temp_dir().join(format!("bzm2-enable0-{unique}.txt")); + let enable1_path = std::env::temp_dir().join(format!("bzm2-enable1-{unique}.txt")); + let reset_path = std::env::temp_dir().join(format!("bzm2-reset-{unique}.txt")); + + let config = Bzm2VirtualDeviceConfig { + serial_paths: vec![serial_path], + baud_rate: DEFAULT_BAUD_RATE, + timestamp_count: crate::asic::bzm2::protocol::DEFAULT_TIMESTAMP_COUNT, + nonce_gap: crate::asic::bzm2::protocol::DEFAULT_NONCE_GAP, + dispatch_interval: Duration::from_millis(50), + nominal_hashrate_ths: DEFAULT_NOMINAL_HASHRATE_THS, + dts_vs_generation: crate::asic::bzm2::protocol::DtsVsGeneration::Gen2, + telemetry: Bzm2TelemetryConfig::default(), + enumeration: Bzm2EnumerationConfig::default(), + bringup: Bzm2BringupConfig { + enabled: true, + rail_set_paths: vec![ + rail0_path.to_string_lossy().into_owned(), + rail1_path.to_string_lossy().into_owned(), + ], + rail_write_scales: vec![1000.0, 1000.0], + rail_enable_paths: vec![ + enable0_path.to_string_lossy().into_owned(), + enable1_path.to_string_lossy().into_owned(), + ], + rail_enable_values: vec!["EN".into(), "ON".into()], + reset_path: Some(reset_path.to_string_lossy().into_owned()), + reset_active_low: true, + plan: Bzm2BringupPlan { + pre_power_delay: Duration::ZERO, + post_power_delay: Duration::ZERO, + release_reset_delay: Duration::ZERO, + steps: vec![ + VoltageStackStep { + rail_index: 0, + voltage: 1.1, + settle_for: Duration::ZERO, + }, + VoltageStackStep { + rail_index: 1, + voltage: 1.25, + settle_for: Duration::ZERO, + }, + ], + ..Default::default() + }, + }, + calibration: Bzm2CalibrationConfig::default(), + }; + let (state_tx, _state_rx) = watch::channel(BoardState { + name: "bzm2-test".into(), + model: "BZM2".into(), + serial: Some("bzm2-test".into()), + ..Default::default() + }); + let mut board = Bzm2Board::new(config, state_tx, mpsc::channel(1).1); + + let _threads = board.create_hash_threads().await.unwrap(); + + assert_eq!(fs::read_to_string(&rail0_path).unwrap(), "1100"); + assert_eq!(fs::read_to_string(&rail1_path).unwrap(), "1250"); + assert_eq!(fs::read_to_string(&enable0_path).unwrap(), "EN"); + assert_eq!(fs::read_to_string(&enable1_path).unwrap(), "ON"); + assert_eq!(fs::read_to_string(&reset_path).unwrap(), "1"); + + board.shutdown().await.unwrap(); + + assert_eq!(fs::read_to_string(&rail0_path).unwrap(), "0"); + assert_eq!(fs::read_to_string(&rail1_path).unwrap(), "0"); + assert_eq!(fs::read_to_string(&reset_path).unwrap(), "0"); + + let _ = fs::remove_file(rail0_path); + let _ = fs::remove_file(rail1_path); + let _ = fs::remove_file(enable0_path); + let _ = fs::remove_file(enable1_path); + let _ = fs::remove_file(reset_path); + drop(pty); + } + #[tokio::test] async fn board_safety_trip_closes_scheduler_event_stream() { let pty = openpty(None, None).unwrap(); @@ -2058,6 +2379,7 @@ mod tests { ..Default::default() }, enumeration: Bzm2EnumerationConfig::default(), + bringup: Bzm2BringupConfig::default(), calibration: Bzm2CalibrationConfig::default(), }; let (state_tx, mut state_rx) = watch::channel(BoardState { From 64c91c62ad5be072515ae743afa7a5fdbae03aa0 Mon Sep 17 00:00:00 2001 From: Reckless Apotheosis <125509978+recklessnode@users.noreply.github.com> Date: Fri, 6 Mar 2026 15:58:57 -0800 Subject: [PATCH 10/24] feat(bzm2): apply domain voltages and rail telemetry Extend the board runtime from sequencing alone to active rail management. This commit adds: - board-state rail telemetry publication - application of planner-generated per-domain voltages onto the configured rail control path - persistence and replay of the applied domain-voltage state alongside the saved operating point --- docs/blockscale-reference-roadmap.md | 22 +- docs/bzm2-port.md | 44 ++- mujina-miner/src/asic/bzm2/pnp.rs | 4 + mujina-miner/src/board/bzm2.rs | 487 ++++++++++++++++++++++++--- 4 files changed, 494 insertions(+), 63 deletions(-) diff --git a/docs/blockscale-reference-roadmap.md b/docs/blockscale-reference-roadmap.md index cf16c273..4263bb12 100644 --- a/docs/blockscale-reference-roadmap.md +++ b/docs/blockscale-reference-roadmap.md @@ -44,11 +44,9 @@ The current repo already has: The biggest missing pieces are: -1. actual rail application for planned domain voltages -2. hardware chain discovery and `ASIC_ID` assignment -3. runtime engine/topology discovery instead of fixed assumptions -4. closed-loop calibration and retune -5. board/API diagnostics parity with the CLI +1. runtime engine/topology discovery instead of fixed assumptions +2. closed-loop calibration and retune +3. board/API diagnostics parity with the CLI ## Phase 1: Discoverable Bring-Up @@ -103,8 +101,8 @@ Status: - completed: `Bzm2BringupPlan` is now wired into `Bzm2Board` startup and shutdown through generic file-backed rail and reset adapters -- next: map planned domain voltages and richer rail telemetry onto those - startup/shutdown hooks +- completed: optional file-backed rail telemetry now flows into `BoardState` +- next: map planned domain voltages onto those startup/shutdown hooks Exit criteria: @@ -131,6 +129,16 @@ Exit criteria: - `Bzm2Board` can apply multi-domain operating points, not just PLL maps +Status: + +- completed: planner-generated per-domain voltages are now mapped onto the + configured rail-control path before PLL ramp +- completed: saved operating-point replay now reapplies persisted per-domain + voltages before clock replay +- completed: live calibration persists per-domain rail targets for restart + replay +- next: Phase 4, topology and defect discovery + ## Phase 4: Topology And Defect Discovery Objective: diff --git a/docs/bzm2-port.md b/docs/bzm2-port.md index 5b1ca1dc..02a1c3f4 100644 --- a/docs/bzm2-port.md +++ b/docs/bzm2-port.md @@ -93,10 +93,33 @@ Supported environment variables: - `MUJINA_BZM2_RAIL_WRITE_SCALES`: optional comma-separated scale factors used when converting volts into the raw file value, for example `1000` for mV or `1000000` for uV +- `MUJINA_BZM2_DOMAIN_RAIL_INDICES`: optional comma-separated mapping from + planner domain id to configured rail index; defaults to one-to-one + `domain_id -> rail_index` when omitted - `MUJINA_BZM2_RAIL_ENABLE_PATHS`: optional comma-separated enable/control file paths paired with the rail list - `MUJINA_BZM2_RAIL_ENABLE_VALUES`: optional comma-separated values written to the rail enable paths during rail initialization +- `MUJINA_BZM2_RAIL_VIN_PATHS`: optional comma-separated rail input-voltage + sensor files +- `MUJINA_BZM2_RAIL_VIN_SCALES`: optional scale factors for the rail input + voltage files +- `MUJINA_BZM2_RAIL_VOUT_PATHS`: optional comma-separated rail output-voltage + sensor files +- `MUJINA_BZM2_RAIL_VOUT_SCALES`: optional scale factors for the rail output + voltage files +- `MUJINA_BZM2_RAIL_CURRENT_PATHS`: optional comma-separated rail current sensor + files +- `MUJINA_BZM2_RAIL_CURRENT_SCALES`: optional scale factors for the rail current + files +- `MUJINA_BZM2_RAIL_POWER_PATHS`: optional comma-separated rail power sensor + files +- `MUJINA_BZM2_RAIL_POWER_SCALES`: optional scale factors for the rail power + files +- `MUJINA_BZM2_RAIL_TEMP_PATHS`: optional comma-separated rail regulator + temperature sensor files +- `MUJINA_BZM2_RAIL_TEMP_SCALES`: optional scale factors for the rail + regulator temperature files - `MUJINA_BZM2_RESET_PATH`: optional reset-control file path - `MUJINA_BZM2_RESET_ACTIVE_LOW`: whether the reset path is active-low, default `true` @@ -122,11 +145,26 @@ Bring-up notes: - if bring-up is enabled, `Bzm2Board` applies the configured rail/reset sequence before chain discovery, calibration, and hash-thread creation +- when the tuning planner produces per-domain voltage targets, `Bzm2Board` + now applies them onto the configured rail-control path before the PLL ramp + rather than treating them as advisory only +- saved operating-point replay now reapplies persisted per-domain voltages + before clock replay when the profile contains them +- if multiple domains are mapped onto one rail, the runtime requires them to + agree on the same target voltage; conflicting targets fail loudly instead of + applying an ambiguous setpoint - on board shutdown, the same plan is used in reverse order to assert reset and drive the configured rails back to `0` -- the current implementation is intentionally coarse-grained: it provides real - board lifecycle sequencing now, while richer rail telemetry and domain-aware - voltage application remain the next steps +- the current implementation is still coarse-grained at the regulator layer: + it applies domain targets onto configured rails, but it does not yet perform + closed-loop voltage verification against live rail telemetry or runtime retune + +If the optional rail telemetry files are configured, the board monitor also +publishes them into normal board state using stable names: + +- `rail0-input`, `rail1-input`, ... for input-side voltage snapshots +- `rail0-output`, `rail1-output`, ... for output-side voltage/current/power +- `rail0-regulator`, `rail1-regulator`, ... for regulator temperatures ## API Telemetry diff --git a/mujina-miner/src/asic/bzm2/pnp.rs b/mujina-miner/src/asic/bzm2/pnp.rs index 78147665..96e75e4b 100644 --- a/mujina-miner/src/asic/bzm2/pnp.rs +++ b/mujina-miner/src/asic/bzm2/pnp.rs @@ -109,6 +109,8 @@ pub struct Bzm2CalibrationSweepRequest { pub struct Bzm2SavedOperatingPoint { pub board_voltage_mv: u32, pub board_throughput_ths: f32, + #[serde(default)] + pub per_domain_voltage_mv: BTreeMap, pub per_asic_pll_mhz: BTreeMap, } @@ -707,6 +709,7 @@ mod tests { saved_operating_point: Some(Bzm2SavedOperatingPoint { board_voltage_mv: 17_500, board_throughput_ths: 42.0, + per_domain_voltage_mv: BTreeMap::new(), per_asic_pll_mhz: stored, }), domain_measurements: vec![Bzm2DomainMeasurement { @@ -756,6 +759,7 @@ mod tests { saved_operating_point: Some(Bzm2SavedOperatingPoint { board_voltage_mv: 17_500, board_throughput_ths: 50.0, + per_domain_voltage_mv: BTreeMap::new(), per_asic_pll_mhz: stored, }), domain_measurements: vec![], diff --git a/mujina-miner/src/board/bzm2.rs b/mujina-miner/src/board/bzm2.rs index befa2fc5..49e9e469 100644 --- a/mujina-miner/src/board/bzm2.rs +++ b/mujina-miner/src/board/bzm2.rs @@ -19,7 +19,7 @@ use crate::{ Bzm2ClockController, Bzm2DomainMeasurement, Bzm2OperatingClass, Bzm2PerformanceMode, Bzm2Pll, Bzm2SavedOperatingPoint, Bzm2Thread, Bzm2ThreadConfig, Bzm2ThreadHandle, Bzm2UartController, Bzm2VoltageDomain, FileGpioPin, FilePowerRail, GpioResetLine, - VoltageStackStep, + VoltageStackStep, control::Bzm2PowerRail, }, hash_thread::{ HashTask, HashThread, HashThreadCapabilities, HashThreadError, HashThreadEvent, @@ -194,8 +194,14 @@ pub struct Bzm2BringupConfig { pub enabled: bool, pub rail_set_paths: Vec, pub rail_write_scales: Vec, + pub domain_rail_indices: Vec, pub rail_enable_paths: Vec, pub rail_enable_values: Vec, + pub rail_vin: Vec, + pub rail_vout: Vec, + pub rail_current: Vec, + pub rail_power: Vec, + pub rail_temperature: Vec, pub reset_path: Option, pub reset_active_low: bool, pub plan: Bzm2BringupPlan, @@ -207,8 +213,14 @@ impl Default for Bzm2BringupConfig { enabled: false, rail_set_paths: Vec::new(), rail_write_scales: Vec::new(), + domain_rail_indices: Vec::new(), rail_enable_paths: Vec::new(), rail_enable_values: Vec::new(), + rail_vin: Vec::new(), + rail_vout: Vec::new(), + rail_current: Vec::new(), + rail_power: Vec::new(), + rail_temperature: Vec::new(), reset_path: None, reset_active_low: true, plan: Bzm2BringupPlan { @@ -223,51 +235,52 @@ impl Default for Bzm2BringupConfig { impl Bzm2BringupConfig { fn from_env() -> Self { - let rail_set_paths = env_var_any(&[ + let rail_set_paths = env_csv_strings_any(&[ "MUJINA_BZM2_RAIL_SET_PATHS", "MUJINA_BZM2_BRINGUP_RAIL_SET_PATHS", - ]) - .map(|value| { - value - .split(',') - .map(str::trim) - .filter(|path| !path.is_empty()) - .map(ToOwned::to_owned) - .collect::>() - }) - .unwrap_or_default(); + ]); let rail_target_volts = parse_csv_numbers::("MUJINA_BZM2_RAIL_TARGET_VOLTS") .or_else(|| parse_csv_numbers::("MUJINA_BZM2_BRINGUP_RAIL_TARGET_VOLTS")) .unwrap_or_default(); let rail_write_scales = parse_csv_numbers::("MUJINA_BZM2_RAIL_WRITE_SCALES") .or_else(|| parse_csv_numbers::("MUJINA_BZM2_BRINGUP_RAIL_WRITE_SCALES")) .unwrap_or_default(); - let rail_enable_paths = env_var_any(&[ + let domain_rail_indices = + parse_csv_numbers_any::(&["MUJINA_BZM2_DOMAIN_RAIL_INDICES"]) + .unwrap_or_default(); + let rail_enable_paths = env_csv_strings_any(&[ "MUJINA_BZM2_RAIL_ENABLE_PATHS", "MUJINA_BZM2_BRINGUP_RAIL_ENABLE_PATHS", - ]) - .map(|value| { - value - .split(',') - .map(str::trim) - .filter(|path| !path.is_empty()) - .map(ToOwned::to_owned) - .collect::>() - }) - .unwrap_or_default(); - let rail_enable_values = env_var_any(&[ + ]); + let rail_enable_values = env_csv_strings_any(&[ "MUJINA_BZM2_RAIL_ENABLE_VALUES", "MUJINA_BZM2_BRINGUP_RAIL_ENABLE_VALUES", - ]) - .map(|value| { - value - .split(',') - .map(str::trim) - .filter(|entry| !entry.is_empty()) - .map(ToOwned::to_owned) - .collect::>() - }) - .unwrap_or_default(); + ]); + let rail_vin = sensor_specs_from_env( + &["MUJINA_BZM2_RAIL_VIN_PATHS"], + &["MUJINA_BZM2_RAIL_VIN_SCALES"], + DEFAULT_VOLTAGE_SCALE, + ); + let rail_vout = sensor_specs_from_env( + &["MUJINA_BZM2_RAIL_VOUT_PATHS"], + &["MUJINA_BZM2_RAIL_VOUT_SCALES"], + DEFAULT_VOLTAGE_SCALE, + ); + let rail_current = sensor_specs_from_env( + &["MUJINA_BZM2_RAIL_CURRENT_PATHS"], + &["MUJINA_BZM2_RAIL_CURRENT_SCALES"], + DEFAULT_CURRENT_SCALE, + ); + let rail_power = sensor_specs_from_env( + &["MUJINA_BZM2_RAIL_POWER_PATHS"], + &["MUJINA_BZM2_RAIL_POWER_SCALES"], + DEFAULT_POWER_SCALE, + ); + let rail_temperature = sensor_specs_from_env( + &["MUJINA_BZM2_RAIL_TEMP_PATHS"], + &["MUJINA_BZM2_RAIL_TEMP_SCALES"], + DEFAULT_BOARD_TEMP_SCALE, + ); let reset_path = env_var_any(&["MUJINA_BZM2_RESET_PATH", "MUJINA_BZM2_BRINGUP_RESET_PATH"]); let enabled = env_flag_any(&["MUJINA_BZM2_ENABLE_BRINGUP", "MUJINA_BZM2_BRINGUP_ENABLE"]) || !rail_set_paths.is_empty() @@ -318,8 +331,14 @@ impl Bzm2BringupConfig { enabled, rail_set_paths, rail_write_scales, + domain_rail_indices, rail_enable_paths, rail_enable_values, + rail_vin, + rail_vout, + rail_current, + rail_power, + rail_temperature, reset_path, reset_active_low: env_flag_default_any(&["MUJINA_BZM2_RESET_ACTIVE_LOW"], true), plan, @@ -363,6 +382,85 @@ impl Bzm2BringupConfig { ) }) } + + fn rail_index_for_domain(&self, domain_id: u16) -> Option { + self.domain_rail_indices + .get(domain_id as usize) + .copied() + .or_else(|| { + let fallback = domain_id as usize; + (fallback < self.rail_set_paths.len()).then_some(fallback) + }) + } + + fn has_telemetry(&self) -> bool { + !self.rail_vin.is_empty() + || !self.rail_vout.is_empty() + || !self.rail_current.is_empty() + || !self.rail_power.is_empty() + || !self.rail_temperature.is_empty() + } + + fn snapshot_telemetry(&self) -> Bzm2TelemetrySnapshot { + let rail_count = [ + self.rail_set_paths.len(), + self.rail_vin.len(), + self.rail_vout.len(), + self.rail_current.len(), + self.rail_power.len(), + self.rail_temperature.len(), + ] + .into_iter() + .max() + .unwrap_or(0); + + let mut temperatures = Vec::new(); + let mut powers = Vec::new(); + for index in 0..rail_count { + let vin = self.rail_vin.get(index).and_then(SensorSpec::read); + let vout = self.rail_vout.get(index).and_then(SensorSpec::read); + let current = self.rail_current.get(index).and_then(SensorSpec::read); + let power = self + .rail_power + .get(index) + .and_then(SensorSpec::read) + .or_else(|| match (vout, current) { + (Some(voltage_v), Some(current_a)) => Some(voltage_v * current_a), + _ => None, + }); + let temperature_c = self.rail_temperature.get(index).and_then(SensorSpec::read); + + if let Some(temperature_c) = temperature_c { + temperatures.push(TemperatureSensor { + name: format!("rail{}-regulator", index), + temperature_c: Some(temperature_c), + }); + } + if vin.is_some() { + powers.push(PowerMeasurement { + name: format!("rail{}-input", index), + voltage_v: vin, + current_a: None, + power_w: None, + }); + } + if vout.is_some() || current.is_some() || power.is_some() { + powers.push(PowerMeasurement { + name: format!("rail{}-output", index), + voltage_v: vout, + current_a: current, + power_w: power, + }); + } + } + + Bzm2TelemetrySnapshot { + fans: Vec::new(), + temperatures, + powers, + trip_reason: None, + } + } } #[derive(Debug, Clone)] @@ -846,11 +944,14 @@ impl Bzm2Board { } fn spawn_monitor(&mut self) { - if !self.config.telemetry.is_enabled() || self.monitor_task.is_some() { + if (!self.config.telemetry.is_enabled() && !self.config.bringup.has_telemetry()) + || self.monitor_task.is_some() + { return; } let telemetry = self.config.telemetry.clone(); + let rail_telemetry = self.config.bringup.clone(); let state_tx = self.state_tx.clone(); let shutdown_handles = self.shutdown_handles.clone(); let serial_controls = self.serial_controls.clone(); @@ -865,6 +966,7 @@ impl Bzm2Board { tokio::select! { _ = interval.tick() => { let snapshot = telemetry.snapshot(); + let rail_snapshot = rail_telemetry.snapshot_telemetry(); let total_stats = serial_controls.iter().fold((0u64, 0u64), |acc, control| { let stats = control.stats(); (acc.0 + stats.bytes_read, acc.1 + stats.bytes_written) @@ -873,6 +975,8 @@ impl Bzm2Board { state.fans = snapshot.fans.clone(); merge_temperature_readings(&mut state.temperatures, &snapshot.temperatures); merge_power_readings(&mut state.powers, &snapshot.powers); + merge_temperature_readings(&mut state.temperatures, &rail_snapshot.temperatures); + merge_power_readings(&mut state.powers, &rail_snapshot.powers); }); trace!(board = %board_name, bytes_read = total_stats.0, bytes_written = total_stats.1, "BZM2 board telemetry updated"); if let Some(reason) = snapshot.trip_reason.clone() { @@ -1115,16 +1219,13 @@ impl Bzm2Board { constraints: Bzm2CalibrationConstraints::default(), force_retune: calibration.force_retune, }); - let mut domain_voltages = plan + let per_domain_voltage_mv = plan .domain_plans .iter() - .map(|domain| domain.voltage_mv) - .collect::>(); - domain_voltages.sort_unstable(); - domain_voltages.dedup(); - if domain_voltages.len() > 1 { - warn!(board = %self.config.device_id(), ?domain_voltages, "planner requested multiple domain voltages; board runtime currently applies clock phases only"); - } + .map(|domain| (domain.domain_id, domain.voltage_mv)) + .collect::>(); + self.apply_domain_voltage_map(&per_domain_voltage_mv) + .await?; let per_asic_pll_mhz = plan .asic_plans @@ -1149,6 +1250,7 @@ impl Bzm2Board { self.config.nominal_hashrate_ths as f32, self.config.serial_paths.len(), ), + per_domain_voltage_mv, per_asic_pll_mhz, }; let profile = Bzm2PersistedCalibrationProfile { @@ -1172,6 +1274,8 @@ impl Bzm2Board { bus_layouts: &[Bzm2BusLayout], profile: &Bzm2PersistedCalibrationProfile, ) -> Result<(), BoardError> { + self.apply_domain_voltage_map(&profile.saved_state.per_domain_voltage_mv) + .await?; for bus in bus_layouts { if bus.asic_count == 0 { continue; @@ -1194,6 +1298,74 @@ impl Bzm2Board { Ok(()) } + async fn apply_domain_voltage_map( + &self, + per_domain_voltage_mv: &BTreeMap, + ) -> Result<(), BoardError> { + if per_domain_voltage_mv.is_empty() { + return Ok(()); + } + if self.config.bringup.rail_set_paths.is_empty() { + warn!( + board = %self.config.device_id(), + ?per_domain_voltage_mv, + "planner produced per-domain voltages, but no BZM2 rail control path is configured" + ); + return Ok(()); + } + + let mut rail_targets_mv = BTreeMap::::new(); + for (&domain_id, &voltage_mv) in per_domain_voltage_mv { + let rail_index = self + .config + .bringup + .rail_index_for_domain(domain_id) + .ok_or_else(|| { + BoardError::HardwareControl(format!( + "BZM2 domain {domain_id} has no mapped rail index" + )) + })?; + if rail_index >= self.config.bringup.rail_set_paths.len() { + return Err(BoardError::HardwareControl(format!( + "BZM2 domain {domain_id} mapped to rail {rail_index}, but only {} rail set paths are configured", + self.config.bringup.rail_set_paths.len() + ))); + } + match rail_targets_mv.entry(rail_index) { + std::collections::btree_map::Entry::Vacant(entry) => { + entry.insert(voltage_mv); + } + std::collections::btree_map::Entry::Occupied(entry) + if *entry.get() != voltage_mv => + { + return Err(BoardError::HardwareControl(format!( + "BZM2 rail {rail_index} received conflicting domain voltages: {}mV vs {}mV", + entry.get(), + voltage_mv + ))); + } + std::collections::btree_map::Entry::Occupied(_) => {} + } + } + + let mut rails = self.config.bringup.build_rails(); + for (rail_index, voltage_mv) in rail_targets_mv { + let rail = rails.get_mut(rail_index).ok_or_else(|| { + BoardError::HardwareControl(format!( + "BZM2 rail {rail_index} is missing from configured rail controls" + )) + })?; + rail.set_voltage(voltage_mv as f32 / 1000.0) + .await + .map_err(|err| { + BoardError::HardwareControl(format!( + "Failed to apply BZM2 domain voltage {voltage_mv}mV on rail {rail_index}: {err}" + )) + })?; + } + Ok(()) + } + async fn apply_frequency_map( &self, bus_layouts: &[Bzm2BusLayout], @@ -1348,13 +1520,27 @@ impl Board for Bzm2Board { self.apply_bringup_sequence().await?; let bus_layouts = self.resolve_bus_layouts().await?; let initial_snapshot = self.config.telemetry.snapshot(); + let initial_rail_snapshot = self.config.bringup.snapshot_telemetry(); let _ = self.state_tx.send_modify(|state| { state.fans = initial_snapshot.fans.clone(); merge_temperature_readings(&mut state.temperatures, &initial_snapshot.temperatures); merge_power_readings(&mut state.powers, &initial_snapshot.powers); + merge_temperature_readings( + &mut state.temperatures, + &initial_rail_snapshot.temperatures, + ); + merge_power_readings(&mut state.powers, &initial_rail_snapshot.powers); }); self.execute_live_calibration(&bus_layouts).await?; + let post_calibration_rail_snapshot = self.config.bringup.snapshot_telemetry(); + let _ = self.state_tx.send_modify(|state| { + merge_temperature_readings( + &mut state.temperatures, + &post_calibration_rail_snapshot.temperatures, + ); + merge_power_readings(&mut state.powers, &post_calibration_rail_snapshot.powers); + }); for (index, serial_path) in self.config.serial_paths.iter().enumerate() { let stream = SerialStream::new(serial_path, self.config.baud_rate).map_err(|err| { @@ -1779,6 +1965,19 @@ fn env_var_any(keys: &[&str]) -> Option { keys.iter().find_map(|key| env::var(key).ok()) } +fn env_csv_strings_any(keys: &[&str]) -> Vec { + env_var_any(keys) + .map(|value| { + value + .split(',') + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .collect::>() + }) + .unwrap_or_default() +} + fn env_flag(key: &str) -> bool { env_var_any(&[key]).as_deref().is_some_and(|value| { matches!( @@ -1839,6 +2038,33 @@ where Some(parsed) } +fn parse_csv_numbers_any(keys: &[&str]) -> Option> +where + T: std::str::FromStr, +{ + keys.iter().find_map(|key| parse_csv_numbers::(key)) +} + +fn sensor_specs_from_env( + paths_keys: &[&str], + scales_keys: &[&str], + default_scale: f32, +) -> Vec { + let paths = env_csv_strings_any(paths_keys); + let scales = parse_csv_numbers_any::(scales_keys).unwrap_or_default(); + paths + .into_iter() + .enumerate() + .map(|(index, path)| SensorSpec { + path, + scale: *scales + .get(index) + .or_else(|| scales.last()) + .unwrap_or(&default_scale), + }) + .collect() +} + fn parse_operating_class(value: &str) -> Option { match value.trim().to_ascii_lowercase().as_str() { "generic" => Some(Bzm2OperatingClass::Generic), @@ -2020,14 +2246,17 @@ mod tests { .unwrap() .to_string_lossy() .into_owned(); + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); let profile_path = std::env::temp_dir().join(format!( "bzm2-profile-{}-{}.json", std::process::id(), - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos() + unique )); + let rail0_path = std::env::temp_dir().join(format!("bzm2-domain-rail0-{unique}.txt")); + let rail1_path = std::env::temp_dir().join(format!("bzm2-domain-rail1-{unique}.txt")); let config = Bzm2VirtualDeviceConfig { serial_paths: vec![serial_path], @@ -2039,11 +2268,19 @@ mod tests { dts_vs_generation: crate::asic::bzm2::protocol::DtsVsGeneration::Gen2, telemetry: Bzm2TelemetryConfig::default(), enumeration: Bzm2EnumerationConfig::default(), - bringup: Bzm2BringupConfig::default(), + bringup: Bzm2BringupConfig { + rail_set_paths: vec![ + rail0_path.to_string_lossy().into_owned(), + rail1_path.to_string_lossy().into_owned(), + ], + rail_write_scales: vec![1000.0, 1000.0], + ..Default::default() + }, calibration: Bzm2CalibrationConfig { enabled: true, asics_per_bus: vec![2], asics_per_domain: vec![1], + domain_voltage_offsets_mv: vec![0, 100], profile_path: Some(profile_path.clone()), skip_lock_check: true, ..Default::default() @@ -2064,9 +2301,30 @@ mod tests { .unwrap() .unwrap(); assert_eq!(profile.saved_state.per_asic_pll_mhz.len(), 2); + assert_eq!(profile.saved_state.per_domain_voltage_mv.len(), 2); + assert_eq!( + fs::read_to_string(&rail0_path).unwrap().trim(), + profile + .saved_state + .per_domain_voltage_mv + .get(&0) + .unwrap() + .to_string() + ); + assert_eq!( + fs::read_to_string(&rail1_path).unwrap().trim(), + profile + .saved_state + .per_domain_voltage_mv + .get(&1) + .unwrap() + .to_string() + ); assert!(profile.persisted.is_some()); let _ = fs::remove_file(profile_path); + let _ = fs::remove_file(rail0_path); + let _ = fs::remove_file(rail1_path); drop(pty); } @@ -2077,14 +2335,17 @@ mod tests { .unwrap() .to_string_lossy() .into_owned(); + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); let profile_path = std::env::temp_dir().join(format!( "bzm2-replay-{}-{}.json", std::process::id(), - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos() + unique )); + let rail0_path = std::env::temp_dir().join(format!("bzm2-replay-rail0-{unique}.txt")); + let rail1_path = std::env::temp_dir().join(format!("bzm2-replay-rail1-{unique}.txt")); let persisted = Bzm2PersistedCalibrationProfile { schema_version: Bzm2PersistedCalibrationProfile::SCHEMA_VERSION, operating_class: operating_class_name(Bzm2OperatingClass::Generic).into(), @@ -2094,6 +2355,7 @@ mod tests { saved_state: Bzm2SavedOperatingPoint { board_voltage_mv: 17_500, board_throughput_ths: 80.0, + per_domain_voltage_mv: BTreeMap::from([(0, 17_450), (1, 17_600)]), per_asic_pll_mhz: BTreeMap::from([ (0, [1_100.0, 1_125.0]), (1, [1_150.0, 1_175.0]), @@ -2113,7 +2375,14 @@ mod tests { dts_vs_generation: crate::asic::bzm2::protocol::DtsVsGeneration::Gen2, telemetry: Bzm2TelemetryConfig::default(), enumeration: Bzm2EnumerationConfig::default(), - bringup: Bzm2BringupConfig::default(), + bringup: Bzm2BringupConfig { + rail_set_paths: vec![ + rail0_path.to_string_lossy().into_owned(), + rail1_path.to_string_lossy().into_owned(), + ], + rail_write_scales: vec![1000.0, 1000.0], + ..Default::default() + }, calibration: Bzm2CalibrationConfig { enabled: true, apply_saved_operating_point: true, @@ -2135,8 +2404,12 @@ mod tests { board.execute_live_calibration(&bus_layouts).await.unwrap(); assert_eq!(fs::read_to_string(&profile_path).unwrap(), original); + assert_eq!(fs::read_to_string(&rail0_path).unwrap().trim(), "17450"); + assert_eq!(fs::read_to_string(&rail1_path).unwrap().trim(), "17600"); let _ = fs::remove_file(profile_path); + let _ = fs::remove_file(rail0_path); + let _ = fs::remove_file(rail1_path); drop(pty); } @@ -2285,11 +2558,17 @@ mod tests { rail1_path.to_string_lossy().into_owned(), ], rail_write_scales: vec![1000.0, 1000.0], + domain_rail_indices: Vec::new(), rail_enable_paths: vec![ enable0_path.to_string_lossy().into_owned(), enable1_path.to_string_lossy().into_owned(), ], rail_enable_values: vec!["EN".into(), "ON".into()], + rail_vin: Vec::new(), + rail_vout: Vec::new(), + rail_current: Vec::new(), + rail_power: Vec::new(), + rail_temperature: Vec::new(), reset_path: Some(reset_path.to_string_lossy().into_owned()), reset_active_low: true, plan: Bzm2BringupPlan { @@ -2343,6 +2622,108 @@ mod tests { drop(pty); } + #[tokio::test] + async fn create_hash_threads_publishes_rail_telemetry() { + let pty = openpty(None, None).unwrap(); + let serial_path = fs::read_link(format!("/proc/self/fd/{}", pty.slave.as_raw_fd())) + .unwrap() + .to_string_lossy() + .into_owned(); + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let vin_path = std::env::temp_dir().join(format!("bzm2-vin-{unique}.txt")); + let vout_path = std::env::temp_dir().join(format!("bzm2-vout-{unique}.txt")); + let current_path = std::env::temp_dir().join(format!("bzm2-current-{unique}.txt")); + let power_path = std::env::temp_dir().join(format!("bzm2-power-{unique}.txt")); + let temp_path = std::env::temp_dir().join(format!("bzm2-temp-{unique}.txt")); + fs::write(&vin_path, "12000\n").unwrap(); + fs::write(&vout_path, "850\n").unwrap(); + fs::write(¤t_path, "1500\n").unwrap(); + fs::write(&power_path, "1275\n").unwrap(); + fs::write(&temp_path, "47000\n").unwrap(); + + let config = Bzm2VirtualDeviceConfig { + serial_paths: vec![serial_path], + baud_rate: DEFAULT_BAUD_RATE, + timestamp_count: crate::asic::bzm2::protocol::DEFAULT_TIMESTAMP_COUNT, + nonce_gap: crate::asic::bzm2::protocol::DEFAULT_NONCE_GAP, + dispatch_interval: Duration::from_millis(50), + nominal_hashrate_ths: DEFAULT_NOMINAL_HASHRATE_THS, + dts_vs_generation: crate::asic::bzm2::protocol::DtsVsGeneration::Gen2, + telemetry: Bzm2TelemetryConfig::default(), + enumeration: Bzm2EnumerationConfig::default(), + bringup: Bzm2BringupConfig { + rail_vin: vec![SensorSpec { + path: vin_path.to_string_lossy().into_owned(), + scale: 0.001, + }], + rail_vout: vec![SensorSpec { + path: vout_path.to_string_lossy().into_owned(), + scale: 0.001, + }], + rail_current: vec![SensorSpec { + path: current_path.to_string_lossy().into_owned(), + scale: 0.001, + }], + rail_power: vec![SensorSpec { + path: power_path.to_string_lossy().into_owned(), + scale: 0.001, + }], + rail_temperature: vec![SensorSpec { + path: temp_path.to_string_lossy().into_owned(), + scale: 0.001, + }], + ..Default::default() + }, + calibration: Bzm2CalibrationConfig::default(), + }; + let (state_tx, state_rx) = watch::channel(BoardState { + name: "bzm2-test".into(), + model: "BZM2".into(), + serial: Some("bzm2-test".into()), + ..Default::default() + }); + let mut board = Bzm2Board::new(config, state_tx, mpsc::channel(1).1); + + let _threads = board.create_hash_threads().await.unwrap(); + let state = state_rx.borrow().clone(); + assert!(state.temperatures.iter().any(|sensor| { + sensor.name == "rail0-regulator" + && sensor + .temperature_c + .is_some_and(|value| (value - 47.0).abs() < 0.001) + })); + assert!(state.powers.iter().any(|power| { + power.name == "rail0-input" + && power + .voltage_v + .is_some_and(|value| (value - 12.0).abs() < 0.001) + })); + assert!(state.powers.iter().any(|power| { + power.name == "rail0-output" + && power + .voltage_v + .is_some_and(|value| (value - 0.85).abs() < 0.001) + && power + .current_a + .is_some_and(|value| (value - 1.5).abs() < 0.001) + && power + .power_w + .is_some_and(|value| (value - 1.275).abs() < 0.001) + })); + + board.shutdown().await.unwrap(); + + let _ = fs::remove_file(vin_path); + let _ = fs::remove_file(vout_path); + let _ = fs::remove_file(current_path); + let _ = fs::remove_file(power_path); + let _ = fs::remove_file(temp_path); + drop(pty); + } + #[tokio::test] async fn board_safety_trip_closes_scheduler_event_stream() { let pty = openpty(None, None).unwrap(); From 740aea290b68b487e16e6cbaeed5162aeab9db33 Mon Sep 17 00:00:00 2001 From: Reckless Apotheosis <125509978+recklessnode@users.noreply.github.com> Date: Fri, 6 Mar 2026 15:59:48 -0800 Subject: [PATCH 11/24] feat(bzm2): add engine discovery and runtime layouts Add the physical engine-discovery path and use the discovered topology in the live runtime. This commit adds: - per-ASIC engine probing helpers and CLI support - board/API publication of discovered engine maps - live dispatch and share reconstruction against the discovered layout instead of a fixed default hole map --- docs/blockscale-reference-roadmap.md | 15 + docs/bzm2-port.md | 39 ++- docs/bzm2-uart-debug.md | 27 ++ mujina-miner/src/api/server.rs | 81 ++++- mujina-miner/src/api/v0.rs | 64 +++- mujina-miner/src/api_client/types.rs | 39 +++ mujina-miner/src/asic/bzm2/mod.rs | 4 +- mujina-miner/src/asic/bzm2/protocol.rs | 11 + mujina-miner/src/asic/bzm2/thread.rs | 60 +++- mujina-miner/src/asic/bzm2/uart.rs | 400 ++++++++++++++++++++++++- mujina-miner/src/asic/hash_thread.rs | 3 + mujina-miner/src/bin/bzm2-debug.rs | 104 ++++++- mujina-miner/src/board/bitaxe.rs | 1 + mujina-miner/src/board/bzm2.rs | 138 ++++++++- mujina-miner/src/board/mod.rs | 8 + 15 files changed, 977 insertions(+), 17 deletions(-) diff --git a/docs/blockscale-reference-roadmap.md b/docs/blockscale-reference-roadmap.md index 4263bb12..e6fbc6ea 100644 --- a/docs/blockscale-reference-roadmap.md +++ b/docs/blockscale-reference-roadmap.md @@ -159,6 +159,21 @@ Exit criteria: - systems with missing or disabled engines do not need a code rebuild or static exclusion map edit +Status: + +- completed: TDM-sync engine probe helpers now detect physical engine presence + by reading `ENGINE_REG_END_NONCE`, matching the historical C detection path +- completed: the debug CLI now supports: + - `engine-probe` + - `discover-engine-map` +- completed: discovered per-ASIC engine maps can now be pushed into live + `BoardState.asics` through: + - `Bzm2Board` command handling + - the live BZM2 thread actor + - `POST /api/v0/boards/{name}/bzm2/discover-engines` +- next: feed the discovered engine map into work dispatch, validation helpers, + and tuning calculations + ## Phase 5: Closed-Loop Calibration And Retune Objective: diff --git a/docs/bzm2-port.md b/docs/bzm2-port.md index 02a1c3f4..017f3be5 100644 --- a/docs/bzm2-port.md +++ b/docs/bzm2-port.md @@ -140,7 +140,6 @@ Startup enumeration notes: `MUJINA_BZM2_ASICS_PER_BUS` topology so warm-restart cases do not collapse to zero ASICs -<<<<<<< HEAD Bring-up notes: - if bring-up is enabled, `Bzm2Board` applies the configured rail/reset @@ -225,6 +224,44 @@ curl -X POST http://127.0.0.1:3000/api/v0/boards/bzm2-0/bzm2/dts-vs-query \ ``` See [bzm2-uart-debug.md](bzm2-uart-debug.md) for CLI usage examples and expected output shape. + +## On-Demand Engine Discovery + +Mujina also supports explicit per-ASIC engine-map discovery when the thread is +idle. + +This is useful when: + +- an ASIC is returning unstable shares and the default engine-hole assumption is + no longer trustworthy +- developers need to compare the live engine map against the historical default + BZM2 hole pattern +- operators want the discovered topology recorded in normal board API state + +The engine-discovery path runs through the live BZM2 hash-thread actor, just +like the DTS/VS query path, so UART ownership stays correct. Successful scans +update `BoardState.asics` with: + +- `id` +- `thread_index` +- `serial_path` +- `discovered_engine_count` +- `missing_engines` + +HTTP API: + +- `POST /api/v0/boards/{name}/bzm2/discover-engines` + +Example HTTP request: + +```bash +curl -X POST http://127.0.0.1:3000/api/v0/boards/bzm2-0/bzm2/discover-engines \ + -H "Content-Type: application/json" \ + -d '{"thread_index":0,"asic":2,"tdm_prediv_raw":15,"tdm_counter":16,"timeout_ms":150}' +``` + +The response returns the refreshed `BoardState`, including the updated +`asics` topology entry for the queried ASIC. ## Design Boundary The legacy `bzmd` board-power path mixes three different concerns: diff --git a/docs/bzm2-uart-debug.md b/docs/bzm2-uart-debug.md index 77682ef9..10dd6e56 100644 --- a/docs/bzm2-uart-debug.md +++ b/docs/bzm2-uart-debug.md @@ -12,6 +12,7 @@ The current CLI folds in the portable parts of the legacy `silicon validation` B - explicit TDM enable and disable control - live TDM result, register, noop, and DTS/VS observation - broadcast TDM register-read observation across many ASICs +- engine presence probing and full physical engine-map discovery - engine-wide timestamp, target, and leading-zero programming - deterministic grid-job exercisers for single-phase and back-to-back job dispatch - existing PLL and DLL diagnostics and bring-up helpers @@ -47,6 +48,7 @@ The most useful `silicon validation` tests map to the following commands: - `test_effbst_tdm_continuous_b2b_jobs` -> `job-grid-2phase-watch` - `uart_command_broadcast_readreg_tdm_async` flows -> `tdm-broadcast-read-watch` - general TDM callback validation -> `tdm-watch` +- historical engine-detect flow -> `engine-probe` or `discover-engine-map` The old multicasted-EFFBST and auto-clock-gating tests depended on the legacy BCH vector library and runtime board harness. The new CLI does not claim golden nonce validation there; it provides deterministic packet generation and live observation so developers can still exercise the same ASIC paths when debugging hardware. @@ -144,6 +146,31 @@ cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ tdm-broadcast-read-watch /dev/ttyUSB0 gen2 0 15 notch 0x12 4 5000000 ``` +Probe one physical engine coordinate using the historical TDM-sync +`ENGINE_REG_END_NONCE` detection rule: + +```text +cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ + engine-probe /dev/ttyUSB0 2 0 0 0x0f 16 100 5000000 +``` + +Scan the full `20 x 12` physical grid and report the discovered hole map for +one ASIC: + +```text +cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ + discover-engine-map /dev/ttyUSB0 2 0x0f 16 100 5000000 +``` + +Notes: + +- these commands intentionally scan all physical coordinates, not the legacy + default engine list +- discovery uses the source-grounded rule from the historical C path: + `ENGINE_REG_END_NONCE == 0xfffffffe` means the engine is present +- the CLI reports whether the discovered missing coordinates still match the + default four-hole BZM2 map or whether the ASIC diverges from that assumption + ## Engine-Wide Programming Helpers Set the target register across every engine row on one ASIC or the whole bus: diff --git a/mujina-miner/src/api/server.rs b/mujina-miner/src/api/server.rs index fa90de32..ba9ce1b1 100644 --- a/mujina-miner/src/api/server.rs +++ b/mujina-miner/src/api/server.rs @@ -137,7 +137,8 @@ mod tests { use super::*; use crate::api::commands::SchedulerCommand; use crate::api_client::types::{ - BoardState, Bzm2DtsVsQueryRequest, SourceState, TemperatureSensor, + AsicState, BoardState, Bzm2DtsVsQueryRequest, Bzm2EngineDiscoveryRequest, EngineCoordinate, + SourceState, TemperatureSensor, }; use crate::board::{BoardCommand, BoardRegistration}; @@ -350,6 +351,84 @@ mod tests { drop(cmd_rx); } + #[tokio::test] + async fn bzm2_engine_discovery_endpoint_returns_refreshed_board_state() { + let (miner_tx, miner_rx) = watch::channel(MinerState::default()); + let (cmd_tx, cmd_rx) = mpsc::channel::(16); + let mut registry = BoardRegistry::new(); + let (state_tx, state_rx) = watch::channel(BoardState { + name: "bzm2-test".into(), + model: "BZM2".into(), + ..Default::default() + }); + let state_tx_for_command = state_tx.clone(); + let (board_cmd_tx, mut board_cmd_rx) = mpsc::channel(1); + registry.push(BoardRegistration { + state_rx, + command_tx: Some(board_cmd_tx), + }); + let router = build_router(miner_rx, Arc::new(Mutex::new(registry)), cmd_tx); + + tokio::spawn(async move { + if let Some(BoardCommand::DiscoverBzm2Engines { + thread_index, + asic, + tdm_prediv_raw, + tdm_counter, + timeout_ms, + reply, + }) = board_cmd_rx.recv().await + { + assert_eq!(thread_index, 0); + assert_eq!(asic, 2); + assert_eq!(tdm_prediv_raw, 0x0f); + assert_eq!(tdm_counter, 16); + assert_eq!(timeout_ms, Some(150)); + state_tx_for_command.send_modify(|state| { + state.asics.push(AsicState { + id: 2, + thread_index: Some(0), + serial_path: Some("/dev/ttyUSB0".into()), + discovered_engine_count: Some(236), + missing_engines: vec![ + EngineCoordinate { row: 3, col: 7 }, + EngineCoordinate { row: 5, col: 11 }, + ], + }); + }); + let _ = reply.send(Ok(())); + } + }); + + let (status, body) = post_json( + router, + "/api/v0/boards/bzm2-test/bzm2/discover-engines", + &Bzm2EngineDiscoveryRequest { + thread_index: 0, + asic: 2, + tdm_prediv_raw: 0x0f, + tdm_counter: 16, + timeout_ms: Some(150), + }, + ) + .await; + + assert_eq!(status, 200); + let board: BoardState = serde_json::from_str(&body).unwrap(); + assert!(board.asics.iter().any(|asic| { + asic.id == 2 + && asic.thread_index == Some(0) + && asic.discovered_engine_count == Some(236) + && asic.missing_engines + == vec![ + EngineCoordinate { row: 3, col: 7 }, + EngineCoordinate { row: 5, col: 11 }, + ] + })); + drop(miner_tx); + drop(cmd_rx); + } + #[tokio::test] async fn sources_returns_list() { let miner_state = MinerState { diff --git a/mujina-miner/src/api/v0.rs b/mujina-miner/src/api/v0.rs index 0b9cdcef..73e32e0f 100644 --- a/mujina-miner/src/api/v0.rs +++ b/mujina-miner/src/api/v0.rs @@ -16,7 +16,8 @@ use utoipa_axum::{router::OpenApiRouter, routes}; use super::commands::SchedulerCommand; use super::server::SharedState; use crate::api_client::types::{ - BoardState, Bzm2DtsVsQueryRequest, MinerPatchRequest, MinerState, SourceState, + BoardState, Bzm2DtsVsQueryRequest, Bzm2EngineDiscoveryRequest, MinerPatchRequest, MinerState, + SourceState, }; use crate::board::BoardCommand; @@ -28,6 +29,7 @@ pub fn routes() -> OpenApiRouter { .routes(routes!(get_boards)) .routes(routes!(get_board)) .routes(routes!(query_bzm2_dts_vs)) + .routes(routes!(discover_bzm2_engines)) .routes(routes!(get_sources)) .routes(routes!(get_source)) } @@ -198,6 +200,66 @@ async fn query_bzm2_dts_vs( .ok_or(StatusCode::NOT_FOUND) } +/// Trigger an explicit BZM2 engine-discovery scan and return the refreshed board state. +#[utoipa::path( + post, + path = "/boards/{name}/bzm2/discover-engines", + tag = "boards", + params( + ("name" = String, Path, description = "Board name"), + ), + request_body = Bzm2EngineDiscoveryRequest, + responses( + (status = OK, description = "Refreshed board details", body = BoardState), + (status = BAD_REQUEST, description = "Board does not support BZM2 engine discovery"), + (status = NOT_FOUND, description = "Board not found"), + (status = INTERNAL_SERVER_ERROR, description = "Board command failed"), + ), +)] +async fn discover_bzm2_engines( + State(state): State, + Path(name): Path, + 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::DiscoverBzm2Engines { + thread_index: req.thread_index, + asic: req.asic, + tdm_prediv_raw: req.tdm_prediv_raw, + tdm_counter: req.tdm_counter, + timeout_ms: req.timeout_ms, + reply: tx, + }) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_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) +} + /// Return all registered job sources. #[utoipa::path( get, diff --git a/mujina-miner/src/api_client/types.rs b/mujina-miner/src/api_client/types.rs index 8dbc53fd..ea35e27d 100644 --- a/mujina-miner/src/api_client/types.rs +++ b/mujina-miner/src/api_client/types.rs @@ -31,6 +31,8 @@ pub struct BoardState { pub temperatures: Vec, pub powers: Vec, pub threads: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub asics: Vec, } /// Fan status. @@ -70,6 +72,27 @@ pub struct ThreadState { 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 @@ -97,6 +120,22 @@ pub struct Bzm2DtsVsQueryRequest { pub asic: u8, } +/// Request body for an explicit BZM2 ASIC engine-discovery scan. +#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] +pub struct Bzm2EngineDiscoveryRequest { + /// Index of the BZM2 UART thread/bus to query. + pub thread_index: usize, + /// ASIC id on that UART bus. + pub asic: u8, + /// Raw TDM pre-divider value written into `LOCAL_REG_UART_TDM_CTL`. + pub tdm_prediv_raw: u32, + /// TDM counter value written into `LOCAL_REG_UART_TDM_CTL`. + pub tdm_counter: u8, + /// Optional per-engine probe timeout in milliseconds. + #[serde(skip_serializing_if = "Option::is_none")] + pub timeout_ms: Option, +} + /// Job source status. #[derive(Clone, Debug, Default, Deserialize, Serialize, ToSchema)] pub struct SourceState { diff --git a/mujina-miner/src/asic/bzm2/mod.rs b/mujina-miner/src/asic/bzm2/mod.rs index b6333af0..6f0fcc51 100644 --- a/mujina-miner/src/asic/bzm2/mod.rs +++ b/mujina-miner/src/asic/bzm2/mod.rs @@ -21,6 +21,6 @@ pub use pnp::{ }; pub use thread::{Bzm2Thread, Bzm2ThreadConfig, Bzm2ThreadHandle}; pub use uart::{ - BROADCAST_GROUP_ASIC, Bzm2DtsVsConfig, Bzm2UartController, Bzm2UartError, DEFAULT_ASIC_ID, - DEFAULT_DTS_VS_QUERY_TIMEOUT, NOTCH_REG, + BROADCAST_GROUP_ASIC, Bzm2DiscoveredEngineMap, Bzm2DtsVsConfig, Bzm2EngineCoordinate, + Bzm2UartController, Bzm2UartError, DEFAULT_ASIC_ID, DEFAULT_DTS_VS_QUERY_TIMEOUT, NOTCH_REG, }; diff --git a/mujina-miner/src/asic/bzm2/protocol.rs b/mujina-miner/src/asic/bzm2/protocol.rs index a4f703e6..7cfee11c 100644 --- a/mujina-miner/src/asic/bzm2/protocol.rs +++ b/mujina-miner/src/asic/bzm2/protocol.rs @@ -15,6 +15,7 @@ pub const TARGET_BYTE: u8 = 0x08; pub const ENGINE_REG_TARGET: u8 = 0x44; pub const ENGINE_REG_TIMESTAMP_COUNT: u8 = 0x48; pub const ENGINE_REG_ZEROS_TO_FIND: u8 = 0x49; +pub const ENGINE_REG_END_NONCE: u8 = 0x40; pub const DEFAULT_TIMESTAMP_COUNT: u8 = 60; pub const DEFAULT_NONCE_GAP: u32 = 0x28; @@ -386,6 +387,16 @@ pub fn default_excluded_engines() -> HashSet<(u8, u8)> { HashSet::from([(0, 4), (0, 5), (19, 5), (19, 11)]) } +pub fn physical_engine_coordinates() -> Vec<(u8, u8)> { + let mut coords = Vec::new(); + for col in 0..LOGICAL_ENGINE_COLS { + for row in 0..LOGICAL_ENGINE_ROWS { + coords.push((row, col)); + } + } + coords +} + pub fn default_engine_coordinates() -> Vec<(u8, u8)> { let excluded = default_excluded_engines(); let mut coords = Vec::new(); diff --git a/mujina-miner/src/asic/bzm2/thread.rs b/mujina-miner/src/asic/bzm2/thread.rs index b4bdf731..ce5bbe17 100644 --- a/mujina-miner/src/asic/bzm2/thread.rs +++ b/mujina-miner/src/asic/bzm2/thread.rs @@ -26,7 +26,10 @@ use super::protocol::{ TdmFrame, TdmFrameParser, default_engine_coordinates, encode_write_job, encode_write_register, leading_zero_threshold, logical_engine_address, }; -use super::uart::{Bzm2DtsVsConfig, DEFAULT_DTS_VS_QUERY_TIMEOUT, configure_dts_vs_stream}; +use super::uart::{ + Bzm2DiscoveredEngineMap, Bzm2DtsVsConfig, DEFAULT_DTS_VS_QUERY_TIMEOUT, + configure_dts_vs_stream, discover_engine_map_stream, +}; #[derive(Debug, Clone)] pub struct Bzm2ThreadConfig { @@ -76,6 +79,29 @@ impl Bzm2ThreadHandle { .await .map_err(|_| HashThreadError::TelemetryQueryFailed("thread dropped response".into()))? } + + pub async fn discover_engine_map( + &self, + asic: u8, + tdm_prediv_raw: u32, + tdm_counter: u8, + timeout: Duration, + ) -> Result { + let (response_tx, response_rx) = oneshot::channel(); + self.command_tx + .send(ThreadCommand::DiscoverEngineMap { + asic, + tdm_prediv_raw, + tdm_counter, + timeout, + response_tx, + }) + .await + .map_err(|_| HashThreadError::ChannelClosed("command channel closed".into()))?; + response_rx + .await + .map_err(|_| HashThreadError::DiagnosticsFailed("thread dropped response".into()))? + } } #[derive(Debug)] @@ -95,6 +121,13 @@ enum ThreadCommand { asic: u8, response_tx: oneshot::Sender>, }, + DiscoverEngineMap { + asic: u8, + tdm_prediv_raw: u32, + tdm_counter: u8, + timeout: Duration, + response_tx: oneshot::Sender>, + }, Shutdown, } @@ -320,6 +353,31 @@ async fn bzm2_thread_actor( ).await; let _ = response_tx.send(result); } + ThreadCommand::DiscoverEngineMap { + asic, + tdm_prediv_raw, + tdm_counter, + timeout, + response_tx, + } => { + if current_task.is_some() { + let _ = response_tx.send(Err(HashThreadError::DiagnosticsFailed( + "BZM2 engine discovery requires the thread to be idle".into(), + ))); + continue; + } + let result = discover_engine_map_stream( + &mut reader, + &mut writer, + asic, + tdm_prediv_raw, + tdm_counter, + timeout, + ) + .await + .map_err(|err| HashThreadError::DiagnosticsFailed(err.to_string())); + let _ = response_tx.send(result); + } ThreadCommand::Shutdown => break, } } diff --git a/mujina-miner/src/asic/bzm2/uart.rs b/mujina-miner/src/asic/bzm2/uart.rs index 8e606a64..7ae7681d 100644 --- a/mujina-miner/src/asic/bzm2/uart.rs +++ b/mujina-miner/src/asic/bzm2/uart.rs @@ -1,14 +1,15 @@ use std::time::Duration; use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::time::timeout; +use tokio::time::{Instant, timeout}; use crate::transport::{SerialReader, SerialWriter}; use super::protocol::{ - BROADCAST_ASIC, DtsVsGeneration, OPCODE_UART_LOOPBACK, OPCODE_UART_NOOP, OPCODE_UART_READREG, - TdmDtsVsFrame, TdmFrame, TdmFrameParser, encode_loopback, encode_multicast_write, encode_noop, - encode_read_register, encode_write_job, encode_write_register, + BROADCAST_ASIC, DtsVsGeneration, ENGINE_REG_END_NONCE, OPCODE_UART_LOOPBACK, OPCODE_UART_NOOP, + OPCODE_UART_READREG, TdmDtsVsFrame, TdmFrame, TdmFrameParser, encode_loopback, + encode_multicast_write, encode_noop, encode_read_register, encode_write_job, + encode_write_register, logical_engine_address, physical_engine_coordinates, }; pub const NOTCH_REG: u16 = 0x0fff; @@ -18,6 +19,7 @@ pub const DEFAULT_ASIC_ID: u8 = 0xfa; pub const DEFAULT_NOOP_PROBE_TIMEOUT: Duration = Duration::from_millis(100); const LOCAL_REG_ASIC_ID: u8 = 0x0b; +const LOCAL_REG_UART_TDM_CTL: u8 = 0x07; const LOCAL_REG_SLOW_CLK_DIV: u8 = 0x08; const LOCAL_REG_UART_TX: u8 = 0x0a; const LOCAL_REG_SENS_TDM_GAP_CNT: u8 = 0x2d; @@ -36,6 +38,7 @@ const THERMAL_SENSOR_MODE: u8 = 0; const VOLTAGE_SENSOR_RESOLUTION: u8 = 14; const VOLTAGE_SENSOR_CONVERSION_MODE: u8 = 1; const VOLTAGE_SENSOR_MODE: u8 = 0; +const DISCOVERED_ENGINE_END_NONCE: u32 = 0xffff_fffe; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Bzm2DtsVsConfig { @@ -82,6 +85,49 @@ pub enum Bzm2UartError { #[error("timed out waiting for DTS/VS frame from ASIC {asic:#x}")] DtsVsTimeout { asic: u8 }, + + #[error( + "timed out waiting for TDM register response from ASIC {asic:#x} engine {engine_address:#05x} offset {offset:#04x}" + )] + TdmRegisterTimeout { + asic: u8, + engine_address: u16, + offset: u8, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct Bzm2EngineCoordinate { + pub row: u8, + pub col: u8, + pub engine_address: u16, +} + +impl Bzm2EngineCoordinate { + pub fn new(row: u8, col: u8) -> Self { + Self { + row, + col, + engine_address: logical_engine_address(row, col), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Bzm2DiscoveredEngineMap { + pub asic: u8, + pub present: Vec, + pub missing: Vec, +} + +impl Bzm2DiscoveredEngineMap { + pub fn present_count(&self) -> usize { + self.present.len() + } + + pub fn missing_count(&self) -> usize { + self.missing.len() + } } /// Low-level BZM2 UART control surface. @@ -357,6 +403,71 @@ impl Bzm2UartController { Ok(assigned) } + pub async fn set_tdm_enabled( + &mut self, + prediv_raw: u32, + counter: u8, + enable: bool, + ) -> Result<(), Bzm2UartError> { + set_tdm_enabled_stream(&mut self.writer, prediv_raw, counter, enable).await + } + + pub async fn enable_tdm(&mut self, prediv_raw: u32, counter: u8) -> Result<(), Bzm2UartError> { + self.set_tdm_enabled(prediv_raw, counter, true).await + } + + pub async fn disable_tdm(&mut self, prediv_raw: u32, counter: u8) -> Result<(), Bzm2UartError> { + self.set_tdm_enabled(prediv_raw, counter, false).await + } + + pub async fn read_register_tdm_sync( + &mut self, + asic: u8, + engine_address: u16, + offset: u8, + count: u8, + wait: Duration, + ) -> Result, Bzm2UartError> { + read_register_tdm_sync_stream( + &mut self.reader, + &mut self.writer, + asic, + engine_address, + offset, + count, + wait, + ) + .await + } + + pub async fn detect_engine( + &mut self, + asic: u8, + row: u8, + col: u8, + wait: Duration, + ) -> Result { + detect_engine_stream(&mut self.reader, &mut self.writer, asic, row, col, wait).await + } + + pub async fn discover_engine_map( + &mut self, + asic: u8, + tdm_prediv_raw: u32, + tdm_counter: u8, + wait: Duration, + ) -> Result { + discover_engine_map_stream( + &mut self.reader, + &mut self.writer, + asic, + tdm_prediv_raw, + tdm_counter, + wait, + ) + .await + } + pub async fn loopback(&mut self, asic: u8, payload: &[u8]) -> Result, Bzm2UartError> { let request = encode_loopback(asic, payload); self.writer.write_all(&request).await?; @@ -514,6 +625,134 @@ pub async fn configure_dts_vs_stream( Ok(()) } +pub async fn set_tdm_enabled_stream( + writer: &mut SerialWriter, + prediv_raw: u32, + counter: u8, + enable: bool, +) -> Result<(), Bzm2UartError> { + write_local_reg_u32_raw( + writer, + BROADCAST_ASIC, + LOCAL_REG_UART_TDM_CTL, + encode_tdm_control(prediv_raw, counter, enable), + ) + .await +} + +pub async fn read_register_tdm_sync_stream( + reader: &mut SerialReader, + writer: &mut SerialWriter, + asic: u8, + engine_address: u16, + offset: u8, + count: u8, + wait: Duration, +) -> Result, Bzm2UartError> { + let request = encode_read_register(asic, engine_address, offset, count); + writer.write_all(&request).await?; + writer.flush().await?; + + let deadline = Instant::now() + wait; + let mut parser = TdmFrameParser::default(); + parser.expect_read_register_bytes(asic, count as usize); + let mut read_buf = [0u8; 256]; + + loop { + let now = Instant::now(); + if now >= deadline { + return Err(Bzm2UartError::TdmRegisterTimeout { + asic, + engine_address, + offset, + }); + } + let remaining = deadline.saturating_duration_since(now); + let read = timeout(remaining, reader.read(&mut read_buf)) + .await + .map_err(|_| Bzm2UartError::TdmRegisterTimeout { + asic, + engine_address, + offset, + })??; + if read == 0 { + return Err(Bzm2UartError::ShortResponse { + expected: 1, + actual: 0, + }); + } + for frame in parser.push(&read_buf[..read]) { + if let TdmFrame::Register(frame) = frame { + if frame.asic == asic { + return Ok(frame.data); + } + } + } + } +} + +pub async fn detect_engine_stream( + reader: &mut SerialReader, + writer: &mut SerialWriter, + asic: u8, + row: u8, + col: u8, + wait: Duration, +) -> Result { + let engine_address = logical_engine_address(row, col); + let data = read_register_tdm_sync_stream( + reader, + writer, + asic, + engine_address, + ENGINE_REG_END_NONCE, + 4, + wait, + ) + .await?; + let end_nonce = u32::from_le_bytes(data.try_into().unwrap()); + Ok(end_nonce == DISCOVERED_ENGINE_END_NONCE) +} + +pub async fn discover_engine_map_stream( + reader: &mut SerialReader, + writer: &mut SerialWriter, + asic: u8, + tdm_prediv_raw: u32, + tdm_counter: u8, + wait: Duration, +) -> Result { + set_tdm_enabled_stream(writer, tdm_prediv_raw, tdm_counter, true).await?; + + let result = async { + let mut present = Vec::new(); + let mut missing = Vec::new(); + + for (row, col) in physical_engine_coordinates() { + let coordinate = Bzm2EngineCoordinate::new(row, col); + if detect_engine_stream(reader, writer, asic, row, col, wait).await? { + present.push(coordinate); + } else { + missing.push(coordinate); + } + } + + Ok(Bzm2DiscoveredEngineMap { + asic, + present, + missing, + }) + } + .await; + + let disable_result = set_tdm_enabled_stream(writer, tdm_prediv_raw, tdm_counter, false).await; + match (result, disable_result) { + (Ok(map), Ok(())) => Ok(map), + (Err(err), _) => Err(err), + (Ok(_), Err(err)) => Err(err), + } +} + pub async fn read_dts_vs_frame_stream( reader: &mut SerialReader, generation: DtsVsGeneration, @@ -606,6 +845,10 @@ fn legacy_voltage_mv_to_tune_code(voltage_mv: u32) -> u32 { ((16384.0 / 6.0) * (2.5 * voltage_mv as f32 / 706.7 + 3.0 / resolution_power + 1.0)) as u32 } +fn encode_tdm_control(prediv_raw: u32, counter: u8, enable: bool) -> u32 { + (prediv_raw << 9) | ((counter as u32) << 1) | u32::from(enable) +} + fn validate_response_header( expected_asic: u8, expected_opcode: u8, @@ -635,6 +878,13 @@ fn validate_response_header( #[cfg(test)] mod tests { use super::*; + use std::fs; + use std::io::{Read, Write}; + use std::os::fd::AsRawFd; + + use nix::pty::openpty; + + use crate::transport::SerialStream; #[test] fn response_header_validation_accepts_matching_unicast_response() { @@ -675,4 +925,146 @@ mod tests { fn default_noop_probe_timeout_is_bounded() { assert_eq!(DEFAULT_NOOP_PROBE_TIMEOUT, Duration::from_millis(100)); } + + #[test] + fn discovered_engine_map_counts_entries() { + let map = Bzm2DiscoveredEngineMap { + asic: 2, + present: vec![ + Bzm2EngineCoordinate::new(0, 0), + Bzm2EngineCoordinate::new(1, 0), + ], + missing: vec![Bzm2EngineCoordinate::new(0, 4)], + }; + + assert_eq!(map.present_count(), 2); + assert_eq!(map.missing_count(), 1); + } + + #[tokio::test] + async fn read_register_tdm_sync_decodes_engine_response() { + let pty = openpty(None, None).unwrap(); + let master = pty.master; + let slave = pty.slave; + let serial_path = fs::read_link(format!("/proc/self/fd/{}", slave.as_raw_fd())) + .unwrap() + .to_string_lossy() + .into_owned(); + let engine_address = logical_engine_address(3, 4); + + let emulator = std::thread::spawn(move || { + let mut file = fs::File::from(master); + let mut request = [0u8; 8]; + file.read_exact(&mut request).unwrap(); + assert_eq!( + request.to_vec(), + encode_read_register(2, engine_address, ENGINE_REG_END_NONCE, 4) + ); + file.write_all(&[2, OPCODE_UART_READREG, 0xfe, 0xff, 0xff, 0xff]) + .unwrap(); + file.flush().unwrap(); + std::thread::sleep(Duration::from_millis(20)); + }); + + let stream = SerialStream::new(&serial_path, 5_000_000).unwrap(); + let (reader, writer, _control) = stream.split(); + let mut uart = Bzm2UartController::new(reader, writer); + let data = uart + .read_register_tdm_sync( + 2, + engine_address, + ENGINE_REG_END_NONCE, + 4, + Duration::from_millis(100), + ) + .await + .unwrap(); + assert_eq!(data, vec![0xfe, 0xff, 0xff, 0xff]); + + emulator.join().unwrap(); + } + + #[tokio::test] + async fn discover_engine_map_scans_physical_coordinates() { + let pty = openpty(None, None).unwrap(); + let master = pty.master; + let slave = pty.slave; + let serial_path = fs::read_link(format!("/proc/self/fd/{}", slave.as_raw_fd())) + .unwrap() + .to_string_lossy() + .into_owned(); + let present = std::collections::BTreeSet::from([(0u8, 0u8), (19u8, 10u8)]); + let prediv = 0x0f; + let counter = 16; + + let emulator = std::thread::spawn(move || { + let mut file = fs::File::from(master); + let expected_enable = encode_write_register( + BROADCAST_ASIC, + NOTCH_REG, + LOCAL_REG_UART_TDM_CTL, + &encode_tdm_control(prediv, counter, true).to_le_bytes(), + ); + let mut enable_request = vec![0u8; expected_enable.len()]; + file.read_exact(&mut enable_request).unwrap(); + assert_eq!(enable_request, expected_enable); + + for (row, col) in physical_engine_coordinates() { + let mut request = [0u8; 8]; + file.read_exact(&mut request).unwrap(); + assert_eq!( + request.to_vec(), + encode_read_register( + 1, + logical_engine_address(row, col), + ENGINE_REG_END_NONCE, + 4 + ) + ); + let value = if present.contains(&(row, col)) { + DISCOVERED_ENGINE_END_NONCE + } else { + 0 + }; + let mut response = vec![1, OPCODE_UART_READREG]; + response.extend_from_slice(&value.to_le_bytes()); + file.write_all(&response).unwrap(); + file.flush().unwrap(); + } + + let expected_disable = encode_write_register( + BROADCAST_ASIC, + NOTCH_REG, + LOCAL_REG_UART_TDM_CTL, + &encode_tdm_control(prediv, counter, false).to_le_bytes(), + ); + let mut disable_request = vec![0u8; expected_disable.len()]; + file.read_exact(&mut disable_request).unwrap(); + assert_eq!(disable_request, expected_disable); + std::thread::sleep(Duration::from_millis(20)); + }); + + let stream = SerialStream::new(&serial_path, 5_000_000).unwrap(); + let (reader, writer, _control) = stream.split(); + let mut uart = Bzm2UartController::new(reader, writer); + let discovery = uart + .discover_engine_map(1, prediv, counter, Duration::from_millis(100)) + .await + .unwrap(); + + assert_eq!(discovery.present_count(), 2); + assert_eq!( + discovery.missing_count(), + physical_engine_coordinates().len() - 2 + ); + assert_eq!( + discovery.present, + vec![ + Bzm2EngineCoordinate::new(0, 0), + Bzm2EngineCoordinate::new(19, 10) + ] + ); + + emulator.join().unwrap(); + } } diff --git a/mujina-miner/src/asic/hash_thread.rs b/mujina-miner/src/asic/hash_thread.rs index 0d3ccfbb..3c8c6326 100644 --- a/mujina-miner/src/asic/hash_thread.rs +++ b/mujina-miner/src/asic/hash_thread.rs @@ -146,6 +146,9 @@ pub enum HashThreadError { #[error("Telemetry query failed: {0}")] TelemetryQueryFailed(String), + #[error("Diagnostics failed: {0}")] + DiagnosticsFailed(String), + #[error("Shutdown timeout")] ShutdownTimeout, diff --git a/mujina-miner/src/bin/bzm2-debug.rs b/mujina-miner/src/bin/bzm2-debug.rs index 9e156b95..1d20ca25 100644 --- a/mujina-miner/src/bin/bzm2-debug.rs +++ b/mujina-miner/src/bin/bzm2-debug.rs @@ -6,8 +6,8 @@ use std::time::Duration; use anyhow::{Context, Result, bail}; use mujina_miner::asic::bzm2::protocol::{ DtsVsGeneration, ENGINE_REG_TARGET, ENGINE_REG_TIMESTAMP_COUNT, ENGINE_REG_ZEROS_TO_FIND, - TdmFrame, TdmFrameParser, default_engine_coordinates, encode_read_register, - encode_read_result_command, logical_engine_address, + TdmFrame, TdmFrameParser, default_engine_coordinates, default_excluded_engines, + encode_read_register, encode_read_result_command, logical_engine_address, }; use mujina_miner::asic::bzm2::{ BROADCAST_GROUP_ASIC, Bzm2ClockController, Bzm2Dll, Bzm2DtsVsConfig, Bzm2Pll, @@ -21,6 +21,7 @@ use tokio::time::{Instant, timeout}; const DEFAULT_BAUD: u32 = 5_000_000; const DEFAULT_WATCH_POLL_MS: u64 = 100; const DEFAULT_BROADCAST_READ_TIMEOUT_MS: u64 = 2_000; +const DEFAULT_ENGINE_DISCOVERY_TIMEOUT_MS: u64 = 100; const LOCAL_REG_UART_TDM_CTL: u8 = 0x07; const MAX_EFFBST_SUBJOBS: u8 = 4; @@ -48,6 +49,8 @@ async fn main() -> Result<()> { "tdm-disable" => cmd_tdm_disable(&args[2..]).await, "tdm-watch" => cmd_tdm_watch(&args[2..]).await, "tdm-broadcast-read-watch" => cmd_tdm_broadcast_read_watch(&args[2..]).await, + "engine-probe" => cmd_engine_probe(&args[2..]).await, + "discover-engine-map" => cmd_discover_engine_map(&args[2..]).await, "engine-target-all" => cmd_engine_target_all(&args[2..]).await, "engine-timestamp-all" => cmd_engine_timestamp_all(&args[2..]).await, "engine-zeros-all" => cmd_engine_zeros_all(&args[2..]).await, @@ -88,6 +91,12 @@ fn print_usage() { eprintln!( " tdm-broadcast-read-watch [baud]" ); + eprintln!( + " engine-probe [timeout-ms] [baud]" + ); + eprintln!( + " discover-engine-map [timeout-ms] [baud]" + ); eprintln!(); eprintln!("Engine-wide validation helpers:"); eprintln!(" engine-target-all [baud]"); @@ -150,6 +159,19 @@ fn parse_timeout_duration(raw: Option<&String>) -> Result { } } +fn parse_engine_discovery_timeout(raw: Option<&String>) -> Result { + match raw { + Some(raw) => { + let millis = parse_u32(raw)?; + if millis == 0 { + bail!("timeout-ms must be greater than zero"); + } + Ok(Duration::from_millis(millis as u64)) + } + None => Ok(Duration::from_millis(DEFAULT_ENGINE_DISCOVERY_TIMEOUT_MS)), + } +} + fn parse_dts_generation(raw: &str) -> Result { DtsVsGeneration::from_env_value(raw) .ok_or_else(|| anyhow::anyhow!("invalid DTS/VS generation: {raw}")) @@ -624,6 +646,71 @@ async fn cmd_tdm_broadcast_read_watch(args: &[String]) -> Result<()> { Ok(()) } +async fn cmd_engine_probe(args: &[String]) -> Result<()> { + if args.len() < 6 || args.len() > 8 { + bail!( + "usage: engine-probe [timeout-ms] [baud]" + ); + } + let mut uart = open_uart(&args[0], parse_baud(args.get(7))?)?; + let asic = parse_u8(&args[1])?; + let row = parse_u8(&args[2])?; + let col = parse_u8(&args[3])?; + let prediv = parse_u32(&args[4])?; + let counter = parse_u8(&args[5])?; + let timeout = parse_engine_discovery_timeout(args.get(6))?; + + uart.enable_tdm(prediv, counter).await?; + let result = uart.detect_engine(asic, row, col, timeout).await; + let disable_result = uart.disable_tdm(prediv, counter).await; + let present = result?; + disable_result?; + + println!( + "asic={asic} row={row} col={col} engine=0x{:03x} present={present}", + logical_engine_address(row, col) + ); + Ok(()) +} + +async fn cmd_discover_engine_map(args: &[String]) -> Result<()> { + if args.len() < 4 || args.len() > 6 { + bail!( + "usage: discover-engine-map [timeout-ms] [baud]" + ); + } + let mut uart = open_uart(&args[0], parse_baud(args.get(5))?)?; + let asic = parse_u8(&args[1])?; + let prediv = parse_u32(&args[2])?; + let counter = parse_u8(&args[3])?; + let timeout = parse_engine_discovery_timeout(args.get(4))?; + + let discovery = uart + .discover_engine_map(asic, prediv, counter, timeout) + .await?; + + let legacy_missing = default_excluded_engines(); + let discovered_missing = discovery + .missing + .iter() + .map(|coord| (coord.row, coord.col)) + .collect::>(); + println!( + "asic={} present={} missing={} legacy_default_holes_match={}", + asic, + discovery.present_count(), + discovery.missing_count(), + discovered_missing.len() == legacy_missing.len() + && discovered_missing + .iter() + .all(|coord| legacy_missing.contains(coord)) + ); + if !discovery.missing.is_empty() { + println!("missing: {}", format_engine_coordinates(&discovery.missing)); + } + Ok(()) +} + async fn cmd_engine_target_all(args: &[String]) -> Result<()> { if args.len() < 3 || args.len() > 4 { bail!("usage: engine-target-all [baud]"); @@ -1242,6 +1329,19 @@ fn format_asic_ids(asics: &[u8]) -> String { .join(",") } +fn format_engine_coordinates(coords: &[mujina_miner::asic::bzm2::Bzm2EngineCoordinate]) -> String { + coords + .iter() + .map(|coord| { + format!( + "r{}c{}(0x{:03x})", + coord.row, coord.col, coord.engine_address + ) + }) + .collect::>() + .join(",") +} + #[derive(Debug, Default, Clone, Copy)] struct TdmStats { result: usize, diff --git a/mujina-miner/src/board/bitaxe.rs b/mujina-miner/src/board/bitaxe.rs index d3e38f9d..5041a443 100644 --- a/mujina-miner/src/board/bitaxe.rs +++ b/mujina-miner/src/board/bitaxe.rs @@ -796,6 +796,7 @@ impl BitaxeBoard { }, ], threads: Vec::new(), + asics: Vec::new(), }); // -- Log summary (throttled) -- diff --git a/mujina-miner/src/board/bzm2.rs b/mujina-miner/src/board/bzm2.rs index 49e9e469..57b30821 100644 --- a/mujina-miner/src/board/bzm2.rs +++ b/mujina-miner/src/board/bzm2.rs @@ -11,15 +11,18 @@ use tokio::task::JoinHandle; use super::{Board, BoardCommand, BoardError, BoardInfo, VirtualBoardDescriptor}; use crate::{ - api_client::types::{BoardState, Fan, PowerMeasurement, TemperatureSensor, ThreadState}, + api_client::types::{ + AsicState, BoardState, EngineCoordinate, Fan, PowerMeasurement, TemperatureSensor, + ThreadState, + }, asic::{ bzm2::{ Bzm2AsicMeasurement, Bzm2AsicTopology, Bzm2BoardCalibrationInput, Bzm2BringupPlan, Bzm2CalibrationConstraints, Bzm2CalibrationMode, Bzm2CalibrationPlanner, - Bzm2ClockController, Bzm2DomainMeasurement, Bzm2OperatingClass, Bzm2PerformanceMode, - Bzm2Pll, Bzm2SavedOperatingPoint, Bzm2Thread, Bzm2ThreadConfig, Bzm2ThreadHandle, - Bzm2UartController, Bzm2VoltageDomain, FileGpioPin, FilePowerRail, GpioResetLine, - VoltageStackStep, control::Bzm2PowerRail, + Bzm2ClockController, Bzm2DiscoveredEngineMap, Bzm2DomainMeasurement, + Bzm2OperatingClass, Bzm2PerformanceMode, Bzm2Pll, Bzm2SavedOperatingPoint, Bzm2Thread, + Bzm2ThreadConfig, Bzm2ThreadHandle, Bzm2UartController, Bzm2VoltageDomain, FileGpioPin, + FilePowerRail, GpioResetLine, VoltageStackStep, control::Bzm2PowerRail, }, hash_thread::{ HashTask, HashThread, HashThreadCapabilities, HashThreadError, HashThreadEvent, @@ -49,6 +52,7 @@ const DEFAULT_ENUMERATION_MAX_ASICS_PER_BUS: u16 = 100; const DEFAULT_BRINGUP_PRE_POWER_MS: u64 = 10; const DEFAULT_BRINGUP_POST_POWER_MS: u64 = 25; const DEFAULT_BRINGUP_RELEASE_RESET_MS: u64 = 25; +const DEFAULT_ENGINE_DISCOVERY_TIMEOUT_MS: u64 = 100; #[derive(Debug, Clone)] pub struct Bzm2VirtualDeviceConfig { @@ -1013,6 +1017,7 @@ impl Bzm2Board { let state_tx = self.state_tx.clone(); let shutdown_handles = self.shutdown_handles.clone(); + let serial_paths = self.config.serial_paths.clone(); let board_name = self.config.device_id(); let (shutdown_tx, mut shutdown_rx) = watch::channel(false); self.command_shutdown = Some(shutdown_tx); @@ -1042,6 +1047,49 @@ impl Bzm2Board { .await; let _ = reply.send(result); } + BoardCommand::DiscoverBzm2Engines { + thread_index, + asic, + tdm_prediv_raw, + tdm_counter, + timeout_ms, + reply, + } => { + let result = async { + let handle = shutdown_handles.get(thread_index).ok_or_else(|| { + BoardError::HardwareControl(format!( + "invalid BZM2 thread index {thread_index} for board {board_name}" + )) + })?; + let serial_path = serial_paths.get(thread_index).ok_or_else(|| { + BoardError::HardwareControl(format!( + "missing serial path for BZM2 thread index {thread_index} on board {board_name}" + )) + })?; + let discovery = handle + .discover_engine_map( + asic, + tdm_prediv_raw, + tdm_counter, + Duration::from_millis(u64::from( + timeout_ms.unwrap_or( + DEFAULT_ENGINE_DISCOVERY_TIMEOUT_MS as u32, + ), + )), + ) + .await + .map_err(|err| BoardError::HardwareControl(err.to_string()))?; + publish_discovered_engine_map( + &state_tx, + thread_index, + serial_path, + &discovery, + ); + Ok(()) + } + .await; + let _ = reply.send(result); + } } } changed = shutdown_rx.changed() => { @@ -1714,6 +1762,45 @@ fn publish_thread_telemetry( }); } +fn publish_discovered_engine_map( + state_tx: &watch::Sender, + thread_index: usize, + serial_path: &str, + discovery: &Bzm2DiscoveredEngineMap, +) { + let missing_engines = discovery + .missing + .iter() + .map(|engine| EngineCoordinate { + row: engine.row, + col: engine.col, + }) + .collect::>(); + + let _ = state_tx.send_modify(|state| { + if let Some(asic) = state + .asics + .iter_mut() + .find(|asic| asic.thread_index == Some(thread_index) && asic.id == discovery.asic) + { + asic.serial_path = Some(serial_path.to_owned()); + asic.discovered_engine_count = Some(discovery.present_count() as u16); + asic.missing_engines = missing_engines.clone(); + } else { + state.asics.push(AsicState { + id: discovery.asic, + thread_index: Some(thread_index), + serial_path: Some(serial_path.to_owned()), + discovered_engine_count: Some(discovery.present_count() as u16), + missing_engines: missing_engines.clone(), + }); + } + state + .asics + .sort_by_key(|asic| (asic.thread_index.unwrap_or(usize::MAX), asic.id)); + }); +} + fn merge_temperature_readings( existing: &mut Vec, updates: &[TemperatureSensor], @@ -2924,4 +3011,45 @@ mod tests { ); assert!(state.threads[0].is_active); } + + #[test] + fn publish_discovered_engine_map_updates_board_state() { + let (state_tx, state_rx) = watch::channel(BoardState { + name: "bzm2-test".into(), + model: "BZM2".into(), + serial: Some("bzm2-test".into()), + ..Default::default() + }); + + publish_discovered_engine_map( + &state_tx, + 1, + "/dev/ttyUSB1", + &Bzm2DiscoveredEngineMap { + asic: 2, + present: vec![ + crate::asic::bzm2::Bzm2EngineCoordinate::new(0, 0), + crate::asic::bzm2::Bzm2EngineCoordinate::new(0, 1), + ], + missing: vec![ + crate::asic::bzm2::Bzm2EngineCoordinate::new(3, 7), + crate::asic::bzm2::Bzm2EngineCoordinate::new(5, 11), + ], + }, + ); + + let state = state_rx.borrow().clone(); + assert_eq!(state.asics.len(), 1); + assert_eq!(state.asics[0].id, 2); + assert_eq!(state.asics[0].thread_index, Some(1)); + assert_eq!(state.asics[0].serial_path.as_deref(), Some("/dev/ttyUSB1")); + assert_eq!(state.asics[0].discovered_engine_count, Some(2)); + assert_eq!( + state.asics[0].missing_engines, + vec![ + EngineCoordinate { row: 3, col: 7 }, + EngineCoordinate { row: 5, col: 11 }, + ] + ); + } } diff --git a/mujina-miner/src/board/mod.rs b/mujina-miner/src/board/mod.rs index aba6d90b..f0bad6f6 100644 --- a/mujina-miner/src/board/mod.rs +++ b/mujina-miner/src/board/mod.rs @@ -72,6 +72,14 @@ pub enum BoardCommand { asic: u8, reply: oneshot::Sender>, }, + DiscoverBzm2Engines { + thread_index: usize, + asic: u8, + tdm_prediv_raw: u32, + tdm_counter: u8, + timeout_ms: Option, + reply: oneshot::Sender>, + }, } /// Helper type for async board factory functions From f77325eb63126e3efec19f735921cab2fed5fceb Mon Sep 17 00:00:00 2001 From: Reckless Apotheosis <125509978+recklessnode@users.noreply.github.com> Date: Fri, 6 Mar 2026 16:00:06 -0800 Subject: [PATCH 12/24] feat(bzm2): make runtime tuning engine-capacity aware Adjust the BZM2 tuning path to account for discovered missing engines so throughput and operating-point decisions scale to imperfect silicon and mixed topologies. --- docs/blockscale-reference-roadmap.md | 16 +- docs/bzm2-pnp.md | 34 ++- mujina-miner/src/asic/bzm2/mod.rs | 3 +- mujina-miner/src/asic/bzm2/pnp.rs | 157 ++++++++++++- mujina-miner/src/board/bzm2.rs | 334 +++++++++++++++++++++++++-- 5 files changed, 503 insertions(+), 41 deletions(-) diff --git a/docs/blockscale-reference-roadmap.md b/docs/blockscale-reference-roadmap.md index e6fbc6ea..b860155c 100644 --- a/docs/blockscale-reference-roadmap.md +++ b/docs/blockscale-reference-roadmap.md @@ -171,8 +171,20 @@ Status: - `Bzm2Board` command handling - the live BZM2 thread actor - `POST /api/v0/boards/{name}/bzm2/discover-engines` -- next: feed the discovered engine map into work dispatch, validation helpers, - and tuning calculations +- completed: successful discovery scans now update the live BZM2 runtime engine + layout used by: + - work dispatch fanout + - result reconstruction + - share validation helpers +- completed: calibration input now consumes active-engine counts and missing + coordinates through: + - live pre-calibration engine discovery when enabled + - saved operating-point topology replay + - default-map fallback when no topology data exists +- completed: saved operating-point reuse and planned hashrate estimation now + normalize against real engine capacity instead of assuming every ASIC has the + default full map +- next: Phase 5, closed-loop calibration and retune ## Phase 5: Closed-Loop Calibration And Retune diff --git a/docs/bzm2-pnp.md b/docs/bzm2-pnp.md index c4df4e25..4d8aa085 100644 --- a/docs/bzm2-pnp.md +++ b/docs/bzm2-pnp.md @@ -12,9 +12,9 @@ Before this change, Mujina's BZM2 support had: - UART register access - PLL and DLL control -What it did not have was the legacy PnP calibration planner: +What it did not have was a native Mujina tuning planner for BZM2: -- strategy and bin-specific target selection +- operating-class and performance-mode target selection - parameter sweep generation - initial voltage and frequency selection from site temperature - saved operating point reuse checks @@ -35,8 +35,8 @@ The original C implementation mixed: The reusable algorithmic parts are: -- derive target voltage, frequency, and pass rate from operating class and performance mode -- derive initial voltage and frequency from site thermal conditions +- derive voltage, clock, and acceptance targets from operating class and performance mode +- derive initial voltage and clock from site thermal conditions - broadcast a starting frequency - sweep upward while respecting power and thermal guard rails - tune back down on individual ASICs or stacks when pass rate falls outside the target window @@ -45,12 +45,12 @@ The reusable algorithmic parts are: ## Mujina Ported Behavior The new Rust module at -[C:\Users\prael\Documents\Codex\bzm2_mujina\mujina-miner\src\asic\bzm2\pnp.rs](C:\Users\prael\Documents\Codex\bzm2_mujina\mujina-miner\src\asic\bzm2\pnp.rs) +`mujina-miner/src/asic/bzm2/pnp.rs` implements the reusable planner without pulling board-MCU or PSU glue into the ASIC layer. Implemented: -- performance mode targets for: +- operating-class target tables for: - generic - EarlyValidation - ProductionValidation @@ -58,8 +58,8 @@ Implemented: - StackTunedB - ExtendedHeadroom - ExtendedHeadroomB -- parameter sweep generation analogous to legacy `pnp_create_paramters_vector()` -- ambient-aware initial voltage and frequency planning analogous to `pnp_set_initial_voltage_frequency()` +- search-space generation corresponding to the historical C sweep helper +- site-temperature-aware initial voltage and clock planning corresponding to the historical C startup helper - saved operating point reuse vs. full retune decisions - domain-aware voltage planning using explicit voltage-domain offsets and guards - per-domain frequency planning using aggregated pass-rate, thermal, and power data @@ -88,8 +88,22 @@ That is materially more scalable than treating a 100-ASIC machine as 100 indepen The planner is now wired into `Bzm2Board` startup so Mujina can: - execute a live pre-thread calibration phase -- persist applied calibration results as a stored profile -- replay a compatible stored profile directly on restart before falling back to retune +- persist applied calibration results as a saved operating point profile +- replay a compatible saved operating point profile directly on restart before falling back to retune +- normalize saved-throughput comparisons and planned board hashrate against + actual active-engine capacity instead of assuming every ASIC still has the + default full map + +Engine-capacity inputs now come from, in order: + +- live pre-calibration engine discovery when enabled +- saved per-ASIC topology embedded in the saved operating point profile +- default BZM2 hole-map fallback when no better topology data is available + +That means tuning decisions can now distinguish between: + +- a slow ASIC that still has full engine capacity +- an ASIC that is throughput-limited because it has permanently missing engines What still remains outside the ASIC planner layer: diff --git a/mujina-miner/src/asic/bzm2/mod.rs b/mujina-miner/src/asic/bzm2/mod.rs index 6f0fcc51..1a251cb8 100644 --- a/mujina-miner/src/asic/bzm2/mod.rs +++ b/mujina-miner/src/asic/bzm2/mod.rs @@ -17,7 +17,8 @@ pub use pnp::{ Bzm2AsicMeasurement, Bzm2AsicPlan, Bzm2AsicTopology, Bzm2BoardCalibrationInput, Bzm2CalibrationConstraints, Bzm2CalibrationMode, Bzm2CalibrationPlan, Bzm2CalibrationPlanner, Bzm2CalibrationSweepRequest, Bzm2DomainMeasurement, Bzm2DomainPlan, Bzm2OperatingClass, - Bzm2PerformanceMode, Bzm2SavedOperatingPoint, Bzm2VoltageDomain, + Bzm2PerformanceMode, Bzm2SavedEngineCoordinate, Bzm2SavedEngineTopology, + Bzm2SavedOperatingPoint, Bzm2VoltageDomain, }; pub use thread::{Bzm2Thread, Bzm2ThreadConfig, Bzm2ThreadHandle}; pub use uart::{ diff --git a/mujina-miner/src/asic/bzm2/pnp.rs b/mujina-miner/src/asic/bzm2/pnp.rs index 96e75e4b..ffa6fbe7 100644 --- a/mujina-miner/src/asic/bzm2/pnp.rs +++ b/mujina-miner/src/asic/bzm2/pnp.rs @@ -33,6 +33,7 @@ const DEFAULT_POWER_THRESHOLD_W: f32 = 4_900.0; const DEFAULT_FREQ_INCREASE_RATIO_HIGH: f32 = 0.28; const DEFAULT_FREQ_INCREASE_RATIO_LOW: f32 = 0.24; const DEFAULT_RECALIBRATE_THROUGHPUT_RATIO: f32 = 0.80; +const NOMINAL_ACTIVE_ENGINE_COUNT: u16 = 236; #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub enum Bzm2PerformanceMode { @@ -111,15 +112,33 @@ pub struct Bzm2SavedOperatingPoint { pub board_throughput_ths: f32, #[serde(default)] pub per_domain_voltage_mv: BTreeMap, + #[serde(default)] + pub per_asic_engine_topology: BTreeMap, pub per_asic_pll_mhz: BTreeMap, } +#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)] +pub struct Bzm2SavedEngineCoordinate { + pub row: u8, + pub col: u8, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)] +pub struct Bzm2SavedEngineTopology { + #[serde(default)] + pub active_engine_count: u16, + #[serde(default)] + pub missing_engines: Vec, +} + #[derive(Debug, Clone)] pub struct Bzm2AsicTopology { pub asic_id: u16, pub domain_id: u16, pub pll_count: usize, pub alive: bool, + pub active_engine_count: u16, + pub missing_engines: Vec, } #[derive(Debug, Clone)] @@ -287,22 +306,37 @@ impl Bzm2CalibrationPlanner { .asic_measurements .iter() .any(|asic| asic.throughput_ths.is_some()); + let live_active_engines = total_active_engine_count(&input.asics); let reuse_saved_operating_point = input.saved_operating_point.as_ref().is_some_and(|stored| { + let current_normalized_throughput = + normalize_throughput(current_throughput, live_active_engines); + let stored_normalized_throughput = normalize_throughput( + stored.board_throughput_ths, + stored_total_active_engine_count( + stored, + input.asics.iter().filter(|asic| asic.alive).count(), + ), + ); !input.force_retune && stored.per_asic_pll_mhz.len() == input.asics.iter().filter(|asic| asic.alive).count() && (!has_live_throughput - || current_throughput - >= stored.board_throughput_ths + || current_normalized_throughput + >= stored_normalized_throughput * input.constraints.recalibrate_throughput_ratio) }); let needs_retune = input.force_retune || (has_live_throughput && input.saved_operating_point.as_ref().is_some_and(|stored| { - current_throughput - < stored.board_throughput_ths - * input.constraints.recalibrate_throughput_ratio + normalize_throughput(current_throughput, live_active_engines) + < normalize_throughput( + stored.board_throughput_ths, + stored_total_active_engine_count( + stored, + input.asics.iter().filter(|asic| asic.alive).count(), + ), + ) * input.constraints.recalibrate_throughput_ratio })); let (initial_voltage_mv, freq_increase_threshold_mhz) = initial_voltage_and_threshold( @@ -467,6 +501,13 @@ impl Bzm2CalibrationPlanner { if domain_guarded { asic_notes.push("bounded by domain power guard".into()); } + if asic.active_engine_count < NOMINAL_ACTIVE_ENGINE_COUNT { + asic_notes.push(format!( + "ASIC has {} active engines and {} missing coordinates", + asic.active_engine_count, + asic.missing_engines.len() + )); + } asic_plans.push(Bzm2AsicPlan { asic_id: asic.asic_id, @@ -487,6 +528,14 @@ impl Bzm2CalibrationPlanner { notes.push("building fresh domain-aware calibration plan".into()); } + if live_active_engines < total_nominal_engine_capacity(&input.asics) { + notes.push(format!( + "tuning normalized throughput against {:.1}% active engine capacity", + (live_active_engines as f32 / total_nominal_engine_capacity(&input.asics) as f32) + * 100.0 + )); + } + if input.voltage_domains.len() > 1 { notes.push("domain-first planning enabled for multi-domain hardware".into()); } @@ -511,6 +560,37 @@ impl Bzm2CalibrationPlanner { } } +fn total_active_engine_count(asics: &[Bzm2AsicTopology]) -> u32 { + asics + .iter() + .filter(|asic| asic.alive) + .map(|asic| u32::from(asic.active_engine_count.max(1))) + .sum::() + .max(1) +} + +fn total_nominal_engine_capacity(asics: &[Bzm2AsicTopology]) -> u32 { + (asics.iter().filter(|asic| asic.alive).count() as u32 * u32::from(NOMINAL_ACTIVE_ENGINE_COUNT)) + .max(1) +} + +fn stored_total_active_engine_count(stored: &Bzm2SavedOperatingPoint, alive_asics: usize) -> u32 { + if stored.per_asic_engine_topology.is_empty() { + return (alive_asics as u32 * u32::from(NOMINAL_ACTIVE_ENGINE_COUNT)).max(1); + } + + stored + .per_asic_engine_topology + .values() + .map(|topology| u32::from(topology.active_engine_count.max(1))) + .sum::() + .max(1) +} + +fn normalize_throughput(throughput_ths: f32, active_engine_count: u32) -> f32 { + throughput_ths / active_engine_count.max(1) as f32 +} + #[derive(Debug, Clone, Copy)] struct OperatingTarget { voltage_mv: u32, @@ -705,11 +785,14 @@ mod tests { domain_id: 0, pll_count: 2, alive: true, + active_engine_count: NOMINAL_ACTIVE_ENGINE_COUNT, + missing_engines: Vec::new(), }], saved_operating_point: Some(Bzm2SavedOperatingPoint { board_voltage_mv: 17_500, board_throughput_ths: 42.0, per_domain_voltage_mv: BTreeMap::new(), + per_asic_engine_topology: BTreeMap::new(), per_asic_pll_mhz: stored, }), domain_measurements: vec![Bzm2DomainMeasurement { @@ -755,11 +838,14 @@ mod tests { domain_id: 0, pll_count: 2, alive: true, + active_engine_count: NOMINAL_ACTIVE_ENGINE_COUNT, + missing_engines: Vec::new(), }], saved_operating_point: Some(Bzm2SavedOperatingPoint { board_voltage_mv: 17_500, board_throughput_ths: 50.0, per_domain_voltage_mv: BTreeMap::new(), + per_asic_engine_topology: BTreeMap::new(), per_asic_pll_mhz: stored, }), domain_measurements: vec![], @@ -778,6 +864,65 @@ mod tests { assert!(plan.needs_retune); } + #[test] + fn planner_normalizes_saved_throughput_by_active_engine_capacity() { + let planner = Bzm2CalibrationPlanner; + let mut stored = BTreeMap::new(); + stored.insert(0, [1_075.0, 1_075.0]); + let plan = planner.plan(&Bzm2BoardCalibrationInput { + operating_class: Bzm2OperatingClass::Generic, + site_temp_c: 15.0, + target_mode: Bzm2PerformanceMode::Standard, + mode: Bzm2CalibrationMode::default(), + per_stack_clocking: false, + voltage_domains: vec![Bzm2VoltageDomain { + domain_id: 0, + asic_ids: vec![0], + voltage_offset_mv: 0, + max_power_w: None, + }], + asics: vec![Bzm2AsicTopology { + asic_id: 0, + domain_id: 0, + pll_count: 2, + alive: true, + active_engine_count: NOMINAL_ACTIVE_ENGINE_COUNT / 2, + missing_engines: vec![Bzm2SavedEngineCoordinate { row: 0, col: 1 }], + }], + saved_operating_point: Some(Bzm2SavedOperatingPoint { + board_voltage_mv: 17_500, + board_throughput_ths: 42.0, + per_domain_voltage_mv: BTreeMap::new(), + per_asic_engine_topology: BTreeMap::from([( + 0, + Bzm2SavedEngineTopology { + active_engine_count: NOMINAL_ACTIVE_ENGINE_COUNT, + missing_engines: Vec::new(), + }, + )]), + per_asic_pll_mhz: stored, + }), + domain_measurements: vec![], + asic_measurements: vec![Bzm2AsicMeasurement { + asic_id: 0, + temperature_c: Some(70.0), + throughput_ths: Some(21.0), + average_pass_rate: Some(0.98), + pll_pass_rates: [Some(0.98), Some(0.98)], + }], + constraints: Bzm2CalibrationConstraints::default(), + force_retune: false, + }); + + assert!(plan.reuse_saved_operating_point); + assert!(!plan.needs_retune); + assert!( + plan.notes + .iter() + .any(|note| note.contains("active engine capacity")) + ); + } + #[test] fn multi_domain_plan_scales_to_large_topology() { let planner = Bzm2CalibrationPlanner; @@ -795,6 +940,8 @@ mod tests { domain_id: asic_id / 4, pll_count: 2, alive: true, + active_engine_count: NOMINAL_ACTIVE_ENGINE_COUNT, + missing_engines: Vec::new(), }) .collect(); let domain_measurements: Vec = (0..25) diff --git a/mujina-miner/src/board/bzm2.rs b/mujina-miner/src/board/bzm2.rs index 57b30821..47074769 100644 --- a/mujina-miner/src/board/bzm2.rs +++ b/mujina-miner/src/board/bzm2.rs @@ -20,9 +20,10 @@ use crate::{ Bzm2AsicMeasurement, Bzm2AsicTopology, Bzm2BoardCalibrationInput, Bzm2BringupPlan, Bzm2CalibrationConstraints, Bzm2CalibrationMode, Bzm2CalibrationPlanner, Bzm2ClockController, Bzm2DiscoveredEngineMap, Bzm2DomainMeasurement, - Bzm2OperatingClass, Bzm2PerformanceMode, Bzm2Pll, Bzm2SavedOperatingPoint, Bzm2Thread, - Bzm2ThreadConfig, Bzm2ThreadHandle, Bzm2UartController, Bzm2VoltageDomain, FileGpioPin, - FilePowerRail, GpioResetLine, VoltageStackStep, control::Bzm2PowerRail, + Bzm2OperatingClass, Bzm2PerformanceMode, Bzm2Pll, Bzm2SavedEngineCoordinate, + Bzm2SavedEngineTopology, Bzm2SavedOperatingPoint, Bzm2Thread, Bzm2ThreadConfig, + Bzm2ThreadHandle, Bzm2UartController, Bzm2VoltageDomain, FileGpioPin, FilePowerRail, + GpioResetLine, VoltageStackStep, control::Bzm2PowerRail, }, hash_thread::{ HashTask, HashThread, HashThreadCapabilities, HashThreadError, HashThreadEvent, @@ -48,6 +49,9 @@ const DEFAULT_CALIBRATION_SITE_TEMP_C: f32 = 20.0; const DEFAULT_CALIBRATION_POST1_DIVIDER: u8 = 0; const DEFAULT_CALIBRATION_LOCK_TIMEOUT_MS: u64 = 1_000; const DEFAULT_CALIBRATION_LOCK_POLL_MS: u64 = 100; +const DEFAULT_CALIBRATION_ENGINE_DISCOVERY_TDM_PREDIV_RAW: u32 = 0x0f; +const DEFAULT_CALIBRATION_ENGINE_DISCOVERY_TDM_COUNTER: u8 = 16; +const DEFAULT_CALIBRATION_ENGINE_DISCOVERY_TIMEOUT_MS: u64 = 100; const DEFAULT_ENUMERATION_MAX_ASICS_PER_BUS: u16 = 100; const DEFAULT_BRINGUP_PRE_POWER_MS: u64 = 10; const DEFAULT_BRINGUP_POST_POWER_MS: u64 = 25; @@ -471,6 +475,7 @@ impl Bzm2BringupConfig { pub struct Bzm2CalibrationConfig { pub enabled: bool, pub apply_saved_operating_point: bool, + pub discover_engine_topology: bool, pub operating_class: Bzm2OperatingClass, pub performance_mode: Bzm2PerformanceMode, pub mode: Bzm2CalibrationMode, @@ -485,6 +490,9 @@ pub struct Bzm2CalibrationConfig { pub skip_lock_check: bool, pub lock_timeout: Duration, pub lock_poll_interval: Duration, + pub engine_discovery_tdm_prediv_raw: u32, + pub engine_discovery_tdm_counter: u8, + pub engine_discovery_timeout: Duration, } impl Default for Bzm2CalibrationConfig { @@ -492,6 +500,7 @@ impl Default for Bzm2CalibrationConfig { Self { enabled: false, apply_saved_operating_point: true, + discover_engine_topology: true, operating_class: Bzm2OperatingClass::Generic, performance_mode: Bzm2PerformanceMode::Standard, mode: Bzm2CalibrationMode::default(), @@ -506,6 +515,11 @@ impl Default for Bzm2CalibrationConfig { skip_lock_check: false, lock_timeout: Duration::from_millis(DEFAULT_CALIBRATION_LOCK_TIMEOUT_MS), lock_poll_interval: Duration::from_millis(DEFAULT_CALIBRATION_LOCK_POLL_MS), + engine_discovery_tdm_prediv_raw: DEFAULT_CALIBRATION_ENGINE_DISCOVERY_TDM_PREDIV_RAW, + engine_discovery_tdm_counter: DEFAULT_CALIBRATION_ENGINE_DISCOVERY_TDM_COUNTER, + engine_discovery_timeout: Duration::from_millis( + DEFAULT_CALIBRATION_ENGINE_DISCOVERY_TIMEOUT_MS, + ), } } } @@ -521,6 +535,13 @@ impl Bzm2CalibrationConfig { ], true, ), + discover_engine_topology: env_flag_default_any( + &[ + "MUJINA_BZM2_CALIBRATION_DISCOVER_ENGINES", + "MUJINA_BZM2_DISCOVER_ENGINES_FOR_CALIBRATION", + ], + true, + ), operating_class: env_var_any(&["MUJINA_BZM2_OPERATING_CLASS", "MUJINA_BZM2_BOARD_BIN"]) .as_deref() .and_then(parse_operating_class) @@ -587,6 +608,29 @@ impl Bzm2CalibrationConfig { .and_then(|value| value.parse().ok()) .unwrap_or(DEFAULT_CALIBRATION_LOCK_POLL_MS), ), + engine_discovery_tdm_prediv_raw: env_var_any(&[ + "MUJINA_BZM2_ENGINE_DISCOVERY_TDM_PREDIV_RAW", + "MUJINA_BZM2_CALIBRATION_ENGINE_DISCOVERY_TDM_PREDIV_RAW", + ]) + .as_deref() + .and_then(parse_u32_any_radix) + .unwrap_or(DEFAULT_CALIBRATION_ENGINE_DISCOVERY_TDM_PREDIV_RAW), + engine_discovery_tdm_counter: env_var_any(&[ + "MUJINA_BZM2_ENGINE_DISCOVERY_TDM_COUNTER", + "MUJINA_BZM2_CALIBRATION_ENGINE_DISCOVERY_TDM_COUNTER", + ]) + .as_deref() + .and_then(parse_u8_any_radix) + .unwrap_or(DEFAULT_CALIBRATION_ENGINE_DISCOVERY_TDM_COUNTER), + engine_discovery_timeout: Duration::from_millis( + env_var_any(&[ + "MUJINA_BZM2_ENGINE_DISCOVERY_TIMEOUT_MS", + "MUJINA_BZM2_CALIBRATION_ENGINE_DISCOVERY_TIMEOUT_MS", + ]) + .as_deref() + .and_then(parse_u64_any_radix) + .unwrap_or(DEFAULT_CALIBRATION_ENGINE_DISCOVERY_TIMEOUT_MS), + ), }; if config.asics_per_bus.is_empty() && serial_count > 0 { @@ -1214,16 +1258,18 @@ impl Bzm2Board { let saved_operating_point = loaded_profile .as_ref() .map(|loaded| loaded.saved_state.clone()); + let engine_topology = self + .resolve_engine_topology_for_calibration(&bus_layouts, saved_operating_point.as_ref()) + .await; let (voltage_domains, domain_lookup) = build_voltage_domains( total_asics as u16, &calibration.asics_per_domain, &calibration.domain_voltage_offsets_mv, ); - let asics = build_topology(&bus_layouts, &domain_lookup); - let alive_asics = asics.iter().filter(|asic| asic.alive).count().max(1); + let asics = build_topology(&bus_layouts, &domain_lookup, &engine_topology); let per_asic_throughput = saved_operating_point .as_ref() - .map(|stored| stored.board_throughput_ths / alive_asics as f32); + .map(|stored| distribute_saved_throughput(stored.board_throughput_ths, &asics)); let shared_temp = snapshot_temperature(&telemetry, "asic") .or_else(|| snapshot_temperature(&telemetry, "board")); let asic_measurements = asics @@ -1231,7 +1277,9 @@ impl Bzm2Board { .map(|asic| Bzm2AsicMeasurement { asic_id: asic.asic_id, temperature_c: shared_temp, - throughput_ths: per_asic_throughput, + throughput_ths: per_asic_throughput + .as_ref() + .and_then(|throughput| throughput.get(&asic.asic_id).copied()), average_pass_rate: None, pll_pass_rates: [None, None], }) @@ -1296,9 +1344,10 @@ impl Bzm2Board { board_throughput_ths: estimate_planned_hashrate( &plan, self.config.nominal_hashrate_ths as f32, - self.config.serial_paths.len(), + &asics, ), per_domain_voltage_mv, + per_asic_engine_topology: engine_topology, per_asic_pll_mhz, }; let profile = Bzm2PersistedCalibrationProfile { @@ -1317,6 +1366,106 @@ impl Bzm2Board { Ok(()) } + async fn resolve_engine_topology_for_calibration( + &self, + bus_layouts: &[Bzm2BusLayout], + saved_operating_point: Option<&Bzm2SavedOperatingPoint>, + ) -> BTreeMap { + let mut topology = saved_operating_point + .map(|saved| saved.per_asic_engine_topology.clone()) + .unwrap_or_default(); + + if self.config.calibration.discover_engine_topology { + for (asic_id, discovery) in self + .discover_engine_topology_for_calibration(bus_layouts) + .await + { + topology.insert(asic_id, saved_engine_topology_from_discovery(&discovery)); + } + } + + for (thread_index, bus) in bus_layouts.iter().enumerate() { + for asic_id in bus.asic_start..bus.asic_start + bus.asic_count { + let saved = topology + .entry(asic_id) + .or_insert_with(default_saved_engine_topology) + .clone(); + if let Some(local_asic) = bus.local_asic_id(asic_id) { + publish_saved_engine_topology( + &self.state_tx, + thread_index, + &bus.serial_path, + local_asic, + &saved, + ); + } + } + } + + topology + } + + async fn discover_engine_topology_for_calibration( + &self, + bus_layouts: &[Bzm2BusLayout], + ) -> BTreeMap { + let mut topology = BTreeMap::new(); + + for (thread_index, bus) in bus_layouts.iter().enumerate() { + if bus.asic_count == 0 { + continue; + } + let stream = match SerialStream::new(&bus.serial_path, self.config.baud_rate) { + Ok(stream) => stream, + Err(err) => { + warn!( + board = %self.config.device_id(), + path = %bus.serial_path, + error = %err, + "Failed to open BZM2 calibration discovery transport" + ); + continue; + } + }; + let (reader, writer, _control) = stream.split(); + let mut uart = Bzm2UartController::new(reader, writer); + + for local_asic in 0..bus.asic_count { + let global_asic = bus.asic_start + local_asic; + match uart + .discover_engine_map( + local_asic as u8, + self.config.calibration.engine_discovery_tdm_prediv_raw, + self.config.calibration.engine_discovery_tdm_counter, + self.config.calibration.engine_discovery_timeout, + ) + .await + { + Ok(discovery) => { + publish_discovered_engine_map( + &self.state_tx, + thread_index, + &bus.serial_path, + &discovery, + ); + topology.insert(global_asic, discovery); + } + Err(err) => { + warn!( + board = %self.config.device_id(), + path = %bus.serial_path, + asic = local_asic, + error = %err, + "BZM2 calibration engine discovery failed; falling back to saved or default topology" + ); + } + } + } + } + + topology + } + async fn apply_saved_operating_point( &self, bus_layouts: &[Bzm2BusLayout], @@ -1768,30 +1917,70 @@ fn publish_discovered_engine_map( serial_path: &str, discovery: &Bzm2DiscoveredEngineMap, ) { - let missing_engines = discovery - .missing - .iter() - .map(|engine| EngineCoordinate { - row: engine.row, - col: engine.col, - }) - .collect::>(); + upsert_asic_state( + state_tx, + thread_index, + serial_path, + discovery.asic, + discovery.present_count() as u16, + discovery + .missing + .iter() + .map(|engine| EngineCoordinate { + row: engine.row, + col: engine.col, + }) + .collect(), + ); +} +fn publish_saved_engine_topology( + state_tx: &watch::Sender, + thread_index: usize, + serial_path: &str, + asic_id: u8, + topology: &Bzm2SavedEngineTopology, +) { + upsert_asic_state( + state_tx, + thread_index, + serial_path, + asic_id, + topology.active_engine_count, + topology + .missing_engines + .iter() + .map(|engine| EngineCoordinate { + row: engine.row, + col: engine.col, + }) + .collect(), + ); +} + +fn upsert_asic_state( + state_tx: &watch::Sender, + thread_index: usize, + serial_path: &str, + asic_id: u8, + active_engine_count: u16, + missing_engines: Vec, +) { let _ = state_tx.send_modify(|state| { if let Some(asic) = state .asics .iter_mut() - .find(|asic| asic.thread_index == Some(thread_index) && asic.id == discovery.asic) + .find(|asic| asic.thread_index == Some(thread_index) && asic.id == asic_id) { asic.serial_path = Some(serial_path.to_owned()); - asic.discovered_engine_count = Some(discovery.present_count() as u16); + asic.discovered_engine_count = Some(active_engine_count); asic.missing_engines = missing_engines.clone(); } else { state.asics.push(AsicState { - id: discovery.asic, + id: asic_id, thread_index: Some(thread_index), serial_path: Some(serial_path.to_owned()), - discovered_engine_count: Some(discovery.present_count() as u16), + discovered_engine_count: Some(active_engine_count), missing_engines: missing_engines.clone(), }); } @@ -1922,21 +2111,77 @@ fn build_voltage_domains( fn build_topology( bus_layouts: &[Bzm2BusLayout], domain_lookup: &BTreeMap, + engine_topology: &BTreeMap, ) -> Vec { let mut asics = Vec::new(); for layout in bus_layouts { for asic_id in layout.asic_start..layout.asic_start + layout.asic_count { + let saved_topology = engine_topology + .get(&asic_id) + .cloned() + .unwrap_or_else(default_saved_engine_topology); asics.push(Bzm2AsicTopology { asic_id, domain_id: *domain_lookup.get(&asic_id).unwrap_or(&0), pll_count: 2, alive: true, + active_engine_count: saved_topology.active_engine_count, + missing_engines: saved_topology.missing_engines, }); } } asics } +fn default_saved_engine_topology() -> Bzm2SavedEngineTopology { + Bzm2SavedEngineTopology { + active_engine_count: crate::asic::bzm2::protocol::default_engine_coordinates().len() as u16, + missing_engines: crate::asic::bzm2::protocol::default_excluded_engines() + .into_iter() + .map(|(row, col)| Bzm2SavedEngineCoordinate { row, col }) + .collect(), + } +} + +fn saved_engine_topology_from_discovery( + discovery: &Bzm2DiscoveredEngineMap, +) -> Bzm2SavedEngineTopology { + Bzm2SavedEngineTopology { + active_engine_count: discovery.present_count() as u16, + missing_engines: discovery + .missing + .iter() + .map(|coord| Bzm2SavedEngineCoordinate { + row: coord.row, + col: coord.col, + }) + .collect(), + } +} + +fn distribute_saved_throughput( + total_throughput_ths: f32, + asics: &[Bzm2AsicTopology], +) -> BTreeMap { + let total_active = asics + .iter() + .filter(|asic| asic.alive) + .map(|asic| asic.active_engine_count.max(1) as f32) + .sum::() + .max(1.0); + + asics + .iter() + .filter(|asic| asic.alive) + .map(|asic| { + ( + asic.asic_id, + total_throughput_ths * (asic.active_engine_count.max(1) as f32 / total_active), + ) + }) + .collect() +} + fn load_saved_operating_point_profile( path: Option<&Path>, ) -> Result, String> { @@ -2004,9 +2249,10 @@ fn store_calibration_profile( fn estimate_planned_hashrate( plan: &crate::asic::bzm2::Bzm2CalibrationPlan, nominal_hashrate_ths: f32, - thread_count: usize, + asics: &[Bzm2AsicTopology], ) -> f32 { - let nominal_board_hashrate = nominal_hashrate_ths * thread_count.max(1) as f32; + let nominal_board_hashrate = + nominal_hashrate_ths * asics.iter().filter(|asic| asic.alive).count().max(1) as f32; let average_frequency_mhz = if plan.asic_plans.is_empty() { plan.desired_clock_mhz } else { @@ -2021,7 +2267,18 @@ fn estimate_planned_hashrate( } else { 1.0 }; - nominal_board_hashrate * ratio.max(0.1) + let active_engine_ratio = { + let total_active = asics + .iter() + .filter(|asic| asic.alive) + .map(|asic| asic.active_engine_count.max(1) as f32) + .sum::() + .max(1.0); + let total_nominal = asics.iter().filter(|asic| asic.alive).count().max(1) as f32 + * default_saved_engine_topology().active_engine_count as f32; + (total_active / total_nominal).max(0.1) + }; + nominal_board_hashrate * ratio.max(0.1) * active_engine_ratio } fn snapshot_temperature(snapshot: &Bzm2TelemetrySnapshot, name: &str) -> Option { @@ -2052,6 +2309,26 @@ fn env_var_any(keys: &[&str]) -> Option { keys.iter().find_map(|key| env::var(key).ok()) } +fn parse_u8_any_radix(raw: &str) -> Option { + parse_u64_any_radix(raw).and_then(|value| u8::try_from(value).ok()) +} + +fn parse_u32_any_radix(raw: &str) -> Option { + parse_u64_any_radix(raw).and_then(|value| u32::try_from(value).ok()) +} + +fn parse_u64_any_radix(raw: &str) -> Option { + let trimmed = raw.trim(); + if let Some(hex) = trimmed + .strip_prefix("0x") + .or_else(|| trimmed.strip_prefix("0X")) + { + u64::from_str_radix(hex, 16).ok() + } else { + trimmed.parse::().ok() + } +} + fn env_csv_strings_any(keys: &[&str]) -> Vec { env_var_any(keys) .map(|value| { @@ -2389,6 +2666,16 @@ mod tests { .unwrap(); assert_eq!(profile.saved_state.per_asic_pll_mhz.len(), 2); assert_eq!(profile.saved_state.per_domain_voltage_mv.len(), 2); + assert_eq!(profile.saved_state.per_asic_engine_topology.len(), 2); + assert_eq!( + profile + .saved_state + .per_asic_engine_topology + .get(&0) + .unwrap() + .active_engine_count, + default_saved_engine_topology().active_engine_count + ); assert_eq!( fs::read_to_string(&rail0_path).unwrap().trim(), profile @@ -2443,6 +2730,7 @@ mod tests { board_voltage_mv: 17_500, board_throughput_ths: 80.0, per_domain_voltage_mv: BTreeMap::from([(0, 17_450), (1, 17_600)]), + per_asic_engine_topology: BTreeMap::new(), per_asic_pll_mhz: BTreeMap::from([ (0, [1_100.0, 1_125.0]), (1, [1_150.0, 1_175.0]), From b07ebfcb696e53eff896b705b28c1ebf9c7cd58e Mon Sep 17 00:00:00 2001 From: Reckless Apotheosis <125509978+recklessnode@users.noreply.github.com> Date: Fri, 6 Mar 2026 16:00:57 -0800 Subject: [PATCH 13/24] feat(bzm2): add runtime measurements and retune state Add the live runtime feedback path for BZM2 tuning. This commit adds: - per-thread, per-ASIC, and per-PLL runtime measurements - feeding live throughput data back into the tuning planner - persistent retune triggers and saved-operating-point validation state The board runtime can now reason about tuning quality from live operation instead of startup-only assumptions. --- docs/blockscale-reference-roadmap.md | 16 + docs/bzm2-pnp.md | 8 +- mujina-miner/src/api_client/types.rs | 57 ++ mujina-miner/src/asic/bzm2/clock.rs | 8 +- mujina-miner/src/asic/bzm2/mod.rs | 6 +- mujina-miner/src/asic/bzm2/protocol.rs | 53 ++ mujina-miner/src/asic/bzm2/thread.rs | 827 ++++++++++++++++++++++++- mujina-miner/src/board/bitaxe.rs | 1 + mujina-miner/src/board/bzm2.rs | 446 ++++++++++++- 9 files changed, 1385 insertions(+), 37 deletions(-) diff --git a/docs/blockscale-reference-roadmap.md b/docs/blockscale-reference-roadmap.md index b860155c..59e48bf3 100644 --- a/docs/blockscale-reference-roadmap.md +++ b/docs/blockscale-reference-roadmap.md @@ -211,6 +211,22 @@ Exit criteria: - tuning decisions are based on measured runtime behavior, not just startup heuristics and persisted estimates +Status: + +- completed: live BZM2 threads now maintain work-based runtime throughput + estimators for: + - whole-thread throughput + - per-ASIC throughput + - per-PLL throughput using the documented row 0-9 / row 10-19 stack split +- completed: `Bzm2Board` now samples and stores runtime tuning measurements + into live board state and an internal cache, including: + - board throughput + - per-ASIC throughput + - per-ASIC average pass rate + - per-PLL pass rate and throughput + - per-domain measured voltage and power +- next: feed those live measurements back into the tuning planner + ## Phase 6: Diagnostics And API Parity Objective: diff --git a/docs/bzm2-pnp.md b/docs/bzm2-pnp.md index 4d8aa085..bf40c13f 100644 --- a/docs/bzm2-pnp.md +++ b/docs/bzm2-pnp.md @@ -90,6 +90,12 @@ The planner is now wired into `Bzm2Board` startup so Mujina can: - execute a live pre-thread calibration phase - persist applied calibration results as a saved operating point profile - replay a compatible saved operating point profile directly on restart before falling back to retune +- collect live runtime tuning measurements during mining for: + - board throughput + - per-ASIC throughput + - per-ASIC average pass rate + - per-PLL throughput and pass rate + - per-domain measured rail voltage and power - normalize saved-throughput comparisons and planned board hashrate against actual active-engine capacity instead of assuming every ASIC still has the default full map @@ -108,7 +114,7 @@ That means tuning decisions can now distinguish between: What still remains outside the ASIC planner layer: - board-specific PSU ramp policy -- dynamic runtime retune from live pass-rate or throughput feedback +- automatic runtime retune decisions driven by the live measurements above - reimplementation of the legacy CSV/database layer Those pieces still belong above the ASIC planner, in board or daemon integration layers. diff --git a/mujina-miner/src/api_client/types.rs b/mujina-miner/src/api_client/types.rs index ea35e27d..6dabc56e 100644 --- a/mujina-miner/src/api_client/types.rs +++ b/mujina-miner/src/api_client/types.rs @@ -33,6 +33,8 @@ pub struct BoardState { pub threads: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub asics: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub bzm2_tuning: Option, } /// Fan status. @@ -86,6 +88,61 @@ pub struct AsicState { pub missing_engines: Vec, } +/// BZM2 runtime tuning measurements derived from live mining operation. +#[derive(Clone, Debug, Default, Deserialize, Serialize, ToSchema)] +pub struct Bzm2TuningState { + #[serde(skip_serializing_if = "Option::is_none")] + pub board_throughput_hs: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub domains: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub asics: Vec, +} + +/// Per-domain live tuning measurement. +#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] +pub struct Bzm2DomainTuningState { + pub domain_id: u16, + #[serde(skip_serializing_if = "Option::is_none")] + pub rail_index: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub target_voltage_mv: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub measured_voltage_mv: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub measured_power_w: Option, +} + +/// Per-PLL live tuning measurement. +#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] +pub struct Bzm2PllTuningState { + pub pll_index: u8, + #[serde(skip_serializing_if = "Option::is_none")] + pub frequency_mhz: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub throughput_hs: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub pass_rate: Option, +} + +/// Per-ASIC live tuning measurement. +#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] +pub struct Bzm2AsicTuningState { + pub id: u8, + #[serde(skip_serializing_if = "Option::is_none")] + pub thread_index: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub active_engine_count: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub throughput_hs: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub average_pass_rate: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub scheduler_share_count: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub plls: Vec, +} + /// Physical engine coordinate on one ASIC. #[derive(Clone, Debug, Deserialize, Serialize, ToSchema, PartialEq, Eq)] pub struct EngineCoordinate { diff --git a/mujina-miner/src/asic/bzm2/clock.rs b/mujina-miner/src/asic/bzm2/clock.rs index 9a52fd3f..58c12039 100644 --- a/mujina-miner/src/asic/bzm2/clock.rs +++ b/mujina-miner/src/asic/bzm2/clock.rs @@ -75,7 +75,7 @@ pub enum Bzm2Pll { } impl Bzm2Pll { - fn register_block(self) -> (u8, u8, u8, u8) { + pub(crate) fn register_block(self) -> (u8, u8, u8, u8) { match self { Self::Pll0 => ( LOCAL_REG_PLL_POSTDIV, @@ -100,7 +100,7 @@ pub enum Bzm2Dll { } impl Bzm2Dll { - fn registers(self) -> (u8, u8, u8, u8, u8) { + pub(crate) fn registers(self) -> (u8, u8, u8, u8, u8) { match self { Self::Dll0 => ( LOCAL_REG_CKDCCR_2_0, @@ -119,7 +119,7 @@ impl Bzm2Dll { } } - fn fincon_register(self) -> u8 { + pub(crate) fn fincon_register(self) -> u8 { match self { Self::Dll0 => LOCAL_REG_CKDLLR_1_0, Self::Dll1 => LOCAL_REG_CKDLLR_1_1, @@ -549,7 +549,7 @@ impl Bzm2ClockController { } } -fn fincon_is_valid(fincon: u8) -> bool { +pub(crate) fn fincon_is_valid(fincon: u8) -> bool { !matches!(fincon & 0xf0, 0xf0 | 0x00) && !matches!(fincon & 0xe0, 0xe0 | 0x00) } diff --git a/mujina-miner/src/asic/bzm2/mod.rs b/mujina-miner/src/asic/bzm2/mod.rs index 1a251cb8..c222c945 100644 --- a/mujina-miner/src/asic/bzm2/mod.rs +++ b/mujina-miner/src/asic/bzm2/mod.rs @@ -20,7 +20,11 @@ pub use pnp::{ Bzm2PerformanceMode, Bzm2SavedEngineCoordinate, Bzm2SavedEngineTopology, Bzm2SavedOperatingPoint, Bzm2VoltageDomain, }; -pub use thread::{Bzm2Thread, Bzm2ThreadConfig, Bzm2ThreadHandle}; +pub use protocol::Bzm2EngineLayout; +pub use thread::{ + Bzm2AsicRuntimeMetrics, Bzm2PllRuntimeMetrics, Bzm2Thread, Bzm2ThreadConfig, Bzm2ThreadHandle, + Bzm2ThreadRuntimeMetrics, +}; pub use uart::{ BROADCAST_GROUP_ASIC, Bzm2DiscoveredEngineMap, Bzm2DtsVsConfig, Bzm2EngineCoordinate, Bzm2UartController, Bzm2UartError, DEFAULT_ASIC_ID, DEFAULT_DTS_VS_QUERY_TIMEOUT, NOTCH_REG, diff --git a/mujina-miner/src/asic/bzm2/protocol.rs b/mujina-miner/src/asic/bzm2/protocol.rs index 7cfee11c..90d8f169 100644 --- a/mujina-miner/src/asic/bzm2/protocol.rs +++ b/mujina-miner/src/asic/bzm2/protocol.rs @@ -411,6 +411,59 @@ pub fn default_engine_coordinates() -> Vec<(u8, u8)> { coords } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Bzm2EngineLayout { + active_coordinates: Vec<(u8, u8)>, + logical_ids_by_address: HashMap, +} + +impl Bzm2EngineLayout { + pub fn from_active_coordinates(coords: I) -> Self + where + I: IntoIterator, + { + let mut active_coordinates = coords + .into_iter() + .filter(|(row, col)| *row < LOGICAL_ENGINE_ROWS && *col < LOGICAL_ENGINE_COLS) + .collect::>(); + active_coordinates.sort_by_key(|(row, col)| (*col, *row)); + active_coordinates.dedup(); + + let logical_ids_by_address = active_coordinates + .iter() + .enumerate() + .map(|(logical_id, (row, col))| (logical_engine_address(*row, *col), logical_id as u16)) + .collect(); + + Self { + active_coordinates, + logical_ids_by_address, + } + } + + pub fn active_coordinates(&self) -> &[(u8, u8)] { + &self.active_coordinates + } + + pub fn active_engine_count(&self) -> usize { + self.active_coordinates.len() + } + + pub fn logical_engine_id(&self, row: u8, col: u8) -> Option { + self.logical_engine_id_from_address(logical_engine_address(row, col)) + } + + pub fn logical_engine_id_from_address(&self, engine_address: u16) -> Option { + self.logical_ids_by_address.get(&engine_address).copied() + } +} + +impl Default for Bzm2EngineLayout { + fn default() -> Self { + Self::from_active_coordinates(default_engine_coordinates()) + } +} + pub fn leading_zero_threshold(target: bitcoin::pow::Target) -> u8 { let bytes = target.to_be_bytes(); let mut zeros = 0u8; diff --git a/mujina-miner/src/asic/bzm2/thread.rs b/mujina-miner/src/asic/bzm2/thread.rs index ce5bbe17..11858a76 100644 --- a/mujina-miner/src/asic/bzm2/thread.rs +++ b/mujina-miner/src/asic/bzm2/thread.rs @@ -1,7 +1,7 @@ -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use std::path::Path; use std::sync::{Arc, RwLock}; -use std::time::Duration; +use std::time::{Duration, Instant}; use async_trait::async_trait; use bitcoin::block::Header as BlockHeader; @@ -18,13 +18,17 @@ use crate::asic::hash_thread::{ use crate::job_source::{GeneralPurposeBits, MerkleRootKind}; use crate::tracing::prelude::*; use crate::transport::serial::{SerialControl, SerialReader, SerialWriter}; -use crate::types::{Difficulty, HashRate}; +use crate::types::{Difficulty, HashRate, HashrateEstimator, Work}; +use super::clock::{ + Bzm2ClockDebugReport, Bzm2Dll, Bzm2DllStatus, Bzm2Pll, Bzm2PllStatus, fincon_is_valid, +}; use super::protocol::{ - self, BROADCAST_ASIC, DEFAULT_NONCE_GAP, DEFAULT_TIMESTAMP_COUNT, DtsVsGeneration, - ENGINE_REG_TARGET, ENGINE_REG_TIMESTAMP_COUNT, ENGINE_REG_ZEROS_TO_FIND, TdmDtsVsFrame, - TdmFrame, TdmFrameParser, default_engine_coordinates, encode_write_job, encode_write_register, - leading_zero_threshold, logical_engine_address, + self, BROADCAST_ASIC, Bzm2EngineLayout, DEFAULT_NONCE_GAP, DEFAULT_TIMESTAMP_COUNT, + DtsVsGeneration, ENGINE_REG_TARGET, ENGINE_REG_TIMESTAMP_COUNT, ENGINE_REG_ZEROS_TO_FIND, + OPCODE_UART_LOOPBACK, OPCODE_UART_NOOP, OPCODE_UART_READREG, TdmDtsVsFrame, TdmFrame, + TdmFrameParser, encode_loopback, encode_noop, encode_read_register, encode_write_job, + encode_write_register, leading_zero_threshold, logical_engine_address, }; use super::uart::{ Bzm2DiscoveredEngineMap, Bzm2DtsVsConfig, DEFAULT_DTS_VS_QUERY_TIMEOUT, @@ -56,6 +60,171 @@ impl Bzm2ThreadConfig { } } +const RUNTIME_MEASUREMENT_WINDOW: Duration = Duration::from_secs(5 * 60); +// Legacy source treats rows 0-9 as the bottom stack (PLL0) and rows 10-19 as +// the top stack (PLL1). +const PLL_STACK_SPLIT_ROW: u8 = 10; + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct Bzm2PllRuntimeMetrics { + pub throughput_hs: Option, + pub scheduler_share_count: u64, +} + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct Bzm2AsicRuntimeMetrics { + pub asic: u8, + pub throughput_hs: Option, + pub scheduler_share_count: u64, + pub plls: [Bzm2PllRuntimeMetrics; 2], +} + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct Bzm2ThreadRuntimeMetrics { + pub throughput_hs: Option, + pub asics: Vec, +} + +struct PllRuntimeMeasurement { + estimator: HashrateEstimator, + scheduler_share_count: u64, +} + +impl PllRuntimeMeasurement { + fn new() -> Self { + Self { + estimator: HashrateEstimator::new(RUNTIME_MEASUREMENT_WINDOW), + scheduler_share_count: 0, + } + } + + fn record_at(&mut self, at: Instant, work: Work) { + self.estimator.record_at(at, work); + self.scheduler_share_count = self.scheduler_share_count.saturating_add(1); + } + + fn snapshot_at(&mut self, now: Instant) -> Bzm2PllRuntimeMetrics { + Bzm2PllRuntimeMetrics { + throughput_hs: self + .estimator + .settled_hashrate() + .map(u64::from) + .or_else(|| { + self.estimator + .has_samples() + .then(|| u64::from(self.estimator.hashrate_at(now))) + }), + scheduler_share_count: self.scheduler_share_count, + } + } +} + +struct AsicRuntimeMeasurement { + estimator: HashrateEstimator, + scheduler_share_count: u64, + plls: [PllRuntimeMeasurement; 2], +} + +impl AsicRuntimeMeasurement { + fn new() -> Self { + Self { + estimator: HashrateEstimator::new(RUNTIME_MEASUREMENT_WINDOW), + scheduler_share_count: 0, + plls: [PllRuntimeMeasurement::new(), PllRuntimeMeasurement::new()], + } + } + + fn record_at(&mut self, at: Instant, pll_index: usize, work: Work) { + self.estimator.record_at(at, work); + self.scheduler_share_count = self.scheduler_share_count.saturating_add(1); + self.plls[pll_index].record_at(at, work); + } + + fn snapshot_at(&mut self, now: Instant, asic: u8) -> Bzm2AsicRuntimeMetrics { + Bzm2AsicRuntimeMetrics { + asic, + throughput_hs: self + .estimator + .settled_hashrate() + .map(u64::from) + .or_else(|| { + self.estimator + .has_samples() + .then(|| u64::from(self.estimator.hashrate_at(now))) + }), + scheduler_share_count: self.scheduler_share_count, + plls: [self.plls[0].snapshot_at(now), self.plls[1].snapshot_at(now)], + } + } +} + +struct ThreadRuntimeMeasurementState { + estimator: HashrateEstimator, + asics: BTreeMap, +} + +impl ThreadRuntimeMeasurementState { + fn new() -> Self { + Self { + estimator: HashrateEstimator::new(RUNTIME_MEASUREMENT_WINDOW), + asics: BTreeMap::new(), + } + } + + fn record_at(&mut self, at: Instant, asic: u8, row: u8, work: Work) { + let pll_index = pll_index_for_row(row); + self.estimator.record_at(at, work); + self.asics + .entry(asic) + .or_insert_with(AsicRuntimeMeasurement::new) + .record_at(at, pll_index, work); + } + + fn snapshot_at(&mut self, now: Instant) -> Bzm2ThreadRuntimeMetrics { + Bzm2ThreadRuntimeMetrics { + throughput_hs: self + .estimator + .settled_hashrate() + .map(u64::from) + .or_else(|| { + self.estimator + .has_samples() + .then(|| u64::from(self.estimator.hashrate_at(now))) + }), + asics: self + .asics + .iter_mut() + .map(|(&asic, measurement)| measurement.snapshot_at(now, asic)) + .collect(), + } + } + + fn current_hashrate( + &mut self, + now: Instant, + is_active: bool, + nominal_hashrate_ths: f64, + ) -> HashRate { + if !is_active { + return HashRate::default(); + } + + let measured = self.estimator.settled_hashrate().or_else(|| { + self.estimator + .has_samples() + .then(|| self.estimator.hashrate_at(now)) + }); + match measured { + Some(hashrate) if !hashrate.is_zero() => hashrate, + _ => HashRate::from_terahashes(nominal_hashrate_ths), + } + } +} + +fn pll_index_for_row(row: u8) -> usize { + if row < PLL_STACK_SPLIT_ROW { 0 } else { 1 } +} + #[derive(Clone)] pub struct Bzm2ThreadHandle { command_tx: mpsc::Sender, @@ -66,6 +235,78 @@ impl Bzm2ThreadHandle { let _ = self.command_tx.try_send(ThreadCommand::Shutdown); } + pub async fn noop(&self, asic: u8) -> Result<[u8; 3], HashThreadError> { + let (response_tx, response_rx) = oneshot::channel(); + self.command_tx + .send(ThreadCommand::QueryNoop { asic, response_tx }) + .await + .map_err(|_| HashThreadError::ChannelClosed("command channel closed".into()))?; + response_rx + .await + .map_err(|_| HashThreadError::DiagnosticsFailed("thread dropped response".into()))? + } + + pub async fn loopback(&self, asic: u8, payload: Vec) -> Result, HashThreadError> { + let (response_tx, response_rx) = oneshot::channel(); + self.command_tx + .send(ThreadCommand::QueryLoopback { + asic, + payload, + response_tx, + }) + .await + .map_err(|_| HashThreadError::ChannelClosed("command channel closed".into()))?; + response_rx + .await + .map_err(|_| HashThreadError::DiagnosticsFailed("thread dropped response".into()))? + } + + pub async fn read_register( + &self, + asic: u8, + engine_address: u16, + offset: u8, + count: u8, + ) -> Result, HashThreadError> { + let (response_tx, response_rx) = oneshot::channel(); + self.command_tx + .send(ThreadCommand::ReadRegister { + asic, + engine_address, + offset, + count, + response_tx, + }) + .await + .map_err(|_| HashThreadError::ChannelClosed("command channel closed".into()))?; + response_rx + .await + .map_err(|_| HashThreadError::DiagnosticsFailed("thread dropped response".into()))? + } + + pub async fn write_register( + &self, + asic: u8, + engine_address: u16, + offset: u8, + value: Vec, + ) -> Result<(), HashThreadError> { + let (response_tx, response_rx) = oneshot::channel(); + self.command_tx + .send(ThreadCommand::WriteRegister { + asic, + engine_address, + offset, + value, + response_tx, + }) + .await + .map_err(|_| HashThreadError::ChannelClosed("command channel closed".into()))?; + response_rx + .await + .map_err(|_| HashThreadError::DiagnosticsFailed("thread dropped response".into()))? + } + pub async fn query_dts_vs( &self, asic: u8, @@ -80,6 +321,17 @@ impl Bzm2ThreadHandle { .map_err(|_| HashThreadError::TelemetryQueryFailed("thread dropped response".into()))? } + pub async fn clock_report(&self, asic: u8) -> Result { + let (response_tx, response_rx) = oneshot::channel(); + self.command_tx + .send(ThreadCommand::QueryClockReport { asic, response_tx }) + .await + .map_err(|_| HashThreadError::ChannelClosed("command channel closed".into()))?; + response_rx + .await + .map_err(|_| HashThreadError::DiagnosticsFailed("thread dropped response".into()))? + } + pub async fn discover_engine_map( &self, asic: u8, @@ -102,6 +354,17 @@ impl Bzm2ThreadHandle { .await .map_err(|_| HashThreadError::DiagnosticsFailed("thread dropped response".into()))? } + + pub async fn runtime_metrics(&self) -> Result { + let (response_tx, response_rx) = oneshot::channel(); + self.command_tx + .send(ThreadCommand::QueryRuntimeMetrics { response_tx }) + .await + .map_err(|_| HashThreadError::ChannelClosed("command channel closed".into()))?; + response_rx + .await + .map_err(|_| HashThreadError::DiagnosticsFailed("thread dropped response".into()))? + } } #[derive(Debug)] @@ -117,6 +380,33 @@ enum ThreadCommand { GoIdle { response_tx: oneshot::Sender, HashThreadError>>, }, + QueryNoop { + asic: u8, + response_tx: oneshot::Sender>, + }, + QueryLoopback { + asic: u8, + payload: Vec, + response_tx: oneshot::Sender, HashThreadError>>, + }, + QueryClockReport { + asic: u8, + response_tx: oneshot::Sender>, + }, + ReadRegister { + asic: u8, + engine_address: u16, + offset: u8, + count: u8, + response_tx: oneshot::Sender, HashThreadError>>, + }, + WriteRegister { + asic: u8, + engine_address: u16, + offset: u8, + value: Vec, + response_tx: oneshot::Sender>, + }, QueryDtsVs { asic: u8, response_tx: oneshot::Sender>, @@ -128,6 +418,9 @@ enum ThreadCommand { timeout: Duration, response_tx: oneshot::Sender>, }, + QueryRuntimeMetrics { + response_tx: oneshot::Sender>, + }, Shutdown, } @@ -273,7 +566,7 @@ async fn bzm2_thread_actor( .send(HashThreadEvent::StatusUpdate(snapshot_status(&status))) .await; - let engine_coords = default_engine_coordinates(); + let mut engine_layout = Bzm2EngineLayout::default(); let mut parser = TdmFrameParser::new(config.dts_vs_generation); let mut current_task: Option = None; let mut engine_dispatches: HashMap = HashMap::new(); @@ -286,6 +579,7 @@ async fn bzm2_thread_actor( status_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); let mut read_buf = [0u8; 4096]; let mut dts_vs_configured = false; + let mut runtime_measurements = ThreadRuntimeMeasurementState::new(); loop { tokio::select! { @@ -298,7 +592,7 @@ async fn bzm2_thread_actor( &mut writer, task, base_sequence, - &engine_coords, + &engine_layout, &mut engine_dispatches, &config, ).await { @@ -307,6 +601,11 @@ async fn bzm2_thread_actor( } base_sequence = base_sequence.wrapping_add(1); set_active(&status, true, config.nominal_hashrate_ths); + refresh_status_hashrate( + &status, + &mut runtime_measurements, + config.nominal_hashrate_ths, + ); let _ = event_tx.send(HashThreadEvent::StatusUpdate(snapshot_status(&status))).await; } let _ = response_tx.send(Ok(old)); @@ -319,7 +618,7 @@ async fn bzm2_thread_actor( &mut writer, task, base_sequence, - &engine_coords, + &engine_layout, &mut engine_dispatches, &config, ).await { @@ -328,6 +627,11 @@ async fn bzm2_thread_actor( } base_sequence = base_sequence.wrapping_add(1); set_active(&status, true, config.nominal_hashrate_ths); + refresh_status_hashrate( + &status, + &mut runtime_measurements, + config.nominal_hashrate_ths, + ); let _ = event_tx.send(HashThreadEvent::StatusUpdate(snapshot_status(&status))).await; } let _ = response_tx.send(Ok(old)); @@ -336,9 +640,98 @@ async fn bzm2_thread_actor( engine_dispatches.clear(); let old = current_task.take(); set_active(&status, false, config.nominal_hashrate_ths); + refresh_status_hashrate( + &status, + &mut runtime_measurements, + config.nominal_hashrate_ths, + ); let _ = event_tx.send(HashThreadEvent::StatusUpdate(snapshot_status(&status))).await; let _ = response_tx.send(Ok(old)); } + ThreadCommand::QueryNoop { asic, response_tx } => { + let result = run_idle_uart_diagnostic( + current_task.is_some(), + dts_vs_configured, + || async { + query_noop(&mut reader, &mut writer, asic).await + }, + ) + .await; + let _ = response_tx.send(result); + } + ThreadCommand::QueryLoopback { + asic, + payload, + response_tx, + } => { + let result = run_idle_uart_diagnostic( + current_task.is_some(), + dts_vs_configured, + || async { + query_loopback(&mut reader, &mut writer, asic, &payload).await + }, + ) + .await; + let _ = response_tx.send(result); + } + ThreadCommand::QueryClockReport { asic, response_tx } => { + let result = run_idle_uart_diagnostic( + current_task.is_some(), + dts_vs_configured, + || async { query_clock_report(&mut reader, &mut writer, asic).await }, + ) + .await; + let _ = response_tx.send(result); + } + ThreadCommand::ReadRegister { + asic, + engine_address, + offset, + count, + response_tx, + } => { + let result = run_idle_uart_diagnostic( + current_task.is_some(), + dts_vs_configured, + || async { + read_register( + &mut reader, + &mut writer, + asic, + engine_address, + offset, + count, + ) + .await + }, + ) + .await; + let _ = response_tx.send(result); + } + ThreadCommand::WriteRegister { + asic, + engine_address, + offset, + value, + response_tx, + } => { + let result = run_idle_uart_diagnostic( + current_task.is_some(), + dts_vs_configured, + || async { + write_register( + &mut writer, + asic, + engine_address, + offset, + &value, + ) + .await + }, + ) + .await; + let _ = response_tx.send(result); + } ThreadCommand::QueryDtsVs { asic, response_tx } => { let result = query_dts_vs_telemetry( asic, @@ -346,9 +739,11 @@ async fn bzm2_thread_actor( &mut writer, &mut parser, &engine_dispatches, + &engine_layout, &config, &status, &event_tx, + &mut runtime_measurements, &mut dts_vs_configured, ).await; let _ = response_tx.send(result); @@ -375,9 +770,18 @@ async fn bzm2_thread_actor( timeout, ) .await + .map(|discovery| { + engine_layout = Bzm2EngineLayout::from_active_coordinates( + discovery.present.iter().map(|engine| (engine.row, engine.col)), + ); + discovery + }) .map_err(|err| HashThreadError::DiagnosticsFailed(err.to_string())); let _ = response_tx.send(result); } + ThreadCommand::QueryRuntimeMetrics { response_tx } => { + let _ = response_tx.send(Ok(runtime_measurements.snapshot_at(Instant::now()))); + } ThreadCommand::Shutdown => break, } } @@ -392,9 +796,11 @@ async fn bzm2_thread_actor( handle_result_frame( &frame, &engine_dispatches, + &engine_layout, &config, &status, &event_tx, + &mut runtime_measurements, ) .await; } @@ -424,7 +830,7 @@ async fn bzm2_thread_actor( &mut writer, task, base_sequence, - &engine_coords, + &engine_layout, &mut engine_dispatches, &config, ).await { @@ -444,12 +850,22 @@ async fn bzm2_thread_actor( } } _ = status_tick.tick() => { + refresh_status_hashrate( + &status, + &mut runtime_measurements, + config.nominal_hashrate_ths, + ); let _ = event_tx.send(HashThreadEvent::StatusUpdate(snapshot_status(&status))).await; } } } set_active(&status, false, config.nominal_hashrate_ths); + refresh_status_hashrate( + &status, + &mut runtime_measurements, + config.nominal_hashrate_ths, + ); let _ = event_tx .send(HashThreadEvent::StatusUpdate(snapshot_status(&status))) .await; @@ -521,15 +937,239 @@ async fn handle_dts_vs_frame( } } +async fn run_idle_uart_diagnostic( + thread_active: bool, + dts_vs_configured: bool, + operation: F, +) -> Result +where + F: FnOnce() -> Fut, + Fut: std::future::Future>, +{ + if thread_active { + return Err(HashThreadError::DiagnosticsFailed( + "BZM2 UART diagnostics require the thread to be idle".into(), + )); + } + if dts_vs_configured { + return Err(HashThreadError::DiagnosticsFailed( + "BZM2 UART diagnostics require DTS/VS streaming to be inactive".into(), + )); + } + operation().await +} + +async fn write_register( + writer: &mut SerialWriter, + asic: u8, + engine_address: u16, + offset: u8, + value: &[u8], +) -> Result<(), HashThreadError> { + writer + .write_all(&encode_write_register(asic, engine_address, offset, value)) + .await + .map_err(|err| HashThreadError::DiagnosticsFailed(err.to_string()))?; + writer + .flush() + .await + .map_err(|err| HashThreadError::DiagnosticsFailed(err.to_string()))?; + Ok(()) +} + +async fn read_local_reg_u8( + reader: &mut SerialReader, + writer: &mut SerialWriter, + asic: u8, + offset: u8, +) -> Result { + let value = read_register(reader, writer, asic, super::uart::NOTCH_REG, offset, 1).await?; + value + .first() + .copied() + .ok_or_else(|| HashThreadError::DiagnosticsFailed("short local register response".into())) +} + +async fn read_local_reg_u32( + reader: &mut SerialReader, + writer: &mut SerialWriter, + asic: u8, + offset: u8, +) -> Result { + let value = read_register(reader, writer, asic, super::uart::NOTCH_REG, offset, 4).await?; + let bytes: [u8; 4] = value + .as_slice() + .try_into() + .map_err(|_| HashThreadError::DiagnosticsFailed("short local register response".into()))?; + Ok(u32::from_le_bytes(bytes)) +} + +async fn read_register( + reader: &mut SerialReader, + writer: &mut SerialWriter, + asic: u8, + engine_address: u16, + offset: u8, + count: u8, +) -> Result, HashThreadError> { + let request = encode_read_register(asic, engine_address, offset, count); + writer + .write_all(&request) + .await + .map_err(|err| HashThreadError::DiagnosticsFailed(err.to_string()))?; + writer + .flush() + .await + .map_err(|err| HashThreadError::DiagnosticsFailed(err.to_string()))?; + + let expected = count as usize + 2; + let mut response = vec![0u8; expected]; + reader + .read_exact(&mut response) + .await + .map_err(|err| HashThreadError::DiagnosticsFailed(err.to_string()))?; + validate_response_header(asic, OPCODE_UART_READREG, &response)?; + Ok(response[2..].to_vec()) +} + +async fn read_pll_status( + reader: &mut SerialReader, + writer: &mut SerialWriter, + asic: u8, + pll: Bzm2Pll, +) -> Result { + let (_, _, enable_reg, misc_reg) = pll.register_block(); + let enable = read_local_reg_u32(reader, writer, asic, enable_reg).await?; + let misc = read_local_reg_u32(reader, writer, asic, misc_reg).await?; + Ok(Bzm2PllStatus { + pll, + enable_register: enable, + misc_register: misc, + enabled: (enable & 0x1) != 0, + locked: (enable & 0x4) != 0, + }) +} + +async fn read_dll_status( + reader: &mut SerialReader, + writer: &mut SerialWriter, + asic: u8, + dll: Bzm2Dll, +) -> Result { + let (control2_reg, _, _, control5_reg, coarse_reg) = dll.registers(); + let control2 = read_local_reg_u8(reader, writer, asic, control2_reg).await?; + let control5 = read_local_reg_u8(reader, writer, asic, control5_reg).await?; + let coarse_raw = read_local_reg_u8(reader, writer, asic, coarse_reg).await?; + let fincon = read_local_reg_u8(reader, writer, asic, dll.fincon_register()).await?; + + Ok(Bzm2DllStatus { + dll, + control2, + control5, + coarsecon: (coarse_raw >> 5) & 0x7, + fincon, + freeze_valid: (control2 & 0x2) != 0, + locked: (control5 & 0x2) != 0, + fincon_valid: fincon_is_valid(fincon), + }) +} + +async fn query_clock_report( + reader: &mut SerialReader, + writer: &mut SerialWriter, + asic: u8, +) -> Result { + Ok(Bzm2ClockDebugReport { + asic, + pll0: read_pll_status(reader, writer, asic, Bzm2Pll::Pll0).await?, + pll1: read_pll_status(reader, writer, asic, Bzm2Pll::Pll1).await?, + dll0: read_dll_status(reader, writer, asic, Bzm2Dll::Dll0).await?, + dll1: read_dll_status(reader, writer, asic, Bzm2Dll::Dll1).await?, + }) +} + +async fn query_noop( + reader: &mut SerialReader, + writer: &mut SerialWriter, + asic: u8, +) -> Result<[u8; 3], HashThreadError> { + let request = encode_noop(asic); + writer + .write_all(&request) + .await + .map_err(|err| HashThreadError::DiagnosticsFailed(err.to_string()))?; + writer + .flush() + .await + .map_err(|err| HashThreadError::DiagnosticsFailed(err.to_string()))?; + + let mut response = [0u8; 5]; + reader + .read_exact(&mut response) + .await + .map_err(|err| HashThreadError::DiagnosticsFailed(err.to_string()))?; + validate_response_header(asic, OPCODE_UART_NOOP, &response)?; + Ok(response[2..5].try_into().unwrap()) +} + +async fn query_loopback( + reader: &mut SerialReader, + writer: &mut SerialWriter, + asic: u8, + payload: &[u8], +) -> Result, HashThreadError> { + let request = encode_loopback(asic, payload); + writer + .write_all(&request) + .await + .map_err(|err| HashThreadError::DiagnosticsFailed(err.to_string()))?; + writer + .flush() + .await + .map_err(|err| HashThreadError::DiagnosticsFailed(err.to_string()))?; + + let expected = payload.len() + 2; + let mut response = vec![0u8; expected]; + reader + .read_exact(&mut response) + .await + .map_err(|err| HashThreadError::DiagnosticsFailed(err.to_string()))?; + validate_response_header(asic, OPCODE_UART_LOOPBACK, &response)?; + Ok(response[2..].to_vec()) +} + +fn validate_response_header( + expected_asic: u8, + expected_opcode: u8, + response: &[u8], +) -> Result<(), HashThreadError> { + if response.len() < 2 { + return Err(HashThreadError::DiagnosticsFailed(format!( + "short UART response: expected at least 2 bytes, got {}", + response.len() + ))); + } + let actual_asic = response[0]; + let actual_opcode = response[1]; + if actual_asic != expected_asic || actual_opcode != expected_opcode { + return Err(HashThreadError::DiagnosticsFailed(format!( + "unexpected UART response header: expected asic {expected_asic:#x} opcode {expected_opcode:#x}, got asic {actual_asic:#x} opcode {actual_opcode:#x}" + ))); + } + Ok(()) +} + async fn query_dts_vs_telemetry( asic: u8, reader: &mut SerialReader, writer: &mut SerialWriter, parser: &mut TdmFrameParser, engine_dispatches: &HashMap, + engine_layout: &Bzm2EngineLayout, config: &Bzm2ThreadConfig, status: &Arc>, event_tx: &mpsc::Sender, + runtime_measurements: &mut ThreadRuntimeMeasurementState, dts_vs_configured: &mut bool, ) -> Result { if !*dts_vs_configured { @@ -568,7 +1208,16 @@ async fn query_dts_vs_telemetry( for frame in parser.push(&read_buf[..read]) { match frame { TdmFrame::Result(frame) => { - handle_result_frame(&frame, engine_dispatches, config, status, event_tx).await; + handle_result_frame( + &frame, + engine_dispatches, + engine_layout, + config, + status, + event_tx, + runtime_measurements, + ) + .await; } TdmFrame::DtsVs(frame) => { let frame_asic = dts_vs_frame_asic(&frame); @@ -599,7 +1248,7 @@ async fn dispatch_task_to_board( writer: &mut SerialWriter, task: &HashTask, base_sequence: u8, - engine_coords: &[(u8, u8)], + engine_layout: &Bzm2EngineLayout, engine_dispatches: &mut HashMap, config: &Bzm2ThreadConfig, ) -> Result<(), HashThreadError> { @@ -634,7 +1283,7 @@ async fn dispatch_task_to_board( let timestamp_count = config.timestamp_count; let bits = task.template.bits.to_consensus(); - for &(row, col) in engine_coords { + for &(row, col) in engine_layout.active_coordinates() { let engine_address = logical_engine_address(row, col); writer @@ -695,7 +1344,7 @@ async fn dispatch_task_to_board( } engine_dispatches.insert( - protocol::logical_engine_id(row, col).unwrap(), + engine_layout.logical_engine_id(row, col).unwrap(), EngineDispatch { task: task.clone(), merkle_root, @@ -711,12 +1360,14 @@ async fn dispatch_task_to_board( async fn handle_result_frame( frame: &protocol::TdmResultFrame, engine_dispatches: &HashMap, + engine_layout: &Bzm2EngineLayout, config: &Bzm2ThreadConfig, status: &Arc>, event_tx: &mpsc::Sender, + runtime_measurements: &mut ThreadRuntimeMeasurementState, ) { let Some((share, target_diff, engine_id)) = - reconstruct_share_from_result(frame, engine_dispatches, config) + reconstruct_share_from_result(frame, engine_dispatches, engine_layout, config) else { return; }; @@ -728,6 +1379,9 @@ async fn handle_result_frame( dispatch.task.share_tx.clone() }; + runtime_measurements.record_at(Instant::now(), frame.asic, frame.row(), share.expected_work); + refresh_status_hashrate(status, runtime_measurements, config.nominal_hashrate_ths); + if share_tx.send(share.clone()).await.is_ok() { let snapshot = { let mut lock = status.write().unwrap(); @@ -751,13 +1405,14 @@ async fn handle_result_frame( fn reconstruct_share_from_result( frame: &protocol::TdmResultFrame, engine_dispatches: &HashMap, + engine_layout: &Bzm2EngineLayout, config: &Bzm2ThreadConfig, ) -> Option<(Share, Difficulty, u16)> { if !frame.nonce_valid() { return None; } - let engine_id = frame.logical_engine_id()?; + let engine_id = engine_layout.logical_engine_id(frame.row(), frame.col())?; let dispatch = engine_dispatches.get(&engine_id)?; let hardware_base_sequence = frame.sequence_id / 4; @@ -848,6 +1503,17 @@ fn set_active(status: &Arc>, is_active: bool, nominal_h }; } +fn refresh_status_hashrate( + status: &Arc>, + runtime_measurements: &mut ThreadRuntimeMeasurementState, + nominal_hashrate_ths: f64, +) { + let now = Instant::now(); + let mut lock = status.write().unwrap(); + lock.hashrate = + runtime_measurements.current_hashrate(now, lock.is_active, nominal_hashrate_ths); +} + fn record_hardware_error(status: &Arc>) { let mut lock = status.write().unwrap(); lock.hardware_errors = lock.hardware_errors.saturating_add(1); @@ -1005,12 +1671,13 @@ mod tests { let mut engine_dispatches = StdHashMap::new(); let config = Bzm2ThreadConfig::new("/dev/null".into(), 5_000_000); let engine_coords = vec![(0, 0), (0, 1)]; + let engine_layout = Bzm2EngineLayout::from_active_coordinates(engine_coords.clone()); dispatch_task_to_board( &mut writer, &task, 1, - &engine_coords, + &engine_layout, &mut engine_dispatches, &config, ) @@ -1058,8 +1725,9 @@ mod tests { let mut task = test_task(); let merkle_root = bitcoin::TxMerkleNode::all_zeros(); let versions = compute_micro_versions(&task); - let (row, col) = default_engine_coordinates()[0]; - let engine_id = protocol::logical_engine_id(row, col).unwrap(); + let engine_layout = Bzm2EngineLayout::default(); + let (row, col) = protocol::default_engine_coordinates()[0]; + let engine_id = engine_layout.logical_engine_id(row, col).unwrap(); let nonce = 0; let expected_hash = bitcoin::block::Header { version: versions[0], @@ -1089,6 +1757,7 @@ mod tests { is_active: true, ..Default::default() })); + let mut runtime_measurements = ThreadRuntimeMeasurementState::new(); let (event_tx, mut event_rx) = tokio_mpsc::channel(4); let (share_tx, mut share_rx) = tokio_mpsc::channel(4); engine_dispatches.get_mut(&engine_id).unwrap().task.share_tx = share_tx; @@ -1106,9 +1775,21 @@ mod tests { let mut parser = protocol::TdmResultParser::default(); let frames = parser.push(&raw); assert_eq!(frames.len(), 1); - assert!(reconstruct_share_from_result(&frames[0], &engine_dispatches, &config).is_some()); + assert!( + reconstruct_share_from_result(&frames[0], &engine_dispatches, &engine_layout, &config) + .is_some() + ); - handle_result_frame(&frames[0], &engine_dispatches, &config, &status, &event_tx).await; + handle_result_frame( + &frames[0], + &engine_dispatches, + &engine_layout, + &config, + &status, + &event_tx, + &mut runtime_measurements, + ) + .await; let share = tokio::time::timeout(Duration::from_millis(250), share_rx.recv()) .await @@ -1127,11 +1808,37 @@ mod tests { HashThreadEvent::StatusUpdate(snapshot) => { assert!(snapshot.is_active); assert_eq!(snapshot.chip_shares_found, 1); - assert_eq!(snapshot.hashrate, HashRate::from_terahashes(40.0)); + assert!(u64::from(snapshot.hashrate) > 0); } other => panic!("unexpected event: {:?}", other), } } + + #[test] + fn runtime_metrics_track_per_asic_and_per_pll_throughput() { + let base = Instant::now(); + let mut runtime = ThreadRuntimeMeasurementState::new(); + let work = bitcoin::pow::Work::from_le_bytes({ + let mut bytes = [0u8; 32]; + bytes[..8].copy_from_slice(&1_000u64.to_le_bytes()); + bytes + }); + + runtime.record_at(base, 2, 0, work); + runtime.record_at(base + Duration::from_secs(10), 2, 12, work); + runtime.record_at(base + Duration::from_secs(20), 2, 12, work); + let snapshot = runtime.snapshot_at(base + Duration::from_secs(20)); + + assert_eq!(snapshot.asics.len(), 1); + let asic = &snapshot.asics[0]; + assert_eq!(asic.asic, 2); + assert_eq!(asic.scheduler_share_count, 3); + assert_eq!(asic.throughput_hs, Some(150)); + assert_eq!(asic.plls[0].scheduler_share_count, 1); + assert_eq!(asic.plls[0].throughput_hs, Some(50)); + assert_eq!(asic.plls[1].scheduler_share_count, 2); + assert_eq!(asic.plls[1].throughput_hs, Some(200)); + } #[tokio::test] async fn gen2_dts_vs_emits_api_telemetry() { let config = Bzm2ThreadConfig::new("/dev/ttyUSB0".into(), 5_000_000); @@ -1251,8 +1958,9 @@ mod tests { let merkle_root = bitcoin::TxMerkleNode::all_zeros(); let versions = compute_micro_versions(&task); - let (row, col) = default_engine_coordinates()[0]; - let engine_id = protocol::logical_engine_id(row, col).unwrap(); + let engine_layout = Bzm2EngineLayout::default(); + let (row, col) = protocol::default_engine_coordinates()[0]; + let engine_id = engine_layout.logical_engine_id(row, col).unwrap(); let nonce = 0; let expected_hash = bitcoin::block::Header { version: versions[0], @@ -1286,7 +1994,8 @@ mod tests { let config = Bzm2ThreadConfig::new("/dev/null".into(), 5_000_000); let (share, target_diff, reconstructed_engine_id) = - reconstruct_share_from_result(&frame, &engine_dispatches, &config).unwrap(); + reconstruct_share_from_result(&frame, &engine_dispatches, &engine_layout, &config) + .unwrap(); assert_eq!(reconstructed_engine_id, engine_id); assert_eq!(share.nonce, nonce); @@ -1306,4 +2015,70 @@ mod tests { ); assert_eq!(target_diff, Difficulty::from_target(task.share_target)); } + + #[test] + fn runtime_engine_layout_compresses_logical_ids_after_missing_engines() { + let layout = Bzm2EngineLayout::from_active_coordinates([(0, 0), (0, 6), (19, 10)]); + + assert_eq!(layout.active_engine_count(), 3); + assert_eq!(layout.logical_engine_id(0, 0), Some(0)); + assert_eq!(layout.logical_engine_id(0, 6), Some(1)); + assert_eq!(layout.logical_engine_id(19, 10), Some(2)); + assert_eq!(layout.logical_engine_id(0, 1), None); + } + + #[tokio::test] + async fn dispatch_uses_runtime_engine_layout() { + let pty = openpty(None, None).unwrap(); + let writer_side = + SerialStream::from_fd(pty.master.into_raw_fd(), SerialConfig::default()).unwrap(); + let reader_side = + SerialStream::from_fd(pty.slave.into_raw_fd(), SerialConfig::default()).unwrap(); + let (_reader_a, mut writer, _control_a) = writer_side.split(); + let (mut reader, _writer_b, _control_b) = reader_side.split(); + + let task = test_task(); + let mut engine_dispatches = StdHashMap::new(); + let config = Bzm2ThreadConfig::new("/dev/null".into(), 5_000_000); + let engine_layout = Bzm2EngineLayout::from_active_coordinates([(0, 0), (19, 10)]); + + dispatch_task_to_board( + &mut writer, + &task, + 1, + &engine_layout, + &mut engine_dispatches, + &config, + ) + .await + .unwrap(); + + let mut buf = vec![0u8; 512]; + let mut bytes = Vec::new(); + let deadline = tokio::time::Instant::now() + Duration::from_millis(250); + while bytes.len() < (8 + 8 + 11 + (48 * 4)) * engine_layout.active_engine_count() { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + let n = tokio::time::timeout(remaining, reader.read(&mut buf)) + .await + .unwrap() + .unwrap(); + if n == 0 { + break; + } + bytes.extend_from_slice(&buf[..n]); + } + + let first_engine = logical_engine_address(0, 0).to_be_bytes(); + let second_engine = logical_engine_address(19, 10).to_be_bytes(); + assert!(bytes.windows(2).any(|window| window == first_engine)); + assert!(bytes.windows(2).any(|window| window == second_engine)); + assert!( + !bytes + .windows(2) + .any(|window| window == logical_engine_address(0, 1).to_be_bytes()) + ); + assert_eq!(engine_dispatches.len(), 2); + assert!(engine_dispatches.contains_key(&0)); + assert!(engine_dispatches.contains_key(&1)); + } } diff --git a/mujina-miner/src/board/bitaxe.rs b/mujina-miner/src/board/bitaxe.rs index 5041a443..3587314c 100644 --- a/mujina-miner/src/board/bitaxe.rs +++ b/mujina-miner/src/board/bitaxe.rs @@ -797,6 +797,7 @@ impl BitaxeBoard { ], threads: Vec::new(), asics: Vec::new(), + bzm2_tuning: None, }); // -- Log summary (throttled) -- diff --git a/mujina-miner/src/board/bzm2.rs b/mujina-miner/src/board/bzm2.rs index 47074769..b210951e 100644 --- a/mujina-miner/src/board/bzm2.rs +++ b/mujina-miner/src/board/bzm2.rs @@ -2,6 +2,7 @@ use std::collections::BTreeMap; use std::env; use std::fs; use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; use std::time::Duration; use async_trait::async_trait; @@ -12,8 +13,8 @@ use tokio::task::JoinHandle; use super::{Board, BoardCommand, BoardError, BoardInfo, VirtualBoardDescriptor}; use crate::{ api_client::types::{ - AsicState, BoardState, EngineCoordinate, Fan, PowerMeasurement, TemperatureSensor, - ThreadState, + AsicState, BoardState, Bzm2AsicTuningState, Bzm2DomainTuningState, Bzm2PllTuningState, + Bzm2TuningState, EngineCoordinate, Fan, PowerMeasurement, TemperatureSensor, ThreadState, }, asic::{ bzm2::{ @@ -22,8 +23,8 @@ use crate::{ Bzm2ClockController, Bzm2DiscoveredEngineMap, Bzm2DomainMeasurement, Bzm2OperatingClass, Bzm2PerformanceMode, Bzm2Pll, Bzm2SavedEngineCoordinate, Bzm2SavedEngineTopology, Bzm2SavedOperatingPoint, Bzm2Thread, Bzm2ThreadConfig, - Bzm2ThreadHandle, Bzm2UartController, Bzm2VoltageDomain, FileGpioPin, FilePowerRail, - GpioResetLine, VoltageStackStep, control::Bzm2PowerRail, + Bzm2ThreadHandle, Bzm2ThreadRuntimeMetrics, Bzm2UartController, Bzm2VoltageDomain, + FileGpioPin, FilePowerRail, GpioResetLine, VoltageStackStep, control::Bzm2PowerRail, }, hash_thread::{ HashTask, HashThread, HashThreadCapabilities, HashThreadError, HashThreadEvent, @@ -870,6 +871,11 @@ impl Bzm2BusLayout { global_asic_id >= self.asic_start && global_asic_id < self.asic_start + self.asic_count } + fn global_asic_id(&self, local_asic_id: u8) -> Option { + (u16::from(local_asic_id) < self.asic_count) + .then_some(self.asic_start + u16::from(local_asic_id)) + } + fn local_asic_id(&self, global_asic_id: u16) -> Option { self.contains(global_asic_id) .then_some((global_asic_id - self.asic_start) as u8) @@ -920,11 +926,26 @@ struct Bzm2LoadedCalibrationProfile { saved_state: Bzm2SavedOperatingPoint, } +#[derive(Debug, Clone, Default)] +struct Bzm2AppliedOperatingState { + per_domain_voltage_mv: BTreeMap, + per_asic_pll_mhz: BTreeMap, +} + +#[derive(Debug, Clone, Default)] +struct Bzm2RuntimeMeasurementCache { + domain_measurements: BTreeMap, + asic_measurements: BTreeMap, +} + pub struct Bzm2Board { config: Bzm2VirtualDeviceConfig, bringup_applied: bool, shutdown_handles: Vec, serial_controls: Vec, + bus_layouts: Arc>>, + applied_operating_state: Arc>, + runtime_measurements: Arc>, state_tx: watch::Sender, command_rx: Option>, monitor_shutdown: Option>, @@ -944,6 +965,9 @@ impl Bzm2Board { bringup_applied: false, shutdown_handles: Vec::new(), serial_controls: Vec::new(), + bus_layouts: Arc::new(Mutex::new(Vec::new())), + applied_operating_state: Arc::new(Mutex::new(Bzm2AppliedOperatingState::default())), + runtime_measurements: Arc::new(Mutex::new(Bzm2RuntimeMeasurementCache::default())), state_tx, command_rx: Some(command_rx), monitor_shutdown: None, @@ -1000,9 +1024,13 @@ impl Bzm2Board { let telemetry = self.config.telemetry.clone(); let rail_telemetry = self.config.bringup.clone(); + let calibration = self.config.calibration.clone(); let state_tx = self.state_tx.clone(); let shutdown_handles = self.shutdown_handles.clone(); let serial_controls = self.serial_controls.clone(); + let bus_layouts = Arc::clone(&self.bus_layouts); + let applied_operating_state = Arc::clone(&self.applied_operating_state); + let runtime_measurements = Arc::clone(&self.runtime_measurements); let board_name = self.config.device_id(); let (shutdown_tx, mut shutdown_rx) = watch::channel(false); self.monitor_shutdown = Some(shutdown_tx); @@ -1015,6 +1043,23 @@ impl Bzm2Board { _ = interval.tick() => { let snapshot = telemetry.snapshot(); let rail_snapshot = rail_telemetry.snapshot_telemetry(); + let thread_metrics = collect_thread_runtime_metrics(&shutdown_handles).await; + let bus_layouts = bus_layouts.lock().unwrap_or_else(|e| e.into_inner()).clone(); + let applied_operating_state = applied_operating_state.lock().unwrap_or_else(|e| e.into_inner()).clone(); + let current_state = state_tx.borrow().clone(); + let (tuning_state, measurement_cache) = build_runtime_tuning_state( + ¤t_state.asics, + ¤t_state.temperatures, + &bus_layouts, + &calibration, + &rail_telemetry, + &rail_snapshot, + &applied_operating_state, + &thread_metrics, + ); + let runtime_domain_count = measurement_cache.domain_measurements.len(); + let runtime_asic_count = measurement_cache.asic_measurements.len(); + *runtime_measurements.lock().unwrap_or_else(|e| e.into_inner()) = measurement_cache; let total_stats = serial_controls.iter().fold((0u64, 0u64), |acc, control| { let stats = control.stats(); (acc.0 + stats.bytes_read, acc.1 + stats.bytes_written) @@ -1025,8 +1070,19 @@ impl Bzm2Board { merge_power_readings(&mut state.powers, &snapshot.powers); merge_temperature_readings(&mut state.temperatures, &rail_snapshot.temperatures); merge_power_readings(&mut state.powers, &rail_snapshot.powers); + state.bzm2_tuning = + (!tuning_state.asics.is_empty() || !tuning_state.domains.is_empty() + || tuning_state.board_throughput_hs.is_some()) + .then_some(tuning_state.clone()); }); - trace!(board = %board_name, bytes_read = total_stats.0, bytes_written = total_stats.1, "BZM2 board telemetry updated"); + trace!( + board = %board_name, + bytes_read = total_stats.0, + bytes_written = total_stats.1, + runtime_domain_count, + runtime_asic_count, + "BZM2 board telemetry updated" + ); if let Some(reason) = snapshot.trip_reason.clone() { warn!(board = %board_name, reason = %reason, "BZM2 safety trip triggered"); for handle in &shutdown_handles { @@ -1334,6 +1390,11 @@ impl Bzm2Board { &per_asic_pll_mhz, ) .await?; + store_applied_operating_state( + &self.applied_operating_state, + &per_domain_voltage_mv, + &per_asic_pll_mhz, + ); if let Some(profile_path) = calibration.profile_path.as_deref() { let saved_operating_point = Bzm2SavedOperatingPoint { @@ -1492,6 +1553,11 @@ impl Bzm2Board { ) .await?; } + store_applied_operating_state( + &self.applied_operating_state, + &profile.saved_state.per_domain_voltage_mv, + &profile.saved_state.per_asic_pll_mhz, + ); Ok(()) } @@ -1716,6 +1782,7 @@ impl Board for Bzm2Board { let mut thread_states = Vec::new(); self.apply_bringup_sequence().await?; let bus_layouts = self.resolve_bus_layouts().await?; + *self.bus_layouts.lock().unwrap_or_else(|e| e.into_inner()) = bus_layouts.clone(); let initial_snapshot = self.config.telemetry.snapshot(); let initial_rail_snapshot = self.config.bringup.snapshot_telemetry(); let _ = self.state_tx.send_modify(|state| { @@ -2182,6 +2249,274 @@ fn distribute_saved_throughput( .collect() } +fn store_applied_operating_state( + state: &Arc>, + per_domain_voltage_mv: &BTreeMap, + per_asic_pll_mhz: &BTreeMap, +) { + let mut guard = state.lock().unwrap_or_else(|e| e.into_inner()); + guard.per_domain_voltage_mv = per_domain_voltage_mv.clone(); + guard.per_asic_pll_mhz = per_asic_pll_mhz.clone(); +} + +async fn collect_thread_runtime_metrics( + handles: &[Bzm2ThreadHandle], +) -> BTreeMap { + let mut metrics = BTreeMap::new(); + for (thread_index, handle) in handles.iter().enumerate() { + match handle.runtime_metrics().await { + Ok(snapshot) => { + metrics.insert(thread_index, snapshot); + } + Err(err) => { + warn!(thread_index, error = %err, "Failed to query BZM2 runtime metrics"); + } + } + } + metrics +} + +fn build_runtime_tuning_state( + asics: &[AsicState], + temperatures: &[TemperatureSensor], + bus_layouts: &[Bzm2BusLayout], + calibration: &Bzm2CalibrationConfig, + bringup: &Bzm2BringupConfig, + rail_snapshot: &Bzm2TelemetrySnapshot, + applied_operating_state: &Bzm2AppliedOperatingState, + thread_metrics: &BTreeMap, +) -> (Bzm2TuningState, Bzm2RuntimeMeasurementCache) { + let total_asics = bus_layouts.iter().map(|bus| bus.asic_count).sum::(); + let (domains, _domain_lookup) = build_voltage_domains( + total_asics, + &calibration.asics_per_domain, + &calibration.domain_voltage_offsets_mv, + ); + + let mut tuning_domains = Vec::new(); + let mut cache_domains = BTreeMap::new(); + for domain in domains { + let rail_index = bringup.rail_index_for_domain(domain.domain_id); + let rail_output_name = rail_index.map(|index| format!("rail{index}-output")); + let measured_voltage_mv = rail_output_name + .as_ref() + .and_then(|name| { + rail_snapshot + .powers + .iter() + .find(|power| power.name == *name) + }) + .and_then(|power| power.voltage_v) + .map(|voltage| (voltage * 1000.0).round() as u32); + let measured_power_w = rail_output_name + .as_ref() + .and_then(|name| { + rail_snapshot + .powers + .iter() + .find(|power| power.name == *name) + }) + .and_then(|power| power.power_w); + tuning_domains.push(Bzm2DomainTuningState { + domain_id: domain.domain_id, + rail_index, + target_voltage_mv: applied_operating_state + .per_domain_voltage_mv + .get(&domain.domain_id) + .copied(), + measured_voltage_mv, + measured_power_w, + }); + cache_domains.insert( + domain.domain_id, + Bzm2DomainMeasurement { + domain_id: domain.domain_id, + measured_voltage_mv, + measured_power_w, + }, + ); + } + tuning_domains.sort_by_key(|domain| domain.domain_id); + + let mut board_throughput_hs = 0u64; + let mut board_has_throughput = false; + let mut tuning_asics = Vec::new(); + let mut cache_asics = BTreeMap::new(); + + for asic in asics { + let Some(thread_index) = asic.thread_index else { + continue; + }; + let Some(bus) = bus_layouts.get(thread_index) else { + continue; + }; + let Some(global_asic_id) = bus.global_asic_id(asic.id) else { + continue; + }; + let runtime_asic = thread_metrics + .get(&thread_index) + .and_then(|metrics| metrics.asics.iter().find(|metrics| metrics.asic == asic.id)); + let missing_engines = + if asic.missing_engines.is_empty() && asic.discovered_engine_count.is_none() { + default_saved_engine_topology() + .missing_engines + .into_iter() + .map(|engine| EngineCoordinate { + row: engine.row, + col: engine.col, + }) + .collect::>() + } else { + asic.missing_engines.clone() + }; + let (stack0_active, stack1_active) = + split_active_engine_counts(asic.discovered_engine_count, &missing_engines); + let frequencies = applied_operating_state + .per_asic_pll_mhz + .get(&global_asic_id) + .copied(); + let mut pll_states = Vec::with_capacity(2); + let mut pll_pass_rates = [None, None]; + + for pll_index in 0..2usize { + let throughput_hs = runtime_asic.and_then(|asic| asic.plls[pll_index].throughput_hs); + let frequency_mhz = frequencies.map(|freq| freq[pll_index]); + let active_engines = if pll_index == 0 { + stack0_active + } else { + stack1_active + }; + let pass_rate = + throughput_hs + .zip(frequency_mhz) + .and_then(|(throughput_hs, frequency_mhz)| { + expected_stack_throughput_hs(active_engines, frequency_mhz) + .map(|expected| throughput_hs as f32 / expected.max(1) as f32) + }); + pll_pass_rates[pll_index] = pass_rate; + pll_states.push(Bzm2PllTuningState { + pll_index: pll_index as u8, + frequency_mhz, + throughput_hs, + pass_rate, + }); + } + + let average_pass_rate = weighted_average_pass_rate(&[ + (pll_pass_rates[0], stack0_active), + (pll_pass_rates[1], stack1_active), + ]); + let throughput_hs = runtime_asic.and_then(|asic| asic.throughput_hs); + if let Some(throughput_hs) = throughput_hs { + board_throughput_hs = board_throughput_hs.saturating_add(throughput_hs); + board_has_throughput = true; + } + + tuning_asics.push(Bzm2AsicTuningState { + id: asic.id, + thread_index: asic.thread_index, + active_engine_count: asic.discovered_engine_count, + throughput_hs, + average_pass_rate, + scheduler_share_count: runtime_asic.map(|asic| asic.scheduler_share_count), + plls: pll_states, + }); + + cache_asics.insert( + global_asic_id, + Bzm2AsicMeasurement { + asic_id: global_asic_id, + temperature_c: asic_temperature_for_sensor( + temperatures, + bus.serial_path.as_str(), + asic.id, + ), + throughput_ths: throughput_hs + .map(|throughput| throughput as f32 / 1_000_000_000_000.0), + average_pass_rate, + pll_pass_rates, + }, + ); + } + tuning_asics.sort_by_key(|asic| (asic.thread_index.unwrap_or(usize::MAX), asic.id)); + + ( + Bzm2TuningState { + board_throughput_hs: board_has_throughput.then_some(board_throughput_hs), + domains: tuning_domains, + asics: tuning_asics, + }, + Bzm2RuntimeMeasurementCache { + domain_measurements: cache_domains, + asic_measurements: cache_asics, + }, + ) +} + +fn split_active_engine_counts( + active_engine_count: Option, + missing_engines: &[EngineCoordinate], +) -> (u16, u16) { + if missing_engines.is_empty() { + if let Some(active_engine_count) = active_engine_count { + let lower = active_engine_count / 2; + return (lower, active_engine_count.saturating_sub(lower)); + } + } + let mut bottom_missing = 0u16; + let mut top_missing = 0u16; + for engine in missing_engines { + if engine.row < 10 { + bottom_missing = bottom_missing.saturating_add(1); + } else { + top_missing = top_missing.saturating_add(1); + } + } + let engines_per_stack = (10u16 * 12u16) as u16; + ( + engines_per_stack.saturating_sub(bottom_missing), + engines_per_stack.saturating_sub(top_missing), + ) +} + +fn expected_stack_throughput_hs(active_engines: u16, frequency_mhz: f32) -> Option { + (frequency_mhz > 0.0).then(|| { + let ghs = active_engines as f32 * 4.0 * (frequency_mhz / 1000.0) / 3.0; + (ghs * 1_000_000_000.0).round() as u64 + }) +} + +fn weighted_average_pass_rate(samples: &[(Option, u16)]) -> Option { + let mut weighted = 0.0f32; + let mut total_weight = 0u32; + for (pass_rate, weight) in samples { + if let Some(pass_rate) = pass_rate { + weighted += pass_rate * *weight as f32; + total_weight += u32::from(*weight); + } + } + (total_weight > 0).then_some(weighted / total_weight as f32) +} + +fn asic_temperature_for_sensor( + temperatures: &[TemperatureSensor], + serial_path: &str, + asic: u8, +) -> Option { + let prefix = Path::new(serial_path) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(serial_path) + .chars() + .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '_' }) + .collect::(); + let name = format!("{prefix}-asic-{asic}-dts"); + temperatures + .iter() + .find(|sensor| sensor.name == name) + .and_then(|sensor| sensor.temperature_c) +} + fn load_saved_operating_point_profile( path: Option<&Path>, ) -> Result, String> { @@ -3340,4 +3675,105 @@ mod tests { ] ); } + + #[test] + fn build_runtime_tuning_state_maps_live_measurements() { + let asics = vec![AsicState { + id: 0, + thread_index: Some(0), + serial_path: Some("/dev/ttyUSB0".into()), + discovered_engine_count: Some(236), + missing_engines: Vec::new(), + }]; + let temperatures = vec![TemperatureSensor { + name: "ttyUSB0-asic-0-dts".into(), + temperature_c: Some(67.0), + }]; + let bus_layouts = vec![Bzm2BusLayout { + serial_path: "/dev/ttyUSB0".into(), + asic_start: 0, + asic_count: 1, + }]; + let calibration = Bzm2CalibrationConfig::default(); + let bringup = Bzm2BringupConfig { + rail_set_paths: vec!["/tmp/rail0".into()], + ..Default::default() + }; + let rail_snapshot = Bzm2TelemetrySnapshot { + powers: vec![PowerMeasurement { + name: "rail0-output".into(), + voltage_v: Some(0.9), + current_a: Some(44.0), + power_w: Some(40.0), + }], + ..Default::default() + }; + let applied = Bzm2AppliedOperatingState { + per_domain_voltage_mv: BTreeMap::from([(0, 18_500)]), + per_asic_pll_mhz: BTreeMap::from([(0, [1_200.0, 1_200.0])]), + }; + let thread_metrics = BTreeMap::from([( + 0usize, + Bzm2ThreadRuntimeMetrics { + throughput_hs: Some(358_720_000_000), + asics: vec![crate::asic::bzm2::Bzm2AsicRuntimeMetrics { + asic: 0, + throughput_hs: Some(358_720_000_000), + scheduler_share_count: 12, + plls: [ + crate::asic::bzm2::Bzm2PllRuntimeMetrics { + throughput_hs: Some(179_360_000_000), + scheduler_share_count: 6, + }, + crate::asic::bzm2::Bzm2PllRuntimeMetrics { + throughput_hs: Some(179_360_000_000), + scheduler_share_count: 6, + }, + ], + }], + }, + )]); + + let (tuning, cache) = build_runtime_tuning_state( + &asics, + &temperatures, + &bus_layouts, + &calibration, + &bringup, + &rail_snapshot, + &applied, + &thread_metrics, + ); + + assert_eq!(tuning.board_throughput_hs, Some(358_720_000_000)); + assert_eq!(tuning.domains.len(), 1); + assert_eq!(tuning.domains[0].target_voltage_mv, Some(18_500)); + assert_eq!(tuning.domains[0].measured_voltage_mv, Some(900)); + assert_eq!(tuning.domains[0].measured_power_w, Some(40.0)); + assert_eq!(tuning.asics.len(), 1); + assert_eq!(tuning.asics[0].throughput_hs, Some(358_720_000_000)); + assert_eq!(tuning.asics[0].scheduler_share_count, Some(12)); + assert!( + tuning.asics[0] + .average_pass_rate + .is_some_and(|pass_rate| (pass_rate - 0.95).abs() < 0.0001) + ); + assert!( + tuning.asics[0].plls[0] + .pass_rate + .is_some_and(|pass_rate| (pass_rate - 0.95).abs() < 0.0001) + ); + assert!( + cache.asic_measurements[&0] + .temperature_c + .is_some_and(|temp| (temp - 67.0).abs() < 0.0001) + ); + assert!( + cache.asic_measurements[&0] + .throughput_ths + .is_some_and(|throughput| (throughput - 0.35872).abs() < 0.0001) + ); + assert_eq!(cache.domain_measurements[&0].measured_voltage_mv, Some(900)); + assert_eq!(cache.domain_measurements[&0].measured_power_w, Some(40.0)); + } } From 1201bb4ecccf8f0b18ac4d118ac1b7a4479c3185 Mon Sep 17 00:00:00 2001 From: Reckless Apotheosis <125509978+recklessnode@users.noreply.github.com> Date: Fri, 6 Mar 2026 16:01:48 -0800 Subject: [PATCH 14/24] feat(bzm2): add live board diagnostics and API parity Expose the highest-value BZM2 diagnostics and runtime status through the board-owned API surface. This commit adds: - live UART diagnostics routed through the active BZM2 thread - chain summary and clock-report endpoints - the supporting API contract types and regression coverage The diagnostics follow the existing UART ownership rules by requiring the thread to be idle when they run. --- docs/blockscale-reference-roadmap.md | 13 ++ docs/bzm2-port.md | 75 ++++++++- docs/bzm2-uart-debug.md | 169 ++++++++++++++++++- mujina-miner/src/api/server.rs | 144 ++++++++++++++++- mujina-miner/src/api/v0.rs | 234 ++++++++++++++++++++++++++- mujina-miner/src/api_client/types.rs | 78 +++++++++ mujina-miner/src/board/bzm2.rs | 79 +++++++++ mujina-miner/src/board/mod.rs | 27 ++++ 8 files changed, 806 insertions(+), 13 deletions(-) diff --git a/docs/blockscale-reference-roadmap.md b/docs/blockscale-reference-roadmap.md index 59e48bf3..20e74cb7 100644 --- a/docs/blockscale-reference-roadmap.md +++ b/docs/blockscale-reference-roadmap.md @@ -248,6 +248,19 @@ Deliverables: - saved operating-point replay path - current calibration and safety status +Status: + +- completed: board/API parity now covers live BZM2 thread-routed commands for: + - `NOOP` + - loopback + - register read/write +- completed: those diagnostics are exposed through HTTP endpoints that preserve + UART ownership by routing through the live BZM2 thread actor +- next: + - add clock-report parity + - add chain enumeration summary + - surface more of the same diagnostics through board state where useful + Exit criteria: - operators can perform high-value diagnostics through the board/API surface diff --git a/docs/bzm2-port.md b/docs/bzm2-port.md index 017f3be5..60c49aac 100644 --- a/docs/bzm2-port.md +++ b/docs/bzm2-port.md @@ -60,8 +60,8 @@ The BZM2 Mujina thread now reimplements the core legacy data path and the genera - reusable multi-rail bring-up and shutdown sequencing for single-rail, small-stack, and larger multi-stack designs - UART-register-based PLL diagnostic/control flow for divider programming, enable/disable, lock polling, and readback - UART-register-based DLL diagnostic/control flow for duty-cycle programming, enable/disable, lock polling, and fincon validation -- developer-facing UART debug CLI documented in [bzm2-uart-debug.md](C:/Users/prael/Documents/Codex/bzm2_mujina/docs/bzm2-uart-debug.md) with unicast, multicast, and broadcast examples -- domain-aware PnP calibration planner documented in [bzm2-pnp.md](C:/Users/prael/Documents/Codex/bzm2_mujina/docs/bzm2-pnp.md) for strategy/bin target selection, parameter sweeps, and per-domain plus per-ASIC tuning +- developer-facing UART debug CLI documented in [bzm2-uart-debug.md](bzm2-uart-debug.md) with unicast, multicast, and broadcast examples +- domain-aware BZM2 tuning planner documented in [bzm2-pnp.md](bzm2-pnp.md) for operating-class and performance-mode target selection, search-space generation, and per-domain plus per-ASIC tuning ## Configuration @@ -240,7 +240,7 @@ This is useful when: The engine-discovery path runs through the live BZM2 hash-thread actor, just like the DTS/VS query path, so UART ownership stays correct. Successful scans -update `BoardState.asics` with: +update the live thread engine layout and `BoardState.asics` with: - `id` - `thread_index` @@ -248,6 +248,10 @@ update `BoardState.asics` with: - `discovered_engine_count` - `missing_engines` +After a successful idle-time scan, subsequent work dispatch and result +reconstruction use the discovered active-engine layout instead of the built-in +default four-hole map. + HTTP API: - `POST /api/v0/boards/{name}/bzm2/discover-engines` @@ -262,6 +266,61 @@ curl -X POST http://127.0.0.1:3000/api/v0/boards/bzm2-0/bzm2/discover-engines \ The response returns the refreshed `BoardState`, including the updated `asics` topology entry for the queried ASIC. + +## API Diagnostics + +The board/API surface now exposes a first live UART diagnostics slice through +the BZM2 thread actor: + +- `POST /api/v0/boards/{name}/bzm2/noop` +- `POST /api/v0/boards/{name}/bzm2/loopback` +- `POST /api/v0/boards/{name}/bzm2/register-read` +- `POST /api/v0/boards/{name}/bzm2/register-write` +- `POST /api/v0/boards/{name}/bzm2/clock-report` + +These commands intentionally route through the live board-owned thread instead +of opening a second serial handle. That keeps UART ownership correct and avoids +silent contention with the mining path. + +Current safety boundary: + +- the target thread must be idle +- DTS/VS streaming must be inactive on that thread + +If either condition is false, the command is rejected rather than racing active +mining traffic or background telemetry frames. + +`clock-report` returns the same PLL/DLL status surface already exposed by the +standalone Rust debug CLI: + +- PLL enable register +- PLL misc register +- PLL enabled/locked bits +- DLL control2/control5 values +- DLL `coarsecon` +- DLL `fincon` +- DLL freeze-valid, lock, and fincon-valid state + +## Chain Summary + +The board/API surface also exposes the current BZM2 chain layout without +opening a second serial handle: + +- `GET /api/v0/boards/{name}/bzm2/chain-summary` + +The response summarizes: + +- current UART bus count +- serial path per bus +- global ASIC start/count per bus +- total ASIC count across the board +- whether the active operating point came from saved replay or live calibration +- current saved operating point validation state + +That gives operators a stable summary view of the active chain layout even when +the underlying board was discovered by startup enumeration rather than static +configuration alone. + ## Design Boundary The legacy `bzmd` board-power path mixes three different concerns: @@ -295,7 +354,13 @@ Still not implemented from the broader legacy stack: - JTAG workflows from the standalone platform documents - JTAG-only PLL debug sequences that are not represented in the shipped UART code - calibration and autotuning state machines -- manufacturing and diagnostics RPC surface +- full manufacturing and diagnostics RPC parity + - beyond the current live API surface for: + - `NOOP` + - loopback + - register read/write + - clock report + - chain summary - any board-MCU protocol that is specific to one carrier or backplane design The top-level `docs` PDFs reference additional JTAG and opcode material, but this port currently implements the opcode surface that is evidenced in the legacy shipping UART path and not an inferred JTAG control plane. @@ -303,4 +368,4 @@ The top-level `docs` PDFs reference additional JTAG and opcode material, but thi See also: -- docs/bzm2-opcode-grounding.md for the source-grounded opcode matrix and the current JTAG evidence boundary +- [bzm2-opcode-grounding.md](bzm2-opcode-grounding.md) for the source-grounded opcode matrix and the current JTAG evidence boundary diff --git a/docs/bzm2-uart-debug.md b/docs/bzm2-uart-debug.md index 10dd6e56..ff8696e3 100644 --- a/docs/bzm2-uart-debug.md +++ b/docs/bzm2-uart-debug.md @@ -4,7 +4,7 @@ This guide documents the direct BZM2 UART and silicon-validation interface added ## Intent -The current CLI folds in the portable parts of the legacy `silicon validation` BZM2 validation surface: +The current CLI folds in the portable parts of the legacy silicon validation surface: - chain enumeration and `ASIC_ID` assignment from the default bus id - ASIC discovery and liveness scans @@ -31,7 +31,7 @@ What is deliberately not ported here: ## Binary -Use [bzm2-debug.rs](C:/Users/prael/Documents/Codex/bzm2_mujina/mujina-miner/src/bin/bzm2-debug.rs) through Cargo: +Use [bzm2-debug.rs](../mujina-miner/src/bin/bzm2-debug.rs) through Cargo: ```text cargo run -p mujina-miner --bin mujina-bzm2-debug -- ... @@ -39,7 +39,7 @@ cargo run -p mujina-miner --bin mujina-bzm2-debug -- ... ## Legacy Test Mapping -The most useful `silicon validation` tests map to the following commands: +The most useful silicon validation tests map to the following commands: - `test_noop_all_asic` -> `noop-scan` - `test_loopback` -> `loopback-scan` @@ -110,6 +110,167 @@ cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ uart-read-result /dev/ttyUSB0 2 gen2 5000000 ``` +## API Visibility + +Passive Gen2 `DTS_VS` traffic is now reflected in the board API as named ASIC telemetry. + +Expected sensor names: + +- temperature: `ttyUSB0-asic-2-dts` +- voltage: `ttyUSB0-asic-2-vs-ch0`, `ttyUSB0-asic-2-vs-ch1`, `ttyUSB0-asic-2-vs-ch2` + +This is board-state telemetry, not a separate debug-only channel. If the miner is already receiving `DTS_VS` frames during operation, these entries appear in the normal board JSON under: + +- `temperatures` +- `powers` + +Use `tdm-watch` when you need to correlate the raw UART frames with the API-visible values. + +## On-Demand DTS/VS Queries + +Passive telemetry is useful when the miner is already receiving `DTS_VS` traffic. When hardware is misbehaving, you often need an explicit query path that forces a fresh ASIC sensor read. + +The debug CLI now supports direct on-demand DTS/VS reads: + +Query one ASIC: + +```text +cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ + dts-vs-query /dev/ttyUSB0 2 gen2 1500 5000000 +``` + +Scan an ASIC range and print each returned DTS/VS frame: + +```text +cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ + dts-vs-scan /dev/ttyUSB0 0 15 gen2 1500 5000000 +``` + +These commands: + +- enable the DTS/VS path if it is not already configured +- wait for a matching DTS/VS frame from the requested ASIC +- print temperature and voltage information in engineering units +- preserve the normal passive telemetry path so the same readings can still appear in board state later + +The `dts-gen` argument should match the ASIC telemetry generation in use: + +- `gen1` +- `gen2` + +Typical output includes: + +- ASIC id +- thermal status and trip bits +- Celsius temperature when available from the generation-specific decode path +- voltage channels in volts + +## HTTP API Query Example + +Mujina also exposes the same operation through the board API for live boards: + +```text +POST /api/v0/boards/{name}/bzm2/dts-vs-query +``` + +Example: + +```bash +curl -X POST http://127.0.0.1:3000/api/v0/boards/bzm2-0/bzm2/dts-vs-query \ + -H "Content-Type: application/json" \ + -d '{"thread_index":0,"asic":2}' +``` + +The response is the refreshed board JSON after the query completes. The relevant values appear in the normal board-state telemetry collections, for example: + +```json +{ + "temperatures": [ + { "name": "ttyUSB0-asic-2-dts", "temperature_c": 64.5 } + ], + "powers": [ + { "name": "ttyUSB0-asic-2-vs-ch0", "voltage_v": 0.78, "current_a": null, "power_w": null } + ] +} +``` + +Request fields: + +- `thread_index`: which BZM2 hash thread owns the target UART bus +- `asic`: ASIC id to query on that bus + +## HTTP API Diagnostics + +Mujina now exposes a first board/API parity slice for live UART diagnostics +without dropping to a separate serial tool. These operations route through the +live BZM2 thread actor, so they preserve UART ownership and operate against the +same board instance visible in the normal API. + +Available endpoints: + +- `POST /api/v0/boards/{name}/bzm2/noop` +- `POST /api/v0/boards/{name}/bzm2/loopback` +- `POST /api/v0/boards/{name}/bzm2/register-read` +- `POST /api/v0/boards/{name}/bzm2/register-write` +- `POST /api/v0/boards/{name}/bzm2/clock-report` + +Examples: + +```bash +curl -X POST http://127.0.0.1:3000/api/v0/boards/bzm2-0/bzm2/noop \ + -H "Content-Type: application/json" \ + -d '{"thread_index":0,"asic":2}' +``` + +```bash +curl -X POST http://127.0.0.1:3000/api/v0/boards/bzm2-0/bzm2/loopback \ + -H "Content-Type: application/json" \ + -d '{"thread_index":0,"asic":2,"payload_hex":"0102aabb"}' +``` + +```bash +curl -X POST http://127.0.0.1:3000/api/v0/boards/bzm2-0/bzm2/register-read \ + -H "Content-Type: application/json" \ + -d '{"thread_index":0,"asic":2,"engine_address":4095,"offset":18,"count":4}' +``` + +```bash +curl -X POST http://127.0.0.1:3000/api/v0/boards/bzm2-0/bzm2/register-write \ + -H "Content-Type: application/json" \ + -d '{"thread_index":0,"asic":2,"engine_address":4095,"offset":18,"value_hex":"deadbeef"}' +``` + +```bash +curl -X POST http://127.0.0.1:3000/api/v0/boards/bzm2-0/bzm2/clock-report \ + -H "Content-Type: application/json" \ + -d '{"thread_index":0,"asic":2}' +``` + +Representative responses: + +```json +{ "payload_hex": "425a32" } +``` + +```json +{ "payload_hex": "0102aabb" } +``` + +```json +{ "value_hex": "11223344" } +``` + +```json +{ "bytes_written": 4 } +``` + +Current safety boundary: + +- these live UART diagnostics require the target BZM2 thread to be idle +- they also require DTS/VS streaming to be inactive on that thread +- if those conditions are not met, the board command returns an error instead + of competing with active mining or background telemetry traffic + ## TDM Control and Observation The legacy `enable_tdm()` path is grounded in a local-register write to `LOCAL_REG_UART_TDM_CTL` (`0x07`) with control word: @@ -285,4 +446,4 @@ cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ ## Scope Boundary -This interface is grounded in the legacy shipped UART path and the `silicon validation` validation source. It is not a JTAG debug interface and it is not a full BCH-vector regression harness. \ No newline at end of file +This interface is grounded in the legacy shipped UART path and the silicon validation source. It is not a JTAG debug interface and it is not a full BCH-vector regression harness. diff --git a/mujina-miner/src/api/server.rs b/mujina-miner/src/api/server.rs index ba9ce1b1..6110a02a 100644 --- a/mujina-miner/src/api/server.rs +++ b/mujina-miner/src/api/server.rs @@ -137,8 +137,10 @@ mod tests { use super::*; use crate::api::commands::SchedulerCommand; use crate::api_client::types::{ - AsicState, BoardState, Bzm2DtsVsQueryRequest, Bzm2EngineDiscoveryRequest, EngineCoordinate, - SourceState, TemperatureSensor, + AsicState, BoardState, Bzm2DtsVsQueryRequest, Bzm2EngineDiscoveryRequest, + Bzm2LoopbackRequest, Bzm2LoopbackResponse, Bzm2NoopRequest, Bzm2NoopResponse, + Bzm2RegisterReadRequest, Bzm2RegisterReadResponse, Bzm2RegisterWriteRequest, + Bzm2RegisterWriteResponse, EngineCoordinate, SourceState, TemperatureSensor, }; use crate::board::{BoardCommand, BoardRegistration}; @@ -351,6 +353,144 @@ mod tests { drop(cmd_rx); } + #[tokio::test] + async fn bzm2_diagnostic_endpoints_round_trip_payloads() { + let (miner_tx, miner_rx) = watch::channel(MinerState::default()); + let (cmd_tx, cmd_rx) = mpsc::channel::(16); + let mut registry = BoardRegistry::new(); + let (_state_tx, state_rx) = watch::channel(BoardState { + name: "bzm2-test".into(), + model: "BZM2".into(), + ..Default::default() + }); + let (board_cmd_tx, mut board_cmd_rx) = mpsc::channel(4); + registry.push(BoardRegistration { + state_rx, + command_tx: Some(board_cmd_tx), + }); + let router = build_router(miner_rx, Arc::new(Mutex::new(registry)), cmd_tx); + + tokio::spawn(async move { + while let Some(command) = board_cmd_rx.recv().await { + match command { + BoardCommand::QueryBzm2Noop { + thread_index, + asic, + reply, + } => { + assert_eq!(thread_index, 0); + assert_eq!(asic, 2); + let _ = reply.send(Ok(*b"BZ2")); + } + BoardCommand::QueryBzm2Loopback { + thread_index, + asic, + payload, + reply, + } => { + assert_eq!(thread_index, 0); + assert_eq!(asic, 2); + assert_eq!(payload, vec![0x01, 0x02, 0xaa, 0xbb]); + let _ = reply.send(Ok(payload)); + } + BoardCommand::ReadBzm2Register { + thread_index, + asic, + engine_address, + offset, + count, + reply, + } => { + assert_eq!(thread_index, 0); + assert_eq!(asic, 2); + assert_eq!(engine_address, 0x0fff); + assert_eq!(offset, 0x12); + assert_eq!(count, 4); + let _ = reply.send(Ok(vec![0x11, 0x22, 0x33, 0x44])); + } + BoardCommand::WriteBzm2Register { + thread_index, + asic, + engine_address, + offset, + value, + reply, + } => { + assert_eq!(thread_index, 0); + assert_eq!(asic, 2); + assert_eq!(engine_address, 0x0fff); + assert_eq!(offset, 0x12); + assert_eq!(value, vec![0xde, 0xad, 0xbe, 0xef]); + let _ = reply.send(Ok(())); + } + _ => {} + } + } + }); + + let (status, body) = post_json( + router.clone(), + "/api/v0/boards/bzm2-test/bzm2/noop", + &Bzm2NoopRequest { + thread_index: 0, + asic: 2, + }, + ) + .await; + assert_eq!(status, 200); + let noop: Bzm2NoopResponse = serde_json::from_str(&body).unwrap(); + assert_eq!(noop.payload_hex, "425a32"); + + let (status, body) = post_json( + router.clone(), + "/api/v0/boards/bzm2-test/bzm2/loopback", + &Bzm2LoopbackRequest { + thread_index: 0, + asic: 2, + payload_hex: "0102aabb".into(), + }, + ) + .await; + assert_eq!(status, 200); + let loopback: Bzm2LoopbackResponse = serde_json::from_str(&body).unwrap(); + assert_eq!(loopback.payload_hex, "0102aabb"); + + let (status, body) = post_json( + router.clone(), + "/api/v0/boards/bzm2-test/bzm2/register-read", + &Bzm2RegisterReadRequest { + thread_index: 0, + asic: 2, + engine_address: 0x0fff, + offset: 0x12, + count: 4, + }, + ) + .await; + assert_eq!(status, 200); + let readback: Bzm2RegisterReadResponse = serde_json::from_str(&body).unwrap(); + assert_eq!(readback.value_hex, "11223344"); + + let (status, body) = post_json( + router, + "/api/v0/boards/bzm2-test/bzm2/register-write", + &Bzm2RegisterWriteRequest { + thread_index: 0, + asic: 2, + engine_address: 0x0fff, + offset: 0x12, + value_hex: "deadbeef".into(), + }, + ) + .await; + assert_eq!(status, 200); + let write_ack: Bzm2RegisterWriteResponse = serde_json::from_str(&body).unwrap(); + assert_eq!(write_ack.bytes_written, 4); + + drop(miner_tx); + drop(cmd_rx); + } + #[tokio::test] async fn bzm2_engine_discovery_endpoint_returns_refreshed_board_state() { let (miner_tx, miner_rx) = watch::channel(MinerState::default()); diff --git a/mujina-miner/src/api/v0.rs b/mujina-miner/src/api/v0.rs index 73e32e0f..feb9b3fc 100644 --- a/mujina-miner/src/api/v0.rs +++ b/mujina-miner/src/api/v0.rs @@ -16,8 +16,10 @@ use utoipa_axum::{router::OpenApiRouter, routes}; use super::commands::SchedulerCommand; use super::server::SharedState; use crate::api_client::types::{ - BoardState, Bzm2DtsVsQueryRequest, Bzm2EngineDiscoveryRequest, MinerPatchRequest, MinerState, - SourceState, + BoardState, Bzm2DtsVsQueryRequest, Bzm2EngineDiscoveryRequest, Bzm2LoopbackRequest, + Bzm2LoopbackResponse, Bzm2NoopRequest, Bzm2NoopResponse, Bzm2RegisterReadRequest, + Bzm2RegisterReadResponse, Bzm2RegisterWriteRequest, Bzm2RegisterWriteResponse, + MinerPatchRequest, MinerState, SourceState, }; use crate::board::BoardCommand; @@ -29,6 +31,10 @@ pub fn routes() -> OpenApiRouter { .routes(routes!(get_boards)) .routes(routes!(get_board)) .routes(routes!(query_bzm2_dts_vs)) + .routes(routes!(query_bzm2_noop)) + .routes(routes!(query_bzm2_loopback)) + .routes(routes!(read_bzm2_register)) + .routes(routes!(write_bzm2_register)) .routes(routes!(discover_bzm2_engines)) .routes(routes!(get_sources)) .routes(routes!(get_source)) @@ -200,6 +206,230 @@ async fn query_bzm2_dts_vs( .ok_or(StatusCode::NOT_FOUND) } +fn decode_hex_payload(raw: &str) -> Result, StatusCode> { + hex::decode(raw.trim()).map_err(|_| StatusCode::BAD_REQUEST) +} + +/// Trigger a live BZM2 NOOP diagnostic through a board-owned UART thread. +#[utoipa::path( + post, + path = "/boards/{name}/bzm2/noop", + tag = "boards", + params( + ("name" = String, Path, description = "Board name"), + ), + request_body = Bzm2NoopRequest, + responses( + (status = OK, description = "NOOP response payload", body = Bzm2NoopResponse), + (status = BAD_REQUEST, description = "Board does not support BZM2 diagnostics"), + (status = NOT_FOUND, description = "Board not found"), + (status = INTERNAL_SERVER_ERROR, description = "Board command failed"), + ), +)] +async fn query_bzm2_noop( + State(state): State, + Path(name): Path, + 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::QueryBzm2Noop { + thread_index: req.thread_index, + asic: req.asic, + reply: tx, + }) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let Ok(Ok(Ok(payload))) = tokio::time::timeout(Duration::from_secs(5), rx).await else { + return Err(StatusCode::INTERNAL_SERVER_ERROR); + }; + + Ok(Json(Bzm2NoopResponse { + payload_hex: hex::encode(payload), + })) +} + +/// Trigger a live BZM2 loopback diagnostic through a board-owned UART thread. +#[utoipa::path( + post, + path = "/boards/{name}/bzm2/loopback", + tag = "boards", + params( + ("name" = String, Path, description = "Board name"), + ), + request_body = Bzm2LoopbackRequest, + responses( + (status = OK, description = "Loopback response payload", body = Bzm2LoopbackResponse), + (status = BAD_REQUEST, description = "Board does not support BZM2 diagnostics or request payload is invalid"), + (status = NOT_FOUND, description = "Board not found"), + (status = INTERNAL_SERVER_ERROR, description = "Board command failed"), + ), +)] +async fn query_bzm2_loopback( + State(state): State, + Path(name): Path, + Json(req): Json, +) -> Result, StatusCode> { + let payload = decode_hex_payload(&req.payload_hex)?; + 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::QueryBzm2Loopback { + thread_index: req.thread_index, + asic: req.asic, + payload, + reply: tx, + }) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let Ok(Ok(Ok(payload))) = tokio::time::timeout(Duration::from_secs(5), rx).await else { + return Err(StatusCode::INTERNAL_SERVER_ERROR); + }; + + Ok(Json(Bzm2LoopbackResponse { + payload_hex: hex::encode(payload), + })) +} + +/// Perform a live BZM2 register read through a board-owned UART thread. +#[utoipa::path( + post, + path = "/boards/{name}/bzm2/register-read", + tag = "boards", + params( + ("name" = String, Path, description = "Board name"), + ), + request_body = Bzm2RegisterReadRequest, + responses( + (status = OK, description = "Register payload", body = Bzm2RegisterReadResponse), + (status = BAD_REQUEST, description = "Board does not support BZM2 diagnostics"), + (status = NOT_FOUND, description = "Board not found"), + (status = INTERNAL_SERVER_ERROR, description = "Board command failed"), + ), +)] +async fn read_bzm2_register( + State(state): State, + Path(name): Path, + 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::ReadBzm2Register { + thread_index: req.thread_index, + asic: req.asic, + engine_address: req.engine_address, + offset: req.offset, + count: req.count, + reply: tx, + }) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let Ok(Ok(Ok(value))) = tokio::time::timeout(Duration::from_secs(5), rx).await else { + return Err(StatusCode::INTERNAL_SERVER_ERROR); + }; + + Ok(Json(Bzm2RegisterReadResponse { + value_hex: hex::encode(value), + })) +} + +/// Perform a live BZM2 register write through a board-owned UART thread. +#[utoipa::path( + post, + path = "/boards/{name}/bzm2/register-write", + tag = "boards", + params( + ("name" = String, Path, description = "Board name"), + ), + request_body = Bzm2RegisterWriteRequest, + responses( + (status = OK, description = "Register write acknowledgement", body = Bzm2RegisterWriteResponse), + (status = BAD_REQUEST, description = "Board does not support BZM2 diagnostics or request payload is invalid"), + (status = NOT_FOUND, description = "Board not found"), + (status = INTERNAL_SERVER_ERROR, description = "Board command failed"), + ), +)] +async fn write_bzm2_register( + State(state): State, + Path(name): Path, + Json(req): Json, +) -> Result, StatusCode> { + let value = decode_hex_payload(&req.value_hex)?; + let bytes_written = value.len(); + 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::WriteBzm2Register { + thread_index: req.thread_index, + asic: req.asic, + engine_address: req.engine_address, + offset: req.offset, + value, + reply: tx, + }) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let Ok(Ok(Ok(()))) = tokio::time::timeout(Duration::from_secs(5), rx).await else { + return Err(StatusCode::INTERNAL_SERVER_ERROR); + }; + + Ok(Json(Bzm2RegisterWriteResponse { bytes_written })) +} + /// Trigger an explicit BZM2 engine-discovery scan and return the refreshed board state. #[utoipa::path( post, diff --git a/mujina-miner/src/api_client/types.rs b/mujina-miner/src/api_client/types.rs index 6dabc56e..56e74a61 100644 --- a/mujina-miner/src/api_client/types.rs +++ b/mujina-miner/src/api_client/types.rs @@ -193,6 +193,84 @@ pub struct Bzm2EngineDiscoveryRequest { pub timeout_ms: Option, } +/// Request body for a live BZM2 NOOP diagnostic query. +#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] +pub struct Bzm2NoopRequest { + /// Index of the BZM2 UART thread/bus to query. + pub thread_index: usize, + /// ASIC id on that UART bus. + pub asic: u8, +} + +/// Response body for a live BZM2 NOOP diagnostic query. +#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] +pub struct Bzm2NoopResponse { + /// Hex-encoded three-byte NOOP payload returned by the ASIC. + pub payload_hex: String, +} + +/// Request body for a live BZM2 loopback diagnostic query. +#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] +pub struct Bzm2LoopbackRequest { + /// Index of the BZM2 UART thread/bus to query. + pub thread_index: usize, + /// ASIC id on that UART bus. + pub asic: u8, + /// Hex-encoded payload to round-trip through the ASIC loopback opcode. + pub payload_hex: String, +} + +/// Response body for a live BZM2 loopback diagnostic query. +#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] +pub struct Bzm2LoopbackResponse { + /// Hex-encoded payload returned by the ASIC. + pub payload_hex: String, +} + +/// Request body for a live BZM2 register read. +#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] +pub struct Bzm2RegisterReadRequest { + /// Index of the BZM2 UART thread/bus to query. + pub thread_index: usize, + /// ASIC id on that UART bus. + pub asic: u8, + /// Engine or local-register address. + pub engine_address: u16, + /// Register offset within the selected engine or local block. + pub offset: u8, + /// Number of bytes to read. + pub count: u8, +} + +/// Response body for a live BZM2 register read. +#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] +pub struct Bzm2RegisterReadResponse { + /// Hex-encoded register payload. + pub value_hex: String, +} + +/// Request body for a live BZM2 register write. +#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] +pub struct Bzm2RegisterWriteRequest { + /// Index of the BZM2 UART thread/bus to query. + pub thread_index: usize, + /// ASIC id on that UART bus. + pub asic: u8, + /// Engine or local-register address. + pub engine_address: u16, + /// Register offset within the selected engine or local block. + pub offset: u8, + /// Hex-encoded bytes to write. + pub value_hex: String, +} + +/// Response body for a live BZM2 register write. +#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] +pub struct Bzm2RegisterWriteResponse { + /// Number of bytes written to the requested register. + pub bytes_written: usize, +} + /// Job source status. #[derive(Clone, Debug, Default, Deserialize, Serialize, ToSchema)] pub struct SourceState { diff --git a/mujina-miner/src/board/bzm2.rs b/mujina-miner/src/board/bzm2.rs index b210951e..77e84680 100644 --- a/mujina-miner/src/board/bzm2.rs +++ b/mujina-miner/src/board/bzm2.rs @@ -1147,6 +1147,85 @@ impl Bzm2Board { .await; let _ = reply.send(result); } + BoardCommand::QueryBzm2Noop { thread_index, asic, reply } => { + let result = async { + let handle = shutdown_handles.get(thread_index).ok_or_else(|| { + BoardError::HardwareControl(format!( + "invalid BZM2 thread index {thread_index} for board {board_name}" + )) + })?; + handle + .noop(asic) + .await + .map_err(|err| BoardError::HardwareControl(err.to_string())) + } + .await; + let _ = reply.send(result); + } + BoardCommand::QueryBzm2Loopback { + thread_index, + asic, + payload, + reply, + } => { + let result = async { + let handle = shutdown_handles.get(thread_index).ok_or_else(|| { + BoardError::HardwareControl(format!( + "invalid BZM2 thread index {thread_index} for board {board_name}" + )) + })?; + handle + .loopback(asic, payload) + .await + .map_err(|err| BoardError::HardwareControl(err.to_string())) + } + .await; + let _ = reply.send(result); + } + BoardCommand::ReadBzm2Register { + thread_index, + asic, + engine_address, + offset, + count, + reply, + } => { + let result = async { + let handle = shutdown_handles.get(thread_index).ok_or_else(|| { + BoardError::HardwareControl(format!( + "invalid BZM2 thread index {thread_index} for board {board_name}" + )) + })?; + handle + .read_register(asic, engine_address, offset, count) + .await + .map_err(|err| BoardError::HardwareControl(err.to_string())) + } + .await; + let _ = reply.send(result); + } + BoardCommand::WriteBzm2Register { + thread_index, + asic, + engine_address, + offset, + value, + reply, + } => { + let result = async { + let handle = shutdown_handles.get(thread_index).ok_or_else(|| { + BoardError::HardwareControl(format!( + "invalid BZM2 thread index {thread_index} for board {board_name}" + )) + })?; + handle + .write_register(asic, engine_address, offset, value) + .await + .map_err(|err| BoardError::HardwareControl(err.to_string())) + } + .await; + let _ = reply.send(result); + } BoardCommand::DiscoverBzm2Engines { thread_index, asic, diff --git a/mujina-miner/src/board/mod.rs b/mujina-miner/src/board/mod.rs index f0bad6f6..67ce6626 100644 --- a/mujina-miner/src/board/mod.rs +++ b/mujina-miner/src/board/mod.rs @@ -72,6 +72,33 @@ pub enum BoardCommand { asic: u8, reply: oneshot::Sender>, }, + QueryBzm2Noop { + thread_index: usize, + asic: u8, + reply: oneshot::Sender>, + }, + QueryBzm2Loopback { + thread_index: usize, + asic: u8, + payload: Vec, + reply: oneshot::Sender, BoardError>>, + }, + ReadBzm2Register { + thread_index: usize, + asic: u8, + engine_address: u16, + offset: u8, + count: u8, + reply: oneshot::Sender, BoardError>>, + }, + WriteBzm2Register { + thread_index: usize, + asic: u8, + engine_address: u16, + offset: u8, + value: Vec, + reply: oneshot::Sender>, + }, DiscoverBzm2Engines { thread_index: usize, asic: u8, From 566ff76e0f6417f3343d9e003053f0e452cfa47d Mon Sep 17 00:00:00 2001 From: Reckless Apotheosis <125509978+recklessnode@users.noreply.github.com> Date: Fri, 6 Mar 2026 16:02:39 -0800 Subject: [PATCH 15/24] docs(bzm2): organize reference docs under docs/bzm2 Move the BZM2 and Blockscale-specific documentation into a dedicated docs/bzm2 subtree, add the missing integration/reference guides, and drop the local porting conversation log from the upstream branch. This keeps the upstream-facing PR focused on reusable implementation and operator documentation rather than local development history. --- .gitattributes | 13 + README.md | 79 ++- .../bzm2/blockscale-asic-integration-guide.md | 546 ++++++++++++++++++ .../blockscale-reference-roadmap.md | 34 +- .../blockscale-uart-protocol-reference.md | 449 ++++++++++++++ docs/bzm2/bzm2-opcode-grounding.md | 80 +++ docs/{ => bzm2}/bzm2-pnp.md | 9 +- docs/{ => bzm2}/bzm2-port.md | 0 docs/{ => bzm2}/bzm2-uart-debug.md | 0 9 files changed, 1198 insertions(+), 12 deletions(-) create mode 100644 .gitattributes create mode 100644 docs/bzm2/blockscale-asic-integration-guide.md rename docs/{ => bzm2}/blockscale-reference-roadmap.md (84%) create mode 100644 docs/bzm2/blockscale-uart-protocol-reference.md create mode 100644 docs/bzm2/bzm2-opcode-grounding.md rename docs/{ => bzm2}/bzm2-pnp.md (90%) rename docs/{ => bzm2}/bzm2-port.md (100%) rename docs/{ => bzm2}/bzm2-uart-debug.md (100%) 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/README.md b/README.md index 615b7732..979352da 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,10 @@ designed to communicate with various Bitcoin mining hash boards via USB serial interfaces. Part of the larger Mujina OS project, an open source, Debian-based embedded Linux distribution optimized for Bitcoin mining hardware. +This repository also includes an active Rust port of the Intel BZM2 mining +stack. The goal of the port is to keep BZM2 support inside Mujina rather than +reviving the original split `cgminer` plus `bzmd` process model. + ## Features - **Heterogeneous Multi-Board Support**: Mix and match different hash board @@ -34,18 +38,34 @@ embedded Linux distribution optimized for Bitcoin mining hardware. - **Accessible Development**: Start developing with minimal hardware; a laptop and a single [Bitaxe](mujina-miner/src/board/bitaxe_gamma.md) board is enough to contribute meaningfully +- **BZM2 Port In Progress**: Native Rust BZM2 support with direct UART work + dispatch, result parsing, telemetry, debug tooling, and startup tuning flows ## Supported Hardware Currently supported: - [**Bitaxe Gamma**](mujina-miner/src/board/bitaxe_gamma.md) with BM1370 ASIC +Experimental support in this repository: +- **BZM2 boards** via Mujina's native Rust BZM2 path + - [Satoshi Starter](https://github.com/Blockscale-Solutions/SatoshiStarter) + - [bitaxeBIRDS](https://github.com/bitaxeorg/bitaxeBIRDS) + - direct UART mining path + - PLL and DLL diagnostics + - DTS/VS telemetry through the API + - on-demand DTS/VS query support through CLI and HTTP API + - silicon-validation helpers adapted from the legacy silicon validation stack + Planned support: - **EmberOne** with BM1362 ASIC -- **EmberOne** with Intel BZM2 ASICs - Antminer S19j Pro hash boards - Any and all ASIC mining hardware +The BZM2 work is functional and test-covered, but still not presented as +production-ready hardware support. The remaining gap is mostly board-specific +bring-up and manufacturing/operations integration rather than the core UART +ASIC path. + ## Documentation ### Project Documentation @@ -53,20 +73,20 @@ Planned support: - [Architecture Overview](docs/architecture.md) - System design and component interaction - [REST API](docs/api.md) - API contract, conventions, and endpoints -- [BZM2 Port Note](docs/bzm2-port.md) - Architecture, implemented behavior, +- [BZM2 Port Note](docs/bzm2/bzm2-port.md) - Architecture, implemented behavior, telemetry, and current scope boundaries for the BZM2 port -- [BZM2 UART Debug Guide](docs/bzm2-uart-debug.md) - CLI usage for UART, +- [BZM2 UART Debug Guide](docs/bzm2/bzm2-uart-debug.md) - CLI usage for UART, telemetry queries, TDM observation, clock diagnostics, and validation flows -- [BZM2 Tuning Planner](docs/bzm2-pnp.md) - Tuning-planner behavior and current +- [BZM2 Tuning Planner](docs/bzm2/bzm2-pnp.md) - Tuning-planner behavior and current calibration scope -- [BZM2 Opcode Grounding](docs/bzm2-opcode-grounding.md) - Source-grounded UART +- [BZM2 Opcode Grounding](docs/bzm2/bzm2-opcode-grounding.md) - Source-grounded UART opcode behavior and the current JTAG evidence boundary -- [Blockscale ASIC Integration Guide](docs/blockscale-asic-integration-guide.md) - +- [Blockscale ASIC Integration Guide](docs/bzm2/blockscale-asic-integration-guide.md) - Generic hardware design guidance for building a custom solution around the Blockscale / BZM2 ASIC -- [Blockscale UART And TDM Reference](docs/blockscale-uart-protocol-reference.md) - +- [Blockscale UART And TDM Reference](docs/bzm2/blockscale-uart-protocol-reference.md) - ASIC-facing UART, TDM, opcode, and job-programming reference -- [Blockscale Reference Roadmap](docs/blockscale-reference-roadmap.md) - +- [Blockscale Reference Roadmap](docs/bzm2/blockscale-reference-roadmap.md) - Ordered implementation plan for closing the remaining bring-up, tuning, and diagnostics gaps - [CPU Mining](docs/cpu-mining.md) - Run without hardware for development and @@ -143,6 +163,33 @@ Without `MUJINA_POOL_URL`, the miner runs with a dummy job source that generates synthetic mining work, which is useful for testing hardware without a pool connection. +### BZM2 Quick Start + +Enable the BZM2 path by pointing Mujina at one or more serial devices: + +```bash +MUJINA_BZM2_SERIAL="/dev/ttyUSB0" \ +MUJINA_BZM2_BAUD="5000000" \ +MUJINA_BZM2_DTS_VS_GEN="2" \ +cargo run -p mujina-miner --bin minerd +``` + +Useful companion tooling: + +```bash +# Query one ASIC's DTS/VS telemetry directly +cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ + dts-vs-query /dev/ttyUSB0 2 gen2 1500 5000000 + +# Read refreshed board telemetry over HTTP +curl -X POST http://127.0.0.1:7785/api/v0/boards/bzm2-0/bzm2/dts-vs-query \ + -H "Content-Type: application/json" \ + -d '{"thread_index":0,"asic":2}' +``` + +See [BZM2 UART Debug Guide](docs/bzm2/bzm2-uart-debug.md) for the full command +surface. + ### API Server The REST API listens on `127.0.0.1:7785` by default. To listen @@ -207,6 +254,18 @@ commands, PMBus/I2C power management, and fan control. See [tools/mujina-dissect/README.md](tools/mujina-dissect/README.md) for detailed usage and documentation. +## Validation Status + +As of the current BZM2 porting work, the full Linux-side `mujina-miner` test +suite passes in WSL with: + +- `327 passed` +- `0 failed` +- `5 ignored` + +That validation includes the BZM2-specific protocol, thread, board, telemetry, +API, and debug-tooling coverage added in this repository. + ## License This project is licensed under the GNU General Public License v3.0 or later. @@ -231,3 +290,7 @@ started. Bitcoin mining hashboard - [emberone-usbserial-fw](https://github.com/256foundation/emberone-usbserial-fw) - Firmware for EmberOne boards +- [Satoshi Starter](https://github.com/Blockscale-Solutions/SatoshiStarter) - + BZM2-based open hardware carrier from Blockscale Solutions +- [bitaxeBIRDS](https://github.com/bitaxeorg/bitaxeBIRDS) - + BZM2-based Bitaxe-family board design diff --git a/docs/bzm2/blockscale-asic-integration-guide.md b/docs/bzm2/blockscale-asic-integration-guide.md new file mode 100644 index 00000000..a2090781 --- /dev/null +++ b/docs/bzm2/blockscale-asic-integration-guide.md @@ -0,0 +1,546 @@ +# Blockscale / BZM2 ASIC Hardware Integration Guide + +## Purpose + +This document consolidates ASIC-level behavior needed to design a custom +hardware solution around the Blockscale / BZM2 mining ASIC. It focuses on +generic implementation requirements: + +- power architecture +- sequencing +- clocking +- UART transport +- multi-ASIC chaining +- telemetry +- protection +- tuning and calibration + +It deliberately avoids copying vendor reference-board implementation details. +Those reference systems are useful examples, but they are not required for a +working design. + +## Scope + +This guide is for hardware developers building: + +- a single-ASIC board +- a small multi-ASIC board with one or a few voltage domains +- a larger multi-ASIC platform with multiple voltage domains + +The core constraint does not change with board size: every ASIC contains an +internal dual-stack engine arrangement and expects the host system to sequence, +clock, load, and protect it correctly. + +## ASIC At A Glance + +The vendor collateral and legacy host software consistently describe the +following ASIC-level properties: + +- `236` hashing engine tiles per ASIC +- `4` engines per tile +- `944` total engines per ASIC +- `2` primary PLL domains, corresponding to the bottom and top engine stacks +- on-die digital temperature sensing +- on-die three-channel voltage sensing +- UART as the primary host control and mining transport +- unicast, multicast, and broadcast job/register distribution +- TDM streaming for results, register responses, `NOOP`, and sensor data + +Throughput per ASIC can be estimated as: + +```text +Throughput (GH/s) = 236 * 4 * PLL_Frequency / 3 * Pass_Rate +``` + +Where: + +- `PLL_Frequency` is in GHz +- `Pass_Rate` is `0.0` to `1.0` + +Example: + +```text +1.2 GHz * (236 * 4 / 3) * 1.0 = 377.6 GH/s +``` + +That formula is useful for sizing cooling, PSU headroom, calibration targets, +and per-domain operating points. + +## Recommended System Partitioning + +The ASIC does not require a specific vendor platform. It does require that the +overall system provide the following functions: + +- stable control-side power +- stable stack-side power +- reference clock generation +- reset and trip handling +- UART master +- telemetry collection and protection logic +- work generation and result collection + +```mermaid +flowchart LR + Host["Host SoC / MCU / FPGA"] --> UART["UART Master"] + Host --> Power["Power / PMBus / Regulators"] + Host --> Cooling["Fan / Pump / Thermal Control"] + Host --> API["Control API / UI"] + UART --> Chain["ASIC Chain"] + Power --> Chain + Cooling --> Chain + Chain --> Sensors["DTS / VS / TRIP"] + Sensors --> Host +``` + +The exact split is your design choice: + +- a Linux SoC can own all logic directly +- an MCU can own power sequencing while a host CPU owns mining +- an FPGA can assist with fanout, timing, or board aggregation + +The ASIC-facing requirements remain the same. + +## Package, IO, And Mechanical Constraints + +The extracted vendor collateral describes: + +- package size: `7.5 x 7 mm` +- package type: exposed-die molded `FCLGA` +- total signal / land interfaces sized around `60` SLI pads and `60` LGA pads +- operating junction target range: roughly `55 C` to `85 C` +- absolute maximum junction temperature: `115 C` + +Design implications: + +- provide strong top-side thermal extraction +- assume continuous high leakage once stack voltage is present +- do not depend on reset state to keep thermal rise negligible +- plan for heatsink and forced airflow, or an equivalent thermal solution + +Even a single-ASIC design should be treated as thermally active immediately +after hash rails are applied. + +## Core Rails And Voltage Architecture + +The ASIC is built around internal voltage stacking. The documents describe two +engine-stack ranges: + +- bottom stack: approximately `0.0 V` to `0.355 V` +- top stack: approximately `0.355 V` to `0.71 V` + +Additional named rails described in the vendor material: + +- GPIO / control IO: `1.2 V` +- `VDD_HASH`: nominal `0.71 V` +- `VDD_P75`: backup rail if the on-chip LDO path is unavailable + +### Why voltage stacking matters + +Voltage stacking is central to efficiency, but it also creates the main +hardware risk: + +- the absolute stack voltages must stay inside safe limits +- the differential between the stacks must stay controlled +- bad sequencing or poor balancing can create overvoltage or thermal runaway + +The ASIC includes internal voltage sensing specifically because the host must +monitor and react to stack imbalance. + +### Voltage sensor channels + +The internal voltage sensor reports three useful channels: + +- `ch0`: bottom stack voltage +- `ch1`: top stack voltage +- `ch2`: differential between the stacks + +For a custom design, treat those as first-class runtime safety inputs, not +debug-only data. + +## Clocking + +### External reference clock + +The ASIC expects an external reference clock on `REFCLKIN`. The extracted +datasheet text describes: + +- `REFCLKIN` as the ASIC reference clock input +- maximum reference clock on that pin up to `50 MHz` + +The same collateral also exposes `REFCLKOUT1` and `REFCLKOUT2`, primarily as +debug-oriented outputs. + +### Internal PLLs + +The ASIC uses two PLLs to feed the two internal engine stacks: + +- `PLL0`: bottom stack +- `PLL1`: top stack + +Bring-up implications: + +- both PLLs are disabled by default +- software must enable them explicitly +- software must wait for lock before releasing dependent logic + +When both divider classes are changed, the documented programming rule is: + +1. write `FBDIV` +2. write `POSTDIV` + +Do not reverse that order during live clock changes. + +### DLL health + +The legacy software also validates DLL state using `coarsecon` and `fincon` +status. That is not strictly required to boot the ASIC, but it is useful for: + +- manufacturing validation +- marginal-clock debug +- SI validation at new board layouts or cable lengths + +If you are building a custom carrier or long-chain design, budget time for DLL +health checks during validation. + +## UART And Chain Topology + +UART is the primary host interface for: + +- enumeration +- register control +- job dispatch +- result retrieval +- TDM streaming +- sensor retrieval + +### Practical UART assumptions + +The extracted materials and legacy software consistently use: + +- default ASIC baud: `5 Mbps` +- host notch / slow clock during bring-up: `50 MHz` + +The pad tables describe the UART-related pads as `1.2 V` IO. Treat this as a +real electrical requirement when selecting the host UART PHY or level-shifting +scheme. + +### Chain orientation and pin muxing + +The vendor material describes a `PINSEL`-based pin muxing arrangement where the +same physical pins can serve as: + +- `RX_IN` / `TX_OUT` +- `RESET_IN` / `RESET_OUT` +- `TRIP_IN` / `TRIP_OUT` + +This is what enables daisy-chain style system layouts. For a generic design, +the important point is: + +- your schematic must preserve a consistent direction through the chain +- reset and trip propagation need the same level of attention as RX/TX routing + +### ASIC enumeration model + +The documented enumeration flow is: + +1. all ASICs start with default `ASIC_ID = 0xFA` +2. the host addresses `0xFA` +3. the first visible ASIC responds +4. the host writes a unique `ASIC_ID` +5. writing the ID also unlocks `RX_OUT` +6. the next ASIC becomes reachable +7. repeat until the chain is assigned + +`NOOP` returning `BZ2` is the simplest chain-liveness check. + +### Broadcast and multicast + +The ASIC supports: + +- unicast to one engine in one ASIC +- broadcast to the same engine position across all ASICs +- multicast to a row group + +That capability is what makes large chains viable over UART. Use it for: + +- initial register programming +- dummy-job deployment +- broad frequency ramps +- row-wise validation + +Reserve unicast for: + +- ASIC ID assignment +- per-ASIC final tuning +- fault isolation +- result ownership and targeted debug + +## Power-Up And Bring-Up Sequence + +The vendor documents describe a specific reference flow, but the reusable logic +for any custom board is: + +```mermaid +flowchart TD + A["Apply control rails and reference clock"] --> B["Apply safe initial stack voltage"] + B --> C["Hold ASICs in reset"] + C --> D["Bring UART online at 5 Mbps"] + D --> E["Enumerate ASICs from default ID 0xFA"] + E --> F["Confirm NOOP = BZ2"] + F --> G["Initialize LDO-related state and ASIC IDs"] + G --> H["Program safe initial PLL frequency"] + H --> I["Wait for PLL lock"] + I --> J["Enable TDM if streaming is needed"] + J --> K["Submit dummy work to keep engines loaded"] + K --> L["Raise stack voltage gradually while monitoring VS"] + L --> M["Run tuning and calibration sweep"] + M --> N["Transition to production job dispatch"] +``` + +### Practical bring-up rules + +- start from a conservative voltage +- start from a conservative clock, typically much lower than final operating + point +- do not ramp voltage or frequency without sensor feedback +- do not leave engines idle during stack-balancing phases if your control + strategy depends on balanced load +- do not start full production mining until IDs, PLL lock, and basic telemetry + are confirmed + +### Dummy-job use is not optional in stacked systems + +The software guide and calibration material both treat dummy jobs as part of +power balancing, not merely a debug trick. In practice, dummy jobs help: + +- keep engines drawing current +- maintain stack balance during ramp-up +- prevent some engines from sitting unloaded while others are active +- hold a repeatable thermal and electrical state during calibration + +## Mining Programming Model + +### Enhanced mode + +Enhanced mode is the default engine programming mode. The documented sequence +for a valid four-lane engine-tile submission is: + +1. enable TCE clocks +2. program nonce bounds and target +3. load the four midstates +4. program four write-job sequences +5. only the fourth write enables execution + +The four logical writes share: + +- merkle root residue +- start timestamp + +They differ by: + +- midstate +- sequence ID + +### Job control behavior + +The documented `JobControl` modes matter operationally: + +- `0x1`: mark pending job ready +- `0x2`: cancel current and pending job, return to idle +- `0x3`: abort current job and immediately launch pending job + +That cancel path is essential for recovery from invalid or stale engine state. + +### Partial and invalid programming + +The hardware documents are explicit about failure behavior: + +- partial programming can consume bytes from a following write and create + unintended nonces +- launching before the fourth write can cause incomplete jobs to execute +- disabled TCE lanes still require software to maintain correct sequencing +- unused TCE lanes should be flushed with zeroed dummy content + +Do not assume the ASIC silently sanitizes malformed software behavior. + +## Telemetry And Protection + +### Temperature sensing + +The ASIC exposes a digital temperature sensor. The developer guide and legacy +software both use the same conversion family: + +```text +T = K + Y * (N - 2^11 / 2^R) / 2^12 +``` + +Where: + +- `T` = temperature in Celsius +- `N` = raw thermal tune code +- `R` = sensor resolution, typically `12` +- `Y = 631.8` +- `K = -293.8` + +At default 12-bit resolution, a raw code near `2084` maps to approximately +`27.6 C`. + +### Voltage sensing + +The voltage conversion used by the vendor guide and legacy implementation is: + +```text +V = 1000 * (2 / 5) * VREF * (6 * N / 2^14 - 3 / 2^R - 1) +``` + +Where: + +- `V` = uncalibrated voltage in mV +- `N` = raw sensor code +- `R` = voltage-sensor resolution +- `VREF = 0.7067` + +### Protection behavior + +The ASIC can assert a trip output when thermal or voltage thresholds are +exceeded. A robust system should wire this into board-level protection. + +Recommended policy: + +- use sensor data for continuous host-side supervision +- use the trip path for fast hardware or firmware response +- treat temperature and differential stack voltage as shutdown-class signals +- never rely on software polling alone for destructive fault containment + +## Calibration Methodology + +The calibration material is useful as methodology, but not as a fixed set of +numbers. The reusable sequence is: + +1. characterize a single ASIC or a small golden sample +2. choose conservative initial voltage and frequency +3. reset all ASICs to the safe starting point +4. bring stack voltage to a safe operating region +5. use dummy jobs to keep the electrical state stable +6. raise frequency in steps, commonly `25 MHz` coarse steps +7. measure pass rate, throughput, temperature, current, and power +8. raise voltage only if throughput targets cannot be met within thermal and + power limits +9. once the board-level operating region is found, fine-tune individual ASICs + in smaller steps, for example `6.25 MHz` +10. persist the resulting operating point for restart reuse + +### Calibration inputs that should be board-specific + +The following should be measured on your own design, not copied from a vendor +reference system: + +- PSU current limits +- PSU power limits +- board thermal limits +- acceptable stack imbalance +- safe junction temperature target +- fan or pump response curves +- pass-rate thresholds for field use + +### What scales from 1 ASIC to 100 ASICs + +A practical strategy for scale is: + +- characterize at the domain level first +- then fine-tune per ASIC + +For example: + +- `1 ASIC`: one domain, direct per-ASIC tuning +- `4 ASICs`: tune the shared rail first, then trim per ASIC if needed +- `100 ASICs`: first establish safe per-domain voltage and coarse clock, then + apply per-ASIC final offsets + +That is also the model implemented in the Rust tuning planner in this +repository. + +## Design Recommendations By System Size + +### Single-ASIC board + +Recommended priorities: + +- keep power sequencing simple and deterministic +- expose UART, reset, and trip for debug access +- expose DTS/VS in firmware or API from day one +- use direct per-ASIC characterization rather than heavy-weight broadcast flows + +### Small multi-ASIC board + +Recommended priorities: + +- decide early whether all ASICs truly share one rail policy +- keep chain routing short and deterministic +- implement broadcast writes and per-ASIC unicast verification +- maintain enough sensor visibility to identify one bad ASIC quickly + +### Large multi-domain system + +Recommended priorities: + +- treat domain balancing as a system function, not an afterthought +- separate board protection from mining software +- use broadcast for coarse actions and unicast for final trim +- persist calibration state and replay it on restart +- provide out-of-band observability for voltage, current, and trip events + +## Common Failure Modes + +Expect these classes of issues during bring-up: + +- chain breaks due to mux orientation or RX/TX direction errors +- false confidence from UART liveness before IDs or PLLs are fully initialized +- stack imbalance during idle or partial-load operation +- thermal runaway from insufficient cooling during early ramp +- residual engine programming causing unexpected nonces +- malformed partial write-job sequences +- assuming all engine IDs are contiguous or present + +Design for fast isolation: + +- per-domain current and voltage visibility +- easy reset control +- easy UART capture +- per-ASIC NOOP and register-read debug +- trip logging + +## Minimum Validation Checklist + +Before calling a hardware platform ready, verify: + +- control IO is truly `1.2 V` compatible +- reference clock integrity at the ASIC pin +- reset propagation through the entire chain +- per-ASIC enumeration from default ID +- `NOOP` response integrity across the full chain +- stable PLL lock across the intended operating range +- valid DTS/VS readings for every ASIC +- no dangerous stack imbalance at idle, dummy load, and production load +- sustained production pass rate at target operating point +- protection response for overtemperature and stack-voltage faults + +## Relationship To The Mujina Rust Implementation + +This repository already includes a practical Rust implementation of the core +ASIC behavior discussed above: + +- UART opcode support +- TDM parsing +- PLL and DLL diagnostics +- DTS/VS telemetry +- on-demand sensor query support +- startup tuning and saved operating-point replay +- silicon-validation CLI flows + +Relevant follow-on documents: + +- [UART and TDM Reference](blockscale-uart-protocol-reference.md) +- [BZM2 Port Note](bzm2-port.md) +- [BZM2 UART Debug Guide](bzm2-uart-debug.md) +- [BZM2 Tuning Planner](bzm2-pnp.md) diff --git a/docs/blockscale-reference-roadmap.md b/docs/bzm2/blockscale-reference-roadmap.md similarity index 84% rename from docs/blockscale-reference-roadmap.md rename to docs/bzm2/blockscale-reference-roadmap.md index 20e74cb7..a7749971 100644 --- a/docs/blockscale-reference-roadmap.md +++ b/docs/bzm2/blockscale-reference-roadmap.md @@ -225,7 +225,25 @@ Status: - per-ASIC average pass rate - per-PLL pass rate and throughput - per-domain measured voltage and power -- next: feed those live measurements back into the tuning planner +- completed: the board runtime now feeds those live measurements back into the + existing tuning planner and publishes the current planner decision through + board state, including: + - reuse-saved-operating-point decision + - needs-retune decision + - desired voltage / clock / accept-ratio targets + - planner notes +- completed: runtime retune triggers are now promoted only after configurable + persistence across monitor polls for: + - throughput regression + - thermal drift + - persistent voltage imbalance +- completed: saved operating point profiles now carry runtime validation state + and are automatically: + - marked `validated` after clean runtime sampling + - marked `invalidated` when persistent retune triggers fire + - excluded from direct replay and planner seeding on later restarts once + invalidated +- next: Phase 6, diagnostics and API parity ## Phase 6: Diagnostics And API Parity @@ -256,10 +274,20 @@ Status: - register read/write - completed: those diagnostics are exposed through HTTP endpoints that preserve UART ownership by routing through the live BZM2 thread actor +- completed: the board/API surface now exposes `clock-report` parity through + the same live thread actor, so operators can inspect PLL/DLL lock state and + clock-control registers without dropping to the standalone CLI +- completed: the board/API surface now exposes a chain-summary view with: + - current per-bus serial path + - global ASIC ranges + - total discovered/configured ASIC count + - the startup path that produced the active operating point + - saved operating point validation state +- completed: Phase 6 exit criteria are now met for the planned board/API + diagnostics slice - next: - - add clock-report parity - - add chain enumeration summary - surface more of the same diagnostics through board state where useful + - only add broader manufacturing parity if there is a clear operator need Exit criteria: diff --git a/docs/bzm2/blockscale-uart-protocol-reference.md b/docs/bzm2/blockscale-uart-protocol-reference.md new file mode 100644 index 00000000..10c4fc04 --- /dev/null +++ b/docs/bzm2/blockscale-uart-protocol-reference.md @@ -0,0 +1,449 @@ +# Blockscale / BZM2 UART And TDM Protocol Reference + +## Purpose + +This document summarizes the ASIC-facing UART protocol, TDM behavior, and job +programming model needed to write host software or validation tooling for the +Blockscale / BZM2 ASIC family. + +It is written as a practical reference for: + +- firmware developers +- board bring-up engineers +- validation engineers +- host software developers + +## Link Characteristics + +The vendor material and legacy host software consistently assume: + +- default ASIC UART baud: `5 Mbps` +- `1.2 V` IO signaling on UART-related pads +- a reference / slow-clock environment derived from a `50 MHz` source during + early bring-up + +Practical recommendation: + +- establish communication at `5 Mbps` first +- validate signal integrity there before attempting any custom timing changes + +## Addressing Model + +Each command carries: + +- `ASIC_ID` +- opcode +- engine or group identifier +- register offset or data payload, depending on opcode + +The documented system addressing model allocates: + +- normal engine space for engine tiles +- top `0xFxx` engine-ID region for non-engine / local functions such as PLLs, + sensors, and internal controller blocks + +Do not assume engine IDs are contiguous or fully populated. The vendor material +explicitly allows holes due to disabled or missing engines. + +## ASIC Identification + +Relevant ID values used during system bring-up: + +- `0xFA`: default ID before enumeration +- `0xFF`: platform-wide broadcast ID + +Enumeration rule: + +- writing a unique `ASIC_ID` to an ASIC previously addressed at `0xFA` also + unlocks forwarding so the next ASIC becomes reachable + +## Routing Modes + +### Unicast + +Use unicast when you need: + +- one register or job to one engine in one ASIC +- per-ASIC tuning +- precise debug +- result ownership validation + +### Broadcast + +Use broadcast when you need: + +- the same action on the same target across all ASICs +- coarse startup programming +- coarse frequency ramps +- fast deployment of common work or dummy work + +### Multicast + +Use multicast when you need: + +- row-wise engine programming +- efficient fanout inside one ASIC or across ASICs +- validation flows that target equivalent engine positions + +The vendor material treats multicast write as a row-group fanout mechanism and +also uses it as the basis for some broadcast-style write patterns. + +## Opcode Summary + +| Opcode | Name | Primary use | Typical response | +| --- | --- | --- | --- | +| `0x0` | `WRITEJOB` | Write engine job state and launch work | No immediate payload response | +| `0x1` | `READRESULT` | Poll ASIC result buffer in non-TDM mode | Result record with status | +| `0x2` | `WRITEREG` | Write engine or local registers | No immediate payload response | +| `0x3` | `READREG` | Read engine or local registers | Register data | +| `0x4` | `MULTICAST_WRITE` | Write a row-group of engines | No immediate payload response | +| `0xD` | `DTS_VS` | Read thermal and voltage sensor data | Sensor payload | +| `0xE` | `LOOPBACK` | Echo payload for transport validation | Echoed payload | +| `0xF` | `NOOP` | Link and chain liveness test | ASCII `BZ2` | + +## Frame Structure + +The legacy software and opcode notes use a leading length field followed by the +actual command header and payload. + +### `NOOP` + +Purpose: + +- confirm the ASIC is synchronized with the host +- confirm chain reachability + +Behavior: + +- transmit a short command to a specific ASIC +- receive three ASCII bytes: `BZ2` + +Practical use: + +- first link test after setting baud +- chain walk after programming IDs +- low-risk sanity check during debug + +### `WRITEJOB` + +Purpose: + +- deliver engine work over UART + +Documented behavior: + +- writes `42` consecutive bytes of job state +- typical transmit length is `48` bytes including framing + +Payload content includes: + +- `32` bytes of midstate +- merkle root residue +- start timestamp +- sequence ID +- job control + +Header construction described in the opcode notes: + +```text +header = (asic << 24) | (WRITEJOB << 20) | (engine << 8) | 41 +``` + +### `READRESULT` + +Purpose: + +- poll the ASIC-level result buffer directly in non-TDM mode + +Returned fields include: + +- status +- engine ID +- sequence ID +- nonce +- timestamp + +A clear valid bit is not the same as a framing error. The host is expected to +consume the full response length even when no valid result is present. + +### `WRITEREG` + +Purpose: + +- write a target register range + +Documented behavior: + +- length is `7 + count` +- payload uses a byte count field encoded as `count - 1` + +Common uses: + +- enabling clocks +- programming nonce bounds and target +- local-register setup for TDM, clocking, or sensors + +### `READREG` + +Purpose: + +- read a target register range + +Practical uses: + +- register validation during bring-up +- ASIC-local debug +- clock and sensor status inspection + +### `MULTICAST_WRITE` + +Purpose: + +- write one register range to multiple engines identified by a row-group ID + +Common uses: + +- row-wise initialization +- engine-wide or board-wide debug patterns +- efficient deployment of common register state + +### `LOOPBACK` + +Purpose: + +- validate the link by echoing a chosen payload + +Use cases: + +- transport validation before full job submission +- characterization of chain reliability at length +- regression tests after changing clocks, cabling, or PHY conditions + +### `DTS_VS` + +Purpose: + +- retrieve thermal and voltage-sensor data + +It can be used: + +- directly as a query +- indirectly through TDM streaming when enabled + +## Enhanced-Mode Job Programming + +Enhanced mode is the normal software contract for engine operation. + +Required setup before job submission: + +- enable TCE clocks via config register `0x01` +- program `StartNonce` at `0x3C` +- program `EndNonce` at `0x40` +- program `Target` at `0x44` +- load the four midstates beginning at `0x10` + +### Four-write sequence + +| Write | `0x30` MR residue | `0x34` start timestamp | `0x38` sequence ID | `0x39` job control | +| --- | --- | --- | --- | --- | +| `WriteJob_0` | same value | same value | `0b00` | `0x0` | +| `WriteJob_1` | same value | same value | `0b01` | `0x0` | +| `WriteJob_2` | same value | same value | `0b10` | `0x0` | +| `WriteJob_3` | same value | same value | `0b11` | `0x1` or `0x3` | + +Key rule: + +- only the fourth write should launch execution + +If `JobControl` is asserted too early, the job can be launched in an incomplete +state. + +## Job Control Semantics + +The documented values are: + +- `0x0`: clear pending only +- `0x1`: mark pending ready +- `0x2`: clear current and pending, return to idle +- `0x3`: abort current and immediately promote pending + +Practical guidance: + +- use `0x1` for orderly sequencing +- use `0x3` for preemption when the current search space is stale +- use `0x2` to recover from invalid or hung engine state before resuming work + +## Partial, Incomplete, And Invalid Programming + +The hardware documentation is explicit that bad sequences are not harmless. + +### Dummy jobs + +When writing dummy jobs: + +- zero the midstate and merkle-residue content for the unused lanes + +Reason: + +- residual values can otherwise generate nonces that do not belong to the host + work model + +### Disabled TCEs + +If some TCEs are disabled: + +- software still has to maintain valid sequence structure +- disabled lanes should receive zeroed dummy content +- valid lanes can still carry production work + +### Partial write-job hazards + +If a write-job is incomplete: + +- missing bytes can be consumed from the next write +- unintended nonce generation can occur + +Do not treat UART framing problems as soft performance issues. They are +functional correctness problems. + +## TDM Overview + +TDM lets ASICs stream data back to the host in time slots associated with ASIC +IDs. + +The extracted datasheet text describes: + +- an ASIC owns TX opportunity when the slot matches its `ASIC_ID` +- the ASIC begins transmission after a programmed TDM delay +- TDM can carry multiple response classes including register responses, results, + `NOOP`, and thermal / voltage data + +The software guide gives a practical example: + +- with `128` bit-time slots at `5 MHz`, a TDM frame is approximately `2.5 ms` + +That matters because it bounds result and telemetry latency across a long chain. + +## Result Aggregation Model + +The software guide describes a two-stage buffering model: + +- each engine tile has an `8`-deep local result FIFO +- the ASIC notch block has a `16`-deep ASIC-level result FIFO + +Host-visible implications: + +- local tile FIFOs are scanned about every `300 us` +- results are aggregated before entering the TDM slot stream +- under expected mining conditions, FIFO overflow should be rare +- overflow bits still need to be handled because overflow means lost nonces + +## Sensor Streaming And Querying + +### TDM sensor behavior + +The datasheet describes thermal and voltage telemetry as a TDM payload source. +The voltage / thermal packet is described as the fourth packet class in TDM +operation. + +### Direct query behavior + +`DTS_VS` can also be used as a direct query opcode when explicit on-demand +sensor retrieval is needed. + +This is the model now exposed in the Rust debug CLI and HTTP API in this +repository. + +## DTS / VS Payload Layout + +The vendor material describes an 8-byte sensor payload. At a practical level, +the host needs to extract: + +- thermal tune code +- thermal validity / enable bits +- thermal-trip status +- voltage enable bit +- voltage shutdown / fault status +- voltage raw codes for `ch0`, `ch1`, and `ch2` +- PLL lock state bits when present in the response generation + +### Voltage channels + +The three channels represent: + +- `ch0`: bottom stack voltage +- `ch1`: top stack voltage +- `ch2`: differential between stacks + +These raw values should be converted into engineering units before being used +for protection or calibration decisions. + +## Sensor Conversion Equations + +### Temperature + +```text +T = K + Y * (N - 2^11 / 2^R) / 2^12 +``` + +Where: + +- `T` = Celsius +- `N` = raw tune code +- `R` = resolution +- `Y = 631.8` +- `K = -293.8` + +### Voltage + +```text +V = 1000 * (2 / 5) * VREF * (6 * N / 2^14 - 3 / 2^R - 1) +``` + +Where: + +- `V` = mV +- `N` = raw voltage code +- `R` = resolution +- `VREF = 0.7067` + +## `NOOP` Timing Caution + +The datasheet text includes a specific warning for `NOOP`: + +- do not issue back-to-back `NOOP` commands with only one stop bit of spacing +- in non-TDM mode, maintain at least a three-byte gap between consecutive + `NOOP`s + +Treat this as a real transport rule during low-level validation. + +## Practical Host Strategy + +For production-capable software, a good division of labor is: + +- use broadcast or multicast for common initialization +- use unicast for identity assignment and final trim +- use TDM for steady-state results and telemetry +- use direct `READREG`, `READRESULT`, `NOOP`, `LOOPBACK`, and `DTS_VS` for + debug and validation + +That is also how the Rust tooling in this repository is structured. + +## Practical Validation With This Repository + +Relevant follow-on references in this repository: + +- [ASIC Integration Guide](blockscale-asic-integration-guide.md) +- [BZM2 UART Debug Guide](bzm2-uart-debug.md) +- [BZM2 Port Note](bzm2-port.md) + +The Rust debug CLI already exposes: + +- `NOOP` scans +- loopback scans +- TDM watch mode +- direct `DTS_VS` queries +- result polling +- PLL and DLL diagnostics +- broadcast and multicast helpers +- silicon-validation flows diff --git a/docs/bzm2/bzm2-opcode-grounding.md b/docs/bzm2/bzm2-opcode-grounding.md new file mode 100644 index 00000000..463f01ce --- /dev/null +++ b/docs/bzm2/bzm2-opcode-grounding.md @@ -0,0 +1,80 @@ +# BZM2 Opcode And JTAG Grounding + +## Scope + +This note captures only behavior that is grounded in material available in this workspace: + +- top-level PDFs in `../docs` +- legacy UART implementation in [uart.h](../bzm2_cgminer/feeds/mining_src/bzmd/uart.h) and [uart.c](../bzm2_cgminer/feeds/mining_src/bzmd/uart.c) +- legacy exercised behavior in [test.c](../bzm2_cgminer/feeds/mining_src/bzmd/tests/test.c) + +Anything not evidenced there is intentionally excluded from the Mujina port. + +## What The PDFs Proved + +The top-level PDFs are present: + +- the vendor JTAG usage guide +- the vendor opcode explanation guide + +From this shell, the PDFs are not extractable into reliable body text. The only recoverable textual evidence was: + +- JTAG PDF bookmark text: `PLL0/PLL1 debug signals` +- opcode PDF outline text: `1 Document Revision History` + +That is not enough to justify implementing a JTAG protocol layer or inventing undocumented opcode semantics. + +## What The Legacy Source Proved + +The legacy `bzmd` source gives a concrete UART wire contract for these opcodes: + +- `WRITEJOB` +- `READRESULT` +- `WRITEREG` +- `READREG` +- `MULTICAST_WRITE` +- `DTS_VS` +- `LOOPBACK` +- `NOOP` + +Grounded request/response behavior from [uart.c](../bzm2_cgminer/feeds/mining_src/bzmd/uart.c): + +- `WRITEREG`: request is `len(2 LE) + header(4 BE) + count_minus_one + payload` +- `MULTICAST_WRITE`: same framing as `WRITEREG`, but opcode `0x4` +- `READREG`: request is fixed-length `8` byte frame with terminal target byte; direct response is `asic + opcode + payload` +- `READRESULT`: in TDM mode, result frame is `asic + opcode + 8-byte payload` +- `NOOP`: request is a 4-byte frame; response payload is 3 bytes +- `LOOPBACK`: request is `len + header + count_minus_one + payload`; response echoes `asic + opcode + payload` +- `DTS_VS`: in TDM mode, payload is 4 bytes for gen1 and 8 bytes for gen2 + +Grounded concurrency and parser behavior from [uart.h](../bzm2_cgminer/feeds/mining_src/bzmd/uart.h), [uart.c](../bzm2_cgminer/feeds/mining_src/bzmd/uart.c), and [test.c](../bzm2_cgminer/feeds/mining_src/bzmd/tests/test.c): + +- TDM parsing is byte-stream oriented and must resynchronize after unknown prefixes +- TDM `READREG` response size is caller-driven and tracked per ASIC +- one outstanding TDM register read per ASIC is the supported model +- one outstanding TDM noop per ASIC is the supported model +- broadcast register writes use `WRITEREG` with ASIC `0xFF`, not a separate broadcast opcode +- broadcast TDM register reads are layered on top of `READREG`, not a distinct opcode + +## What Mujina Now Grounds + +Current Mujina BZM2 support in [protocol.rs](./mujina-miner/src/asic/bzm2/protocol.rs) is now explicitly locked to the legacy-tested UART behavior for: + +- `WRITEREG`, `READREG`, `WRITEJOB`, `MULTICAST_WRITE`, `READRESULT`, `NOOP`, `LOOPBACK`, `DTS_VS` +- gen1 and gen2 DTS/VS payload decoding +- partial-frame buffering and resynchronization after unknown byte prefixes +- legacy wire-format invariants for register, noop, and loopback command encoders + +## Deliberate Exclusions + +Not implemented from the docs side: + +- JTAG command transport +- JTAG IR/DR scan helpers +- PLL debug readout sequences +- any opcode semantics that cannot be traced to shipped UART code or tests + +Reason: + +- the available source in this workspace proves the UART mining/control path +- the available PDF extraction on this machine does not provide enough packet-level JTAG detail to implement anything defensible \ No newline at end of file diff --git a/docs/bzm2-pnp.md b/docs/bzm2/bzm2-pnp.md similarity index 90% rename from docs/bzm2-pnp.md rename to docs/bzm2/bzm2-pnp.md index bf40c13f..18a76c52 100644 --- a/docs/bzm2-pnp.md +++ b/docs/bzm2/bzm2-pnp.md @@ -99,6 +99,14 @@ The planner is now wired into `Bzm2Board` startup so Mujina can: - normalize saved-throughput comparisons and planned board hashrate against actual active-engine capacity instead of assuming every ASIC still has the default full map +- run the same planner against live runtime measurements during mining so the + board can continuously evaluate whether the current operating point is still + valid +- automatically promote saved operating point state from `pending` to + `validated` after clean runtime sampling +- automatically invalidate saved operating point profiles when persistent + runtime retune triggers fire, so restart replay will not reuse a known-bad + operating point Engine-capacity inputs now come from, in order: @@ -114,7 +122,6 @@ That means tuning decisions can now distinguish between: What still remains outside the ASIC planner layer: - board-specific PSU ramp policy -- automatic runtime retune decisions driven by the live measurements above - reimplementation of the legacy CSV/database layer Those pieces still belong above the ASIC planner, in board or daemon integration layers. diff --git a/docs/bzm2-port.md b/docs/bzm2/bzm2-port.md similarity index 100% rename from docs/bzm2-port.md rename to docs/bzm2/bzm2-port.md diff --git a/docs/bzm2-uart-debug.md b/docs/bzm2/bzm2-uart-debug.md similarity index 100% rename from docs/bzm2-uart-debug.md rename to docs/bzm2/bzm2-uart-debug.md From fa3b9ce7228b34839f960f0e172589ca504660ec Mon Sep 17 00:00:00 2001 From: Reckless Apotheosis <125509978+recklessnode@users.noreply.github.com> Date: Wed, 18 Mar 2026 14:57:02 -0700 Subject: [PATCH 16/24] docs(bzm2): scrub non-public source references --- .../bzm2/blockscale-asic-integration-guide.md | 40 +++++++++---------- .../blockscale-uart-protocol-reference.md | 27 ++++++------- docs/bzm2/bzm2-opcode-grounding.md | 19 +-------- docs/bzm2/bzm2-port.md | 2 +- 4 files changed, 36 insertions(+), 52 deletions(-) diff --git a/docs/bzm2/blockscale-asic-integration-guide.md b/docs/bzm2/blockscale-asic-integration-guide.md index a2090781..ee460c50 100644 --- a/docs/bzm2/blockscale-asic-integration-guide.md +++ b/docs/bzm2/blockscale-asic-integration-guide.md @@ -33,7 +33,7 @@ clock, load, and protect it correctly. ## ASIC At A Glance -The vendor collateral and legacy host software consistently describe the +The shipped software and observable ASIC behavior consistently indicate the following ASIC-level properties: - `236` hashing engine tiles per ASIC @@ -102,7 +102,7 @@ The ASIC-facing requirements remain the same. ## Package, IO, And Mechanical Constraints -The extracted vendor collateral describes: +The package and interface behavior used by the shipped software indicate: - package size: `7.5 x 7 mm` - package type: exposed-die molded `FCLGA` @@ -128,7 +128,7 @@ engine-stack ranges: - bottom stack: approximately `0.0 V` to `0.355 V` - top stack: approximately `0.355 V` to `0.71 V` -Additional named rails described in the vendor material: +Additional named rails used by the legacy platform: - GPIO / control IO: `1.2 V` - `VDD_HASH`: nominal `0.71 V` @@ -161,14 +161,14 @@ debug-only data. ### External reference clock -The ASIC expects an external reference clock on `REFCLKIN`. The extracted -datasheet text describes: +The ASIC expects an external reference clock on `REFCLKIN`. The hardware +interface used by the shipped software assumes: - `REFCLKIN` as the ASIC reference clock input - maximum reference clock on that pin up to `50 MHz` -The same collateral also exposes `REFCLKOUT1` and `REFCLKOUT2`, primarily as -debug-oriented outputs. +The same interface model also exposes `REFCLKOUT1` and `REFCLKOUT2`, primarily +as debug-oriented outputs. ### Internal PLLs @@ -215,7 +215,7 @@ UART is the primary host interface for: ### Practical UART assumptions -The extracted materials and legacy software consistently use: +The shipped software consistently uses: - default ASIC baud: `5 Mbps` - host notch / slow clock during bring-up: `50 MHz` @@ -226,7 +226,7 @@ scheme. ### Chain orientation and pin muxing -The vendor material describes a `PINSEL`-based pin muxing arrangement where the +The hardware interface uses a `PINSEL`-based pin muxing arrangement where the same physical pins can serve as: - `RX_IN` / `TX_OUT` @@ -241,7 +241,7 @@ the important point is: ### ASIC enumeration model -The documented enumeration flow is: +The enumeration flow implemented by the legacy stack is: 1. all ASICs start with default `ASIC_ID = 0xFA` 2. the host addresses `0xFA` @@ -277,8 +277,7 @@ Reserve unicast for: ## Power-Up And Bring-Up Sequence -The vendor documents describe a specific reference flow, but the reusable logic -for any custom board is: +The reusable logic for any custom board is: ```mermaid flowchart TD @@ -310,8 +309,8 @@ flowchart TD ### Dummy-job use is not optional in stacked systems -The software guide and calibration material both treat dummy jobs as part of -power balancing, not merely a debug trick. In practice, dummy jobs help: +The shipped software treats dummy jobs as part of power balancing, not merely a +debug trick. In practice, dummy jobs help: - keep engines drawing current - maintain stack balance during ramp-up @@ -322,7 +321,7 @@ power balancing, not merely a debug trick. In practice, dummy jobs help: ### Enhanced mode -Enhanced mode is the default engine programming mode. The documented sequence +Enhanced mode is the default engine programming mode. The implemented sequence for a valid four-lane engine-tile submission is: 1. enable TCE clocks @@ -343,7 +342,7 @@ They differ by: ### Job control behavior -The documented `JobControl` modes matter operationally: +The `JobControl` modes matter operationally: - `0x1`: mark pending job ready - `0x2`: cancel current and pending job, return to idle @@ -353,7 +352,8 @@ That cancel path is essential for recovery from invalid or stale engine state. ### Partial and invalid programming -The hardware documents are explicit about failure behavior: +The legacy software behavior and protocol handling make the failure behavior +clear: - partial programming can consume bytes from a following write and create unintended nonces @@ -367,8 +367,8 @@ Do not assume the ASIC silently sanitizes malformed software behavior. ### Temperature sensing -The ASIC exposes a digital temperature sensor. The developer guide and legacy -software both use the same conversion family: +The ASIC exposes a digital temperature sensor. The legacy software uses the +following conversion family: ```text T = K + Y * (N - 2^11 / 2^R) / 2^12 @@ -387,7 +387,7 @@ At default 12-bit resolution, a raw code near `2084` maps to approximately ### Voltage sensing -The voltage conversion used by the vendor guide and legacy implementation is: +The voltage conversion used by the legacy implementation is: ```text V = 1000 * (2 / 5) * VREF * (6 * N / 2^14 - 3 / 2^R - 1) diff --git a/docs/bzm2/blockscale-uart-protocol-reference.md b/docs/bzm2/blockscale-uart-protocol-reference.md index 10c4fc04..517dd93d 100644 --- a/docs/bzm2/blockscale-uart-protocol-reference.md +++ b/docs/bzm2/blockscale-uart-protocol-reference.md @@ -15,7 +15,7 @@ It is written as a practical reference for: ## Link Characteristics -The vendor material and legacy host software consistently assume: +The legacy host software consistently assumes: - default ASIC UART baud: `5 Mbps` - `1.2 V` IO signaling on UART-related pads @@ -42,8 +42,8 @@ The documented system addressing model allocates: - top `0xFxx` engine-ID region for non-engine / local functions such as PLLs, sensors, and internal controller blocks -Do not assume engine IDs are contiguous or fully populated. The vendor material -explicitly allows holes due to disabled or missing engines. +Do not assume engine IDs are contiguous or fully populated. Disabled or missing +engines create holes that software must tolerate. ## ASIC Identification @@ -85,8 +85,8 @@ Use multicast when you need: - efficient fanout inside one ASIC or across ASICs - validation flows that target equivalent engine positions -The vendor material treats multicast write as a row-group fanout mechanism and -also uses it as the basis for some broadcast-style write patterns. +Multicast write acts as a row-group fanout mechanism and is also used as the +basis for some broadcast-style write patterns. ## Opcode Summary @@ -311,14 +311,14 @@ functional correctness problems. TDM lets ASICs stream data back to the host in time slots associated with ASIC IDs. -The extracted datasheet text describes: +The hardware interface used by the shipped software assumes: - an ASIC owns TX opportunity when the slot matches its `ASIC_ID` - the ASIC begins transmission after a programmed TDM delay - TDM can carry multiple response classes including register responses, results, `NOOP`, and thermal / voltage data -The software guide gives a practical example: +The shipped software implies a practical example: - with `128` bit-time slots at `5 MHz`, a TDM frame is approximately `2.5 ms` @@ -326,7 +326,7 @@ That matters because it bounds result and telemetry latency across a long chain. ## Result Aggregation Model -The software guide describes a two-stage buffering model: +The shipped software uses a two-stage buffering model: - each engine tile has an `8`-deep local result FIFO - the ASIC notch block has a `16`-deep ASIC-level result FIFO @@ -342,9 +342,9 @@ Host-visible implications: ### TDM sensor behavior -The datasheet describes thermal and voltage telemetry as a TDM payload source. -The voltage / thermal packet is described as the fourth packet class in TDM -operation. +Thermal and voltage telemetry are exposed as a TDM payload source in the +implemented interface. The voltage / thermal packet is treated as the fourth +packet class in TDM operation. ### Direct query behavior @@ -356,8 +356,7 @@ repository. ## DTS / VS Payload Layout -The vendor material describes an 8-byte sensor payload. At a practical level, -the host needs to extract: +The implemented 8-byte sensor payload requires the host to extract: - thermal tune code - thermal validity / enable bits @@ -409,7 +408,7 @@ Where: ## `NOOP` Timing Caution -The datasheet text includes a specific warning for `NOOP`: +The legacy timing rules include a specific warning for `NOOP`: - do not issue back-to-back `NOOP` commands with only one stop bit of spacing - in non-TDM mode, maintain at least a three-byte gap between consecutive diff --git a/docs/bzm2/bzm2-opcode-grounding.md b/docs/bzm2/bzm2-opcode-grounding.md index 463f01ce..7b3a94e2 100644 --- a/docs/bzm2/bzm2-opcode-grounding.md +++ b/docs/bzm2/bzm2-opcode-grounding.md @@ -2,28 +2,13 @@ ## Scope -This note captures only behavior that is grounded in material available in this workspace: +This note captures only behavior that is grounded in material included in this repository: -- top-level PDFs in `../docs` - legacy UART implementation in [uart.h](../bzm2_cgminer/feeds/mining_src/bzmd/uart.h) and [uart.c](../bzm2_cgminer/feeds/mining_src/bzmd/uart.c) - legacy exercised behavior in [test.c](../bzm2_cgminer/feeds/mining_src/bzmd/tests/test.c) Anything not evidenced there is intentionally excluded from the Mujina port. -## What The PDFs Proved - -The top-level PDFs are present: - -- the vendor JTAG usage guide -- the vendor opcode explanation guide - -From this shell, the PDFs are not extractable into reliable body text. The only recoverable textual evidence was: - -- JTAG PDF bookmark text: `PLL0/PLL1 debug signals` -- opcode PDF outline text: `1 Document Revision History` - -That is not enough to justify implementing a JTAG protocol layer or inventing undocumented opcode semantics. - ## What The Legacy Source Proved The legacy `bzmd` source gives a concrete UART wire contract for these opcodes: @@ -77,4 +62,4 @@ Not implemented from the docs side: Reason: - the available source in this workspace proves the UART mining/control path -- the available PDF extraction on this machine does not provide enough packet-level JTAG detail to implement anything defensible \ No newline at end of file +- the repository-visible sources do not provide enough packet-level JTAG detail to implement anything defensible diff --git a/docs/bzm2/bzm2-port.md b/docs/bzm2/bzm2-port.md index 60c49aac..3d2d99c0 100644 --- a/docs/bzm2/bzm2-port.md +++ b/docs/bzm2/bzm2-port.md @@ -363,7 +363,7 @@ Still not implemented from the broader legacy stack: - chain summary - any board-MCU protocol that is specific to one carrier or backplane design -The top-level `docs` PDFs reference additional JTAG and opcode material, but this port currently implements the opcode surface that is evidenced in the legacy shipping UART path and not an inferred JTAG control plane. +This port currently implements the opcode surface that is evidenced in the legacy shipping UART path and not an inferred JTAG control plane. See also: From 7f591f5b3453ccf01ad387df0723c1b68847016e Mon Sep 17 00:00:00 2001 From: Reckless Apotheosis <125509978+recklessnode@users.noreply.github.com> Date: Wed, 18 Mar 2026 15:10:46 -0700 Subject: [PATCH 17/24] refactor(tuning): move Blockscale calibration out of the ASIC module --- docs/bzm2/bzm2-pnp.md | 3 +- mujina-miner/src/asic/bzm2/mod.rs | 8 - mujina-miner/src/board/bzm2.rs | 16 +- mujina-miner/src/lib.rs | 1 + mujina-miner/src/tuning/blockscale.rs | 998 ++++++++++++++++++++++++++ mujina-miner/src/tuning/mod.rs | 1 + 6 files changed, 1010 insertions(+), 17 deletions(-) create mode 100644 mujina-miner/src/tuning/blockscale.rs create mode 100644 mujina-miner/src/tuning/mod.rs diff --git a/docs/bzm2/bzm2-pnp.md b/docs/bzm2/bzm2-pnp.md index 18a76c52..733c7e06 100644 --- a/docs/bzm2/bzm2-pnp.md +++ b/docs/bzm2/bzm2-pnp.md @@ -45,7 +45,7 @@ The reusable algorithmic parts are: ## Mujina Ported Behavior The new Rust module at -`mujina-miner/src/asic/bzm2/pnp.rs` +`mujina-miner/src/tuning/blockscale.rs` implements the reusable planner without pulling board-MCU or PSU glue into the ASIC layer. Implemented: @@ -125,4 +125,3 @@ What still remains outside the ASIC planner layer: - reimplementation of the legacy CSV/database layer Those pieces still belong above the ASIC planner, in board or daemon integration layers. - diff --git a/mujina-miner/src/asic/bzm2/mod.rs b/mujina-miner/src/asic/bzm2/mod.rs index c222c945..258e11f0 100644 --- a/mujina-miner/src/asic/bzm2/mod.rs +++ b/mujina-miner/src/asic/bzm2/mod.rs @@ -1,6 +1,5 @@ pub mod clock; pub mod control; -pub mod pnp; pub mod protocol; pub mod thread; pub mod uart; @@ -13,13 +12,6 @@ pub use control::{ Bzm2BringupPlan, FileGpioPin, FilePowerRail, GpioResetLine, PowerRailTelemetry, Tps546PowerRail, VoltageStackStep, }; -pub use pnp::{ - Bzm2AsicMeasurement, Bzm2AsicPlan, Bzm2AsicTopology, Bzm2BoardCalibrationInput, - Bzm2CalibrationConstraints, Bzm2CalibrationMode, Bzm2CalibrationPlan, Bzm2CalibrationPlanner, - Bzm2CalibrationSweepRequest, Bzm2DomainMeasurement, Bzm2DomainPlan, Bzm2OperatingClass, - Bzm2PerformanceMode, Bzm2SavedEngineCoordinate, Bzm2SavedEngineTopology, - Bzm2SavedOperatingPoint, Bzm2VoltageDomain, -}; pub use protocol::Bzm2EngineLayout; pub use thread::{ Bzm2AsicRuntimeMetrics, Bzm2PllRuntimeMetrics, Bzm2Thread, Bzm2ThreadConfig, Bzm2ThreadHandle, diff --git a/mujina-miner/src/board/bzm2.rs b/mujina-miner/src/board/bzm2.rs index 77e84680..81014df8 100644 --- a/mujina-miner/src/board/bzm2.rs +++ b/mujina-miner/src/board/bzm2.rs @@ -18,12 +18,8 @@ use crate::{ }, asic::{ bzm2::{ - Bzm2AsicMeasurement, Bzm2AsicTopology, Bzm2BoardCalibrationInput, Bzm2BringupPlan, - Bzm2CalibrationConstraints, Bzm2CalibrationMode, Bzm2CalibrationPlanner, - Bzm2ClockController, Bzm2DiscoveredEngineMap, Bzm2DomainMeasurement, - Bzm2OperatingClass, Bzm2PerformanceMode, Bzm2Pll, Bzm2SavedEngineCoordinate, - Bzm2SavedEngineTopology, Bzm2SavedOperatingPoint, Bzm2Thread, Bzm2ThreadConfig, - Bzm2ThreadHandle, Bzm2ThreadRuntimeMetrics, Bzm2UartController, Bzm2VoltageDomain, + Bzm2BringupPlan, Bzm2ClockController, Bzm2DiscoveredEngineMap, Bzm2Pll, Bzm2Thread, + Bzm2ThreadConfig, Bzm2ThreadHandle, Bzm2ThreadRuntimeMetrics, Bzm2UartController, FileGpioPin, FilePowerRail, GpioResetLine, VoltageStackStep, control::Bzm2PowerRail, }, hash_thread::{ @@ -33,6 +29,12 @@ use crate::{ }, tracing::prelude::*, transport::{SerialControl, SerialStream}, + tuning::blockscale::{ + Bzm2AsicMeasurement, Bzm2AsicTopology, Bzm2BoardCalibrationInput, + Bzm2CalibrationConstraints, Bzm2CalibrationMode, Bzm2CalibrationPlanner, + Bzm2DomainMeasurement, Bzm2OperatingClass, Bzm2PerformanceMode, Bzm2SavedEngineCoordinate, + Bzm2SavedEngineTopology, Bzm2SavedOperatingPoint, Bzm2VoltageDomain, + }, }; const DEFAULT_BAUD_RATE: u32 = 5_000_000; @@ -2661,7 +2663,7 @@ fn store_calibration_profile( } fn estimate_planned_hashrate( - plan: &crate::asic::bzm2::Bzm2CalibrationPlan, + plan: &crate::tuning::blockscale::Bzm2CalibrationPlan, nominal_hashrate_ths: f32, asics: &[Bzm2AsicTopology], ) -> f32 { diff --git a/mujina-miner/src/lib.rs b/mujina-miner/src/lib.rs index fa348c23..2708f46b 100644 --- a/mujina-miner/src/lib.rs +++ b/mujina-miner/src/lib.rs @@ -14,5 +14,6 @@ pub mod scheduler; pub mod stratum_v1; pub mod tracing; pub mod transport; +pub mod tuning; pub mod types; mod u256; diff --git a/mujina-miner/src/tuning/blockscale.rs b/mujina-miner/src/tuning/blockscale.rs new file mode 100644 index 00000000..ffa6fbe7 --- /dev/null +++ b/mujina-miner/src/tuning/blockscale.rs @@ -0,0 +1,998 @@ +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +const CALI_VOLTAGE_MV: u32 = 50; +const CALI_FREQ_MHZ: f32 = 25.0; +const CALI_PASS_RATE_STEP: f32 = 0.0025; +const TARGET_VOLTAGE_MAX_MV: u32 = 21_000; +const TARGET_VOLTAGE_MIN_MV: u32 = 16_950; +const TARGET_FREQ_MAX_MHZ: f32 = 2_000.0; +const TARGET_FREQ_MIN_MHZ: f32 = 800.0; +const TARGET_FREQ_HIGH_PLUS_MHZ: f32 = 1_312.5; +const TARGET_FREQ_HIGH_MHZ: f32 = 1_200.0; +const TARGET_FREQ_BALANCED_MHZ: f32 = 1_150.0; +const TARGET_FREQ_LOW_MHZ: f32 = 1_000.0; +const FREQ_RANGE_MHZ: f32 = 100.0; +const MAX_FREQ_RANGE_MHZ: f32 = 150.0; +const ACCEPT_RATIO_BAND_MAX_THROUGHPUT: f32 = 0.02; +const ACCEPT_RATIO_BAND_STANDARD: f32 = 0.02; +const ACCEPT_RATIO_BAND_EFFICIENCY: f32 = 0.02; +const MIN_ACCEPT_RATIO: f32 = 0.90; +const DESIRED_ACCEPT_RATIO_MAX_THROUGHPUT: f32 = 0.975; +const DESIRED_ACCEPT_RATIO_STANDARD: f32 = 0.975; +const DESIRED_ACCEPT_RATIO_EFFICIENCY: f32 = 0.975; +const STARTUP_VOLTAGE_BIAS_MV: i32 = 50; +const SITE_TEMP_COLD_SOAK_C: f32 = -2.5; +const SITE_TEMP_COOL_C: f32 = 7.5; +const SITE_TEMP_NOMINAL_C: f32 = 17.5; +const SITE_TEMP_WARM_C: f32 = 27.5; +const DEFAULT_THERMAL_THRESHOLD_C: f32 = 100.0; +const DEFAULT_AVG_THERMAL_THRESHOLD_C: f32 = 85.0; +const DEFAULT_CURRENT_THRESHOLD_A: f32 = 260.0; +const DEFAULT_POWER_THRESHOLD_W: f32 = 4_900.0; +const DEFAULT_FREQ_INCREASE_RATIO_HIGH: f32 = 0.28; +const DEFAULT_FREQ_INCREASE_RATIO_LOW: f32 = 0.24; +const DEFAULT_RECALIBRATE_THROUGHPUT_RATIO: f32 = 0.80; +const NOMINAL_ACTIVE_ENGINE_COUNT: u16 = 236; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum Bzm2PerformanceMode { + MaxThroughput, + Standard, + Efficiency, +} + +impl Bzm2PerformanceMode { + fn pass_rate_range(self) -> f32 { + match self { + Self::MaxThroughput => ACCEPT_RATIO_BAND_MAX_THROUGHPUT, + Self::Standard => ACCEPT_RATIO_BAND_STANDARD, + Self::Efficiency => ACCEPT_RATIO_BAND_EFFICIENCY, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Bzm2OperatingClass { + Generic, + EarlyValidation, + ProductionValidation, + StackTunedA, + StackTunedB, + ExtendedHeadroom, + ExtendedHeadroomB, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Bzm2CalibrationMode { + pub sweep_strategy: bool, + pub sweep_voltage: bool, + pub sweep_frequency: bool, + pub sweep_pass_rate: bool, +} + +#[derive(Debug, Clone)] +pub struct Bzm2CalibrationConstraints { + pub max_power_w: f32, + pub max_current_a: f32, + pub max_thermal_c: f32, + pub max_avg_thermal_c: f32, + pub freq_range_mhz: f32, + pub max_freq_range_mhz: f32, + pub recalibrate_throughput_ratio: f32, +} + +impl Default for Bzm2CalibrationConstraints { + fn default() -> Self { + Self { + max_power_w: DEFAULT_POWER_THRESHOLD_W, + max_current_a: DEFAULT_CURRENT_THRESHOLD_A, + max_thermal_c: DEFAULT_THERMAL_THRESHOLD_C, + max_avg_thermal_c: DEFAULT_AVG_THERMAL_THRESHOLD_C, + freq_range_mhz: FREQ_RANGE_MHZ, + max_freq_range_mhz: MAX_FREQ_RANGE_MHZ, + recalibrate_throughput_ratio: DEFAULT_RECALIBRATE_THROUGHPUT_RATIO, + } + } +} + +#[derive(Debug, Clone)] +pub struct Bzm2CalibrationSweepRequest { + pub operating_class: Bzm2OperatingClass, + pub target_mode: Bzm2PerformanceMode, + pub mode: Bzm2CalibrationMode, + pub voltage_steps: u8, + pub frequency_steps: u8, + pub pass_rate_steps: u8, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Bzm2SavedOperatingPoint { + pub board_voltage_mv: u32, + pub board_throughput_ths: f32, + #[serde(default)] + pub per_domain_voltage_mv: BTreeMap, + #[serde(default)] + pub per_asic_engine_topology: BTreeMap, + pub per_asic_pll_mhz: BTreeMap, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)] +pub struct Bzm2SavedEngineCoordinate { + pub row: u8, + pub col: u8, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)] +pub struct Bzm2SavedEngineTopology { + #[serde(default)] + pub active_engine_count: u16, + #[serde(default)] + pub missing_engines: Vec, +} + +#[derive(Debug, Clone)] +pub struct Bzm2AsicTopology { + pub asic_id: u16, + pub domain_id: u16, + pub pll_count: usize, + pub alive: bool, + pub active_engine_count: u16, + pub missing_engines: Vec, +} + +#[derive(Debug, Clone)] +pub struct Bzm2VoltageDomain { + pub domain_id: u16, + pub asic_ids: Vec, + pub voltage_offset_mv: i32, + pub max_power_w: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct Bzm2DomainMeasurement { + pub domain_id: u16, + pub measured_voltage_mv: Option, + pub measured_power_w: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct Bzm2AsicMeasurement { + pub asic_id: u16, + pub temperature_c: Option, + pub throughput_ths: Option, + pub average_pass_rate: Option, + pub pll_pass_rates: [Option; 2], +} + +#[derive(Debug, Clone)] +pub struct Bzm2BoardCalibrationInput { + pub operating_class: Bzm2OperatingClass, + pub site_temp_c: f32, + pub target_mode: Bzm2PerformanceMode, + pub mode: Bzm2CalibrationMode, + pub per_stack_clocking: bool, + pub voltage_domains: Vec, + pub asics: Vec, + pub saved_operating_point: Option, + pub domain_measurements: Vec, + pub asic_measurements: Vec, + pub constraints: Bzm2CalibrationConstraints, + pub force_retune: bool, +} + +#[derive(Debug, Clone)] +pub struct Bzm2DomainPlan { + pub domain_id: u16, + pub voltage_mv: u32, + pub average_frequency_mhz: f32, + pub guarded: bool, + pub notes: Vec, +} + +#[derive(Debug, Clone)] +pub struct Bzm2AsicPlan { + pub asic_id: u16, + pub domain_id: u16, + pub pll_frequencies_mhz: [f32; 2], + pub notes: Vec, +} + +#[derive(Debug, Clone)] +pub struct Bzm2CalibrationPlan { + pub reuse_saved_operating_point: bool, + pub needs_retune: bool, + pub desired_voltage_mv: u32, + pub desired_clock_mhz: f32, + pub desired_accept_ratio: f32, + pub initial_voltage_mv: u32, + pub initial_frequency_mhz: f32, + pub freq_increase_threshold_mhz: f32, + pub search_space: Vec, + pub domain_plans: Vec, + pub asic_plans: Vec, + pub notes: Vec, +} + +#[derive(Debug, Clone)] +pub struct Bzm2ParameterSet { + pub mode: Bzm2PerformanceMode, + pub desired_voltage_mv: u32, + pub desired_clock_mhz: f32, + pub desired_accept_ratio: f32, +} + +#[derive(Debug, Default)] +pub struct Bzm2CalibrationPlanner; + +impl Bzm2CalibrationPlanner { + pub fn build_search_space( + &self, + request: &Bzm2CalibrationSweepRequest, + ) -> Vec { + let modes = if request.mode.sweep_strategy { + vec![ + Bzm2PerformanceMode::MaxThroughput, + Bzm2PerformanceMode::Standard, + Bzm2PerformanceMode::Efficiency, + ] + } else { + vec![request.target_mode] + }; + + let mut parameters = Vec::new(); + for mode in modes { + let target = operating_targets(request.operating_class, mode); + let voltage_offsets = build_offsets(request.mode.sweep_voltage, request.voltage_steps); + let frequency_offsets = + build_frequency_offsets(request.mode.sweep_frequency, request.frequency_steps); + let pass_rate_offsets = + build_pass_rate_offsets(request.mode.sweep_pass_rate, request.pass_rate_steps); + + for voltage_offset in &voltage_offsets { + for frequency_offset in &frequency_offsets { + for pass_rate_offset in &pass_rate_offsets { + parameters.push(Bzm2ParameterSet { + mode, + desired_voltage_mv: clamp_voltage(apply_i32( + target.voltage_mv, + *voltage_offset, + )), + desired_clock_mhz: clamp_frequency( + target.frequency_mhz + *frequency_offset, + ), + desired_accept_ratio: clamp_pass_rate( + target.pass_rate + *pass_rate_offset, + ), + }); + } + } + } + } + + parameters.sort_by(|a, b| { + a.mode + .cmp(&b.mode) + .then(a.desired_voltage_mv.cmp(&b.desired_voltage_mv)) + .then_with(|| a.desired_clock_mhz.total_cmp(&b.desired_clock_mhz)) + .then_with(|| a.desired_accept_ratio.total_cmp(&b.desired_accept_ratio)) + }); + parameters.dedup_by(|a, b| { + a.mode == b.mode + && a.desired_voltage_mv == b.desired_voltage_mv + && (a.desired_clock_mhz - b.desired_clock_mhz).abs() < f32::EPSILON + && (a.desired_accept_ratio - b.desired_accept_ratio).abs() < f32::EPSILON + }); + parameters + } + + pub fn plan(&self, input: &Bzm2BoardCalibrationInput) -> Bzm2CalibrationPlan { + let target = operating_targets(input.operating_class, input.target_mode); + let search_space = self.build_search_space(&Bzm2CalibrationSweepRequest { + operating_class: input.operating_class, + target_mode: input.target_mode, + mode: input.mode, + voltage_steps: 4, + frequency_steps: 4, + pass_rate_steps: 2, + }); + + let current_throughput = input + .asic_measurements + .iter() + .filter_map(|asic| asic.throughput_ths) + .sum::(); + let has_live_throughput = input + .asic_measurements + .iter() + .any(|asic| asic.throughput_ths.is_some()); + let live_active_engines = total_active_engine_count(&input.asics); + let reuse_saved_operating_point = + input.saved_operating_point.as_ref().is_some_and(|stored| { + let current_normalized_throughput = + normalize_throughput(current_throughput, live_active_engines); + let stored_normalized_throughput = normalize_throughput( + stored.board_throughput_ths, + stored_total_active_engine_count( + stored, + input.asics.iter().filter(|asic| asic.alive).count(), + ), + ); + !input.force_retune + && stored.per_asic_pll_mhz.len() + == input.asics.iter().filter(|asic| asic.alive).count() + && (!has_live_throughput + || current_normalized_throughput + >= stored_normalized_throughput + * input.constraints.recalibrate_throughput_ratio) + }); + let needs_retune = input.force_retune + || (has_live_throughput + && input.saved_operating_point.as_ref().is_some_and(|stored| { + normalize_throughput(current_throughput, live_active_engines) + < normalize_throughput( + stored.board_throughput_ths, + stored_total_active_engine_count( + stored, + input.asics.iter().filter(|asic| asic.alive).count(), + ), + ) * input.constraints.recalibrate_throughput_ratio + })); + + let (initial_voltage_mv, freq_increase_threshold_mhz) = initial_voltage_and_threshold( + target.voltage_mv, + input.site_temp_c, + input.mode.sweep_frequency, + ); + let initial_frequency_mhz = clamp_frequency( + (target.frequency_mhz - input.constraints.freq_range_mhz).max(TARGET_FREQ_MIN_MHZ), + ); + + let domain_measurements: BTreeMap = input + .domain_measurements + .iter() + .map(|measurement| (measurement.domain_id, measurement)) + .collect(); + let asic_measurements: BTreeMap = input + .asic_measurements + .iter() + .map(|measurement| (measurement.asic_id, measurement)) + .collect(); + + let mut domain_plans = Vec::new(); + let mut asic_plans = Vec::new(); + let mut notes = Vec::new(); + + for domain in &input.voltage_domains { + let domain_target_voltage = + clamp_voltage(apply_i32(initial_voltage_mv, domain.voltage_offset_mv)); + let domain_power = domain_measurements + .get(&domain.domain_id) + .and_then(|measurement| measurement.measured_power_w) + .unwrap_or_default(); + let domain_guarded = domain.max_power_w.is_some_and(|limit| domain_power > limit) + || domain_power > input.constraints.max_power_w; + + let domain_asics: Vec<&Bzm2AsicTopology> = input + .asics + .iter() + .filter(|asic| asic.alive && asic.domain_id == domain.domain_id) + .collect(); + let domain_avg_temp = average( + domain_asics + .iter() + .filter_map(|asic| asic_measurements.get(&asic.asic_id)) + .filter_map(|measurement| measurement.temperature_c), + ); + let domain_avg_pass_rate = average( + domain_asics + .iter() + .filter_map(|asic| asic_measurements.get(&asic.asic_id)) + .filter_map(|measurement| measurement.average_pass_rate), + ); + + let mut domain_frequency = initial_frequency_mhz; + let mut domain_notes = Vec::new(); + if let Some(pass_rate) = domain_avg_pass_rate { + if pass_rate >= target.pass_rate && !domain_guarded { + domain_frequency = clamp_frequency( + target + .frequency_mhz + .min(initial_frequency_mhz + input.constraints.max_freq_range_mhz), + ); + domain_notes.push(format!( + "domain average pass rate {:.2}% supports target frequency", + pass_rate * 100.0 + )); + } else { + domain_notes.push(format!( + "domain average pass rate {:.2}% below target {:.2}%", + pass_rate * 100.0, + target.pass_rate * 100.0 + )); + } + } + if let Some(temp) = domain_avg_temp { + if temp >= input.constraints.max_avg_thermal_c { + domain_frequency = clamp_frequency(domain_frequency - CALI_FREQ_MHZ); + domain_notes.push(format!( + "domain average temperature {:.1}C triggered thermal guard", + temp + )); + } + } + if domain_guarded { + domain_frequency = clamp_frequency(domain_frequency - CALI_FREQ_MHZ); + domain_notes.push("domain power guard active".into()); + } + + domain_plans.push(Bzm2DomainPlan { + domain_id: domain.domain_id, + voltage_mv: domain_target_voltage, + average_frequency_mhz: domain_frequency, + guarded: domain_guarded, + notes: domain_notes.clone(), + }); + + for asic in domain_asics { + let measurement = asic_measurements.get(&asic.asic_id).copied(); + let mut pll_frequencies = [domain_frequency; 2]; + let mut asic_notes = Vec::new(); + + if reuse_saved_operating_point { + if let Some(stored) = input + .saved_operating_point + .as_ref() + .and_then(|stored| stored.per_asic_pll_mhz.get(&asic.asic_id)) + { + pll_frequencies = *stored; + asic_notes.push("reusing stored per-ASIC calibration".into()); + } + } else if let Some(measurement) = measurement { + if let Some(temp) = measurement.temperature_c { + if temp >= input.constraints.max_thermal_c { + pll_frequencies = + [clamp_frequency(domain_frequency - CALI_FREQ_MHZ); 2]; + asic_notes.push(format!( + "ASIC temperature {:.1}C exceeded thermal threshold", + temp + )); + } + } + + if input.per_stack_clocking { + for (pll_index, pass_rate) in measurement.pll_pass_rates.iter().enumerate() + { + if let Some(pass_rate) = pass_rate { + let low = target.pass_rate - input.target_mode.pass_rate_range(); + let high = target.pass_rate + input.target_mode.pass_rate_range(); + if *pass_rate < low { + pll_frequencies[pll_index] = + clamp_frequency(pll_frequencies[pll_index] - CALI_FREQ_MHZ); + asic_notes.push(format!( + "PLL {} pass rate {:.2}% below window", + pll_index, + pass_rate * 100.0 + )); + } else if *pass_rate > high && !domain_guarded { + pll_frequencies[pll_index] = clamp_frequency( + pll_frequencies[pll_index] + CALI_FREQ_MHZ / 2.0, + ); + asic_notes.push(format!( + "PLL {} pass rate {:.2}% above window", + pll_index, + pass_rate * 100.0 + )); + } + } + } + } else if let Some(pass_rate) = measurement.average_pass_rate { + if pass_rate < target.pass_rate - input.target_mode.pass_rate_range() { + pll_frequencies = + [clamp_frequency(domain_frequency - CALI_FREQ_MHZ); 2]; + asic_notes.push(format!( + "ASIC pass rate {:.2}% below target window", + pass_rate * 100.0 + )); + } + } + } + + if domain_guarded { + asic_notes.push("bounded by domain power guard".into()); + } + if asic.active_engine_count < NOMINAL_ACTIVE_ENGINE_COUNT { + asic_notes.push(format!( + "ASIC has {} active engines and {} missing coordinates", + asic.active_engine_count, + asic.missing_engines.len() + )); + } + + asic_plans.push(Bzm2AsicPlan { + asic_id: asic.asic_id, + domain_id: asic.domain_id, + pll_frequencies_mhz: pll_frequencies, + notes: asic_notes, + }); + } + } + + if reuse_saved_operating_point { + notes.push("saved operating point is consistent with current throughput".into()); + } else if needs_retune { + notes.push( + "saved operating point is missing or underperforming; full retune required".into(), + ); + } else { + notes.push("building fresh domain-aware calibration plan".into()); + } + + if live_active_engines < total_nominal_engine_capacity(&input.asics) { + notes.push(format!( + "tuning normalized throughput against {:.1}% active engine capacity", + (live_active_engines as f32 / total_nominal_engine_capacity(&input.asics) as f32) + * 100.0 + )); + } + + if input.voltage_domains.len() > 1 { + notes.push("domain-first planning enabled for multi-domain hardware".into()); + } + if input.asics.len() >= 100 { + notes.push("planner uses one domain aggregation pass and one ASIC tuning pass".into()); + } + + Bzm2CalibrationPlan { + reuse_saved_operating_point, + needs_retune, + desired_voltage_mv: target.voltage_mv, + desired_clock_mhz: target.frequency_mhz, + desired_accept_ratio: target.pass_rate, + initial_voltage_mv, + initial_frequency_mhz, + freq_increase_threshold_mhz, + search_space, + domain_plans, + asic_plans, + notes, + } + } +} + +fn total_active_engine_count(asics: &[Bzm2AsicTopology]) -> u32 { + asics + .iter() + .filter(|asic| asic.alive) + .map(|asic| u32::from(asic.active_engine_count.max(1))) + .sum::() + .max(1) +} + +fn total_nominal_engine_capacity(asics: &[Bzm2AsicTopology]) -> u32 { + (asics.iter().filter(|asic| asic.alive).count() as u32 * u32::from(NOMINAL_ACTIVE_ENGINE_COUNT)) + .max(1) +} + +fn stored_total_active_engine_count(stored: &Bzm2SavedOperatingPoint, alive_asics: usize) -> u32 { + if stored.per_asic_engine_topology.is_empty() { + return (alive_asics as u32 * u32::from(NOMINAL_ACTIVE_ENGINE_COUNT)).max(1); + } + + stored + .per_asic_engine_topology + .values() + .map(|topology| u32::from(topology.active_engine_count.max(1))) + .sum::() + .max(1) +} + +fn normalize_throughput(throughput_ths: f32, active_engine_count: u32) -> f32 { + throughput_ths / active_engine_count.max(1) as f32 +} + +#[derive(Debug, Clone, Copy)] +struct OperatingTarget { + voltage_mv: u32, + frequency_mhz: f32, + pass_rate: f32, +} + +fn operating_targets( + operating_class: Bzm2OperatingClass, + mode: Bzm2PerformanceMode, +) -> OperatingTarget { + let (high_voltage, balanced_voltage, low_voltage, high_freq) = match operating_class { + Bzm2OperatingClass::Generic => (17_600, 17_500, 17_150, TARGET_FREQ_HIGH_MHZ), + Bzm2OperatingClass::EarlyValidation => (17_800, 17_700, 17_350, TARGET_FREQ_HIGH_MHZ), + Bzm2OperatingClass::ProductionValidation => (17_550, 17_450, 17_100, TARGET_FREQ_HIGH_MHZ), + Bzm2OperatingClass::StackTunedA => (17_300, 17_200, 16_850, TARGET_FREQ_HIGH_MHZ), + Bzm2OperatingClass::StackTunedB => (17_600, 17_500, 17_150, TARGET_FREQ_HIGH_MHZ), + Bzm2OperatingClass::ExtendedHeadroom => (17_900, 17_450, 17_100, TARGET_FREQ_HIGH_PLUS_MHZ), + Bzm2OperatingClass::ExtendedHeadroomB => { + (18_050, 17_550, 17_150, TARGET_FREQ_HIGH_PLUS_MHZ) + } + }; + + match mode { + Bzm2PerformanceMode::MaxThroughput => OperatingTarget { + voltage_mv: high_voltage, + frequency_mhz: high_freq, + pass_rate: DESIRED_ACCEPT_RATIO_MAX_THROUGHPUT, + }, + Bzm2PerformanceMode::Standard => OperatingTarget { + voltage_mv: balanced_voltage, + frequency_mhz: TARGET_FREQ_BALANCED_MHZ, + pass_rate: DESIRED_ACCEPT_RATIO_STANDARD, + }, + Bzm2PerformanceMode::Efficiency => OperatingTarget { + voltage_mv: low_voltage, + frequency_mhz: TARGET_FREQ_LOW_MHZ, + pass_rate: DESIRED_ACCEPT_RATIO_EFFICIENCY, + }, + } +} + +fn build_offsets(enabled: bool, steps: u8) -> Vec { + if !enabled { + return vec![0]; + } + + let steps = steps.min(20) as i32; + (-steps..=steps) + .map(|step| step * CALI_VOLTAGE_MV as i32) + .collect() +} + +fn build_frequency_offsets(enabled: bool, steps: u8) -> Vec { + if !enabled { + return vec![0.0]; + } + + let steps = steps.min(16) as i32; + (-steps..=steps) + .map(|step| step as f32 * CALI_FREQ_MHZ) + .collect() +} + +fn build_pass_rate_offsets(enabled: bool, steps: u8) -> Vec { + if !enabled { + return vec![0.0]; + } + + let steps = steps.min(4) as i32; + (-steps..=steps) + .map(|step| step as f32 * CALI_PASS_RATE_STEP) + .collect() +} + +fn initial_voltage_and_threshold( + desired_voltage_mv: u32, + site_temp_c: f32, + frequency_mode: bool, +) -> (u32, f32) { + let (offset, ratio) = if site_temp_c < SITE_TEMP_COLD_SOAK_C { + (STARTUP_VOLTAGE_BIAS_MV * 2, DEFAULT_FREQ_INCREASE_RATIO_LOW) + } else if site_temp_c < SITE_TEMP_COOL_C { + (STARTUP_VOLTAGE_BIAS_MV, DEFAULT_FREQ_INCREASE_RATIO_HIGH) + } else if site_temp_c < SITE_TEMP_NOMINAL_C { + (0, DEFAULT_FREQ_INCREASE_RATIO_HIGH) + } else if site_temp_c < SITE_TEMP_WARM_C { + (-STARTUP_VOLTAGE_BIAS_MV, DEFAULT_FREQ_INCREASE_RATIO_HIGH) + } else { + ( + -STARTUP_VOLTAGE_BIAS_MV * 2, + DEFAULT_FREQ_INCREASE_RATIO_LOW, + ) + }; + + let threshold = if frequency_mode { + CALI_FREQ_MHZ * DEFAULT_FREQ_INCREASE_RATIO_LOW + } else { + CALI_FREQ_MHZ * ratio + }; + + ( + clamp_voltage(apply_i32(desired_voltage_mv, offset)), + threshold, + ) +} + +fn clamp_voltage(voltage_mv: u32) -> u32 { + voltage_mv.clamp(TARGET_VOLTAGE_MIN_MV, TARGET_VOLTAGE_MAX_MV) +} + +fn clamp_frequency(frequency_mhz: f32) -> f32 { + frequency_mhz.clamp(TARGET_FREQ_MIN_MHZ, TARGET_FREQ_MAX_MHZ) +} + +fn clamp_pass_rate(pass_rate: f32) -> f32 { + pass_rate.clamp(MIN_ACCEPT_RATIO, DESIRED_ACCEPT_RATIO_MAX_THROUGHPUT) +} + +fn apply_i32(value: u32, offset: i32) -> u32 { + if offset >= 0 { + value.saturating_add(offset as u32) + } else { + value.saturating_sub(offset.unsigned_abs()) + } +} + +fn average(values: impl Iterator) -> Option { + let mut total = 0.0; + let mut count = 0usize; + for value in values { + total += value; + count += 1; + } + (count > 0).then_some(total / count as f32) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn search_space_expands_requested_axes() { + let planner = Bzm2CalibrationPlanner; + let parameters = planner.build_search_space(&Bzm2CalibrationSweepRequest { + operating_class: Bzm2OperatingClass::Generic, + target_mode: Bzm2PerformanceMode::Standard, + mode: Bzm2CalibrationMode { + sweep_strategy: true, + sweep_voltage: true, + sweep_frequency: true, + sweep_pass_rate: true, + }, + voltage_steps: 1, + frequency_steps: 1, + pass_rate_steps: 1, + }); + + assert!(parameters.len() > 20); + assert!( + parameters + .iter() + .any(|p| p.mode == Bzm2PerformanceMode::MaxThroughput) + ); + assert!(parameters.iter().any(|p| p.desired_voltage_mv < 17_500)); + assert!( + parameters + .iter() + .any(|p| p.desired_clock_mhz > TARGET_FREQ_BALANCED_MHZ) + ); + } + + #[test] + fn single_asic_plan_prefers_saved_operating_point_when_consistent() { + let planner = Bzm2CalibrationPlanner; + let mut stored = BTreeMap::new(); + stored.insert(0, [1_075.0, 1_075.0]); + let plan = planner.plan(&Bzm2BoardCalibrationInput { + operating_class: Bzm2OperatingClass::Generic, + site_temp_c: 15.0, + target_mode: Bzm2PerformanceMode::Standard, + mode: Bzm2CalibrationMode::default(), + per_stack_clocking: false, + voltage_domains: vec![Bzm2VoltageDomain { + domain_id: 0, + asic_ids: vec![0], + voltage_offset_mv: 0, + max_power_w: None, + }], + asics: vec![Bzm2AsicTopology { + asic_id: 0, + domain_id: 0, + pll_count: 2, + alive: true, + active_engine_count: NOMINAL_ACTIVE_ENGINE_COUNT, + missing_engines: Vec::new(), + }], + saved_operating_point: Some(Bzm2SavedOperatingPoint { + board_voltage_mv: 17_500, + board_throughput_ths: 42.0, + per_domain_voltage_mv: BTreeMap::new(), + per_asic_engine_topology: BTreeMap::new(), + per_asic_pll_mhz: stored, + }), + domain_measurements: vec![Bzm2DomainMeasurement { + domain_id: 0, + measured_voltage_mv: Some(17_480), + measured_power_w: Some(320.0), + }], + asic_measurements: vec![Bzm2AsicMeasurement { + asic_id: 0, + temperature_c: Some(72.0), + throughput_ths: Some(40.0), + average_pass_rate: Some(0.98), + pll_pass_rates: [Some(0.98), Some(0.98)], + }], + constraints: Bzm2CalibrationConstraints::default(), + force_retune: false, + }); + + assert!(plan.reuse_saved_operating_point); + assert!(!plan.needs_retune); + assert_eq!(plan.asic_plans[0].pll_frequencies_mhz, [1_075.0, 1_075.0]); + } + + #[test] + fn planner_requests_retune_when_throughput_drops() { + let planner = Bzm2CalibrationPlanner; + let mut stored = BTreeMap::new(); + stored.insert(0, [1_150.0, 1_150.0]); + let plan = planner.plan(&Bzm2BoardCalibrationInput { + operating_class: Bzm2OperatingClass::Generic, + site_temp_c: 20.0, + target_mode: Bzm2PerformanceMode::Standard, + mode: Bzm2CalibrationMode::default(), + per_stack_clocking: false, + voltage_domains: vec![Bzm2VoltageDomain { + domain_id: 0, + asic_ids: vec![0], + voltage_offset_mv: 0, + max_power_w: None, + }], + asics: vec![Bzm2AsicTopology { + asic_id: 0, + domain_id: 0, + pll_count: 2, + alive: true, + active_engine_count: NOMINAL_ACTIVE_ENGINE_COUNT, + missing_engines: Vec::new(), + }], + saved_operating_point: Some(Bzm2SavedOperatingPoint { + board_voltage_mv: 17_500, + board_throughput_ths: 50.0, + per_domain_voltage_mv: BTreeMap::new(), + per_asic_engine_topology: BTreeMap::new(), + per_asic_pll_mhz: stored, + }), + domain_measurements: vec![], + asic_measurements: vec![Bzm2AsicMeasurement { + asic_id: 0, + temperature_c: Some(74.0), + throughput_ths: Some(20.0), + average_pass_rate: Some(0.94), + pll_pass_rates: [Some(0.94), Some(0.94)], + }], + constraints: Bzm2CalibrationConstraints::default(), + force_retune: false, + }); + + assert!(!plan.reuse_saved_operating_point); + assert!(plan.needs_retune); + } + + #[test] + fn planner_normalizes_saved_throughput_by_active_engine_capacity() { + let planner = Bzm2CalibrationPlanner; + let mut stored = BTreeMap::new(); + stored.insert(0, [1_075.0, 1_075.0]); + let plan = planner.plan(&Bzm2BoardCalibrationInput { + operating_class: Bzm2OperatingClass::Generic, + site_temp_c: 15.0, + target_mode: Bzm2PerformanceMode::Standard, + mode: Bzm2CalibrationMode::default(), + per_stack_clocking: false, + voltage_domains: vec![Bzm2VoltageDomain { + domain_id: 0, + asic_ids: vec![0], + voltage_offset_mv: 0, + max_power_w: None, + }], + asics: vec![Bzm2AsicTopology { + asic_id: 0, + domain_id: 0, + pll_count: 2, + alive: true, + active_engine_count: NOMINAL_ACTIVE_ENGINE_COUNT / 2, + missing_engines: vec![Bzm2SavedEngineCoordinate { row: 0, col: 1 }], + }], + saved_operating_point: Some(Bzm2SavedOperatingPoint { + board_voltage_mv: 17_500, + board_throughput_ths: 42.0, + per_domain_voltage_mv: BTreeMap::new(), + per_asic_engine_topology: BTreeMap::from([( + 0, + Bzm2SavedEngineTopology { + active_engine_count: NOMINAL_ACTIVE_ENGINE_COUNT, + missing_engines: Vec::new(), + }, + )]), + per_asic_pll_mhz: stored, + }), + domain_measurements: vec![], + asic_measurements: vec![Bzm2AsicMeasurement { + asic_id: 0, + temperature_c: Some(70.0), + throughput_ths: Some(21.0), + average_pass_rate: Some(0.98), + pll_pass_rates: [Some(0.98), Some(0.98)], + }], + constraints: Bzm2CalibrationConstraints::default(), + force_retune: false, + }); + + assert!(plan.reuse_saved_operating_point); + assert!(!plan.needs_retune); + assert!( + plan.notes + .iter() + .any(|note| note.contains("active engine capacity")) + ); + } + + #[test] + fn multi_domain_plan_scales_to_large_topology() { + let planner = Bzm2CalibrationPlanner; + let domains: Vec = (0..25) + .map(|domain_id| Bzm2VoltageDomain { + domain_id, + asic_ids: (0..4).map(|offset| domain_id * 4 + offset).collect(), + voltage_offset_mv: if domain_id % 2 == 0 { 0 } else { 25 }, + max_power_w: Some(450.0), + }) + .collect(); + let asics: Vec = (0..100) + .map(|asic_id| Bzm2AsicTopology { + asic_id, + domain_id: asic_id / 4, + pll_count: 2, + alive: true, + active_engine_count: NOMINAL_ACTIVE_ENGINE_COUNT, + missing_engines: Vec::new(), + }) + .collect(); + let domain_measurements: Vec = (0..25) + .map(|domain_id| Bzm2DomainMeasurement { + domain_id, + measured_voltage_mv: Some(17_450), + measured_power_w: Some(if domain_id == 3 { 500.0 } else { 300.0 }), + }) + .collect(); + let asic_measurements: Vec = (0..100) + .map(|asic_id| Bzm2AsicMeasurement { + asic_id, + temperature_c: Some(if asic_id == 13 { 101.0 } else { 74.0 }), + throughput_ths: Some(0.4), + average_pass_rate: Some(if asic_id % 9 == 0 { 0.93 } else { 0.98 }), + pll_pass_rates: [Some(0.97), Some(0.98)], + }) + .collect(); + + let plan = planner.plan(&Bzm2BoardCalibrationInput { + operating_class: Bzm2OperatingClass::ExtendedHeadroom, + site_temp_c: 10.0, + target_mode: Bzm2PerformanceMode::Standard, + mode: Bzm2CalibrationMode::default(), + per_stack_clocking: true, + voltage_domains: domains, + asics, + saved_operating_point: None, + domain_measurements, + asic_measurements, + constraints: Bzm2CalibrationConstraints::default(), + force_retune: false, + }); + + assert_eq!(plan.domain_plans.len(), 25); + assert_eq!(plan.asic_plans.len(), 100); + assert!( + plan.domain_plans + .iter() + .find(|domain| domain.domain_id == 3) + .unwrap() + .guarded + ); + assert!( + plan.asic_plans + .iter() + .find(|asic| asic.asic_id == 13) + .unwrap() + .pll_frequencies_mhz[0] + < plan.initial_frequency_mhz + 1.0 + ); + assert!(plan.notes.iter().any(|note| note.contains("domain-first"))); + } +} diff --git a/mujina-miner/src/tuning/mod.rs b/mujina-miner/src/tuning/mod.rs new file mode 100644 index 00000000..d26352aa --- /dev/null +++ b/mujina-miner/src/tuning/mod.rs @@ -0,0 +1 @@ +pub mod blockscale; From 5444d5007e61320b54b3d6ea3e8538741db11d15 Mon Sep 17 00:00:00 2001 From: Reckless Apotheosis <125509978+recklessnode@users.noreply.github.com> Date: Wed, 18 Mar 2026 19:31:37 -0700 Subject: [PATCH 18/24] refactor(board): move rail and reset sequencing out of the ASIC module --- docs/bzm2/blockscale-reference-roadmap.md | 5 +- docs/bzm2/bzm2-port.md | 5 +- mujina-miner/src/asic/bzm2/mod.rs | 5 - mujina-miner/src/board/bzm2.rs | 17 +- mujina-miner/src/board/mod.rs | 1 + mujina-miner/src/board/power.rs | 425 ++++++++++++++++++++++ 6 files changed, 442 insertions(+), 16 deletions(-) create mode 100644 mujina-miner/src/board/power.rs diff --git a/docs/bzm2/blockscale-reference-roadmap.md b/docs/bzm2/blockscale-reference-roadmap.md index a7749971..3e875fce 100644 --- a/docs/bzm2/blockscale-reference-roadmap.md +++ b/docs/bzm2/blockscale-reference-roadmap.md @@ -89,7 +89,7 @@ Objective: Deliverables: -1. Wire `Bzm2BringupPlan` into `Bzm2Board` +1. Wire `VoltageStackBringupPlan` into `Bzm2Board` 2. Add a concrete board-facing rail bundle abstraction: - one or more rails - optional reset line @@ -99,7 +99,7 @@ Deliverables: Status: -- completed: `Bzm2BringupPlan` is now wired into `Bzm2Board` startup and +- completed: `VoltageStackBringupPlan` is now wired into `Bzm2Board` startup and shutdown through generic file-backed rail and reset adapters - completed: optional file-backed rail telemetry now flows into `BoardState` - next: map planned domain voltages onto those startup/shutdown hooks @@ -330,3 +330,4 @@ Reason: - enumeration removes a major assumption from the current board runtime - it is ASIC-generic - it is directly grounded in documented and legacy UART behavior + diff --git a/docs/bzm2/bzm2-port.md b/docs/bzm2/bzm2-port.md index 3d2d99c0..3bc25914 100644 --- a/docs/bzm2/bzm2-port.md +++ b/docs/bzm2/bzm2-port.md @@ -15,14 +15,14 @@ In Mujina, those responsibilities map cleanly onto existing abstractions: - `Backplane` instantiates the virtual board through `inventory` - `board::bzm2::Bzm2Board` opens serial transports and creates hash threads - `asic::bzm2::Bzm2Thread` performs direct UART job dispatch, telemetry parsing, and share validation -- `asic::bzm2::control` provides reusable GPIO-reset and PMBus/I2C rail sequencing primitives +- `board::power` provides reusable GPIO-reset and PMBus/I2C rail sequencing primitives A standalone Rust daemon is therefore not required for the mining path. ## Bring-Up And Shutdown `Bzm2Board` now supports optional board-level power and reset sequencing through -the existing `Bzm2BringupPlan`. +the existing `VoltageStackBringupPlan`. The current generic integration path uses file-backed adapters: @@ -369,3 +369,4 @@ This port currently implements the opcode surface that is evidenced in the legac See also: - [bzm2-opcode-grounding.md](bzm2-opcode-grounding.md) for the source-grounded opcode matrix and the current JTAG evidence boundary + diff --git a/mujina-miner/src/asic/bzm2/mod.rs b/mujina-miner/src/asic/bzm2/mod.rs index 258e11f0..8c606d91 100644 --- a/mujina-miner/src/asic/bzm2/mod.rs +++ b/mujina-miner/src/asic/bzm2/mod.rs @@ -1,5 +1,4 @@ pub mod clock; -pub mod control; pub mod protocol; pub mod thread; pub mod uart; @@ -8,10 +7,6 @@ pub use clock::{ Bzm2ClockController, Bzm2ClockDebugReport, Bzm2ClockError, Bzm2Dll, Bzm2DllConfig, Bzm2DllStatus, Bzm2Pll, Bzm2PllConfig, Bzm2PllStatus, }; -pub use control::{ - Bzm2BringupPlan, FileGpioPin, FilePowerRail, GpioResetLine, PowerRailTelemetry, - Tps546PowerRail, VoltageStackStep, -}; pub use protocol::Bzm2EngineLayout; pub use thread::{ Bzm2AsicRuntimeMetrics, Bzm2PllRuntimeMetrics, Bzm2Thread, Bzm2ThreadConfig, Bzm2ThreadHandle, diff --git a/mujina-miner/src/board/bzm2.rs b/mujina-miner/src/board/bzm2.rs index 81014df8..03e20dc1 100644 --- a/mujina-miner/src/board/bzm2.rs +++ b/mujina-miner/src/board/bzm2.rs @@ -18,15 +18,18 @@ use crate::{ }, asic::{ bzm2::{ - Bzm2BringupPlan, Bzm2ClockController, Bzm2DiscoveredEngineMap, Bzm2Pll, Bzm2Thread, - Bzm2ThreadConfig, Bzm2ThreadHandle, Bzm2ThreadRuntimeMetrics, Bzm2UartController, - FileGpioPin, FilePowerRail, GpioResetLine, VoltageStackStep, control::Bzm2PowerRail, + Bzm2ClockController, Bzm2DiscoveredEngineMap, Bzm2Pll, Bzm2Thread, Bzm2ThreadConfig, + Bzm2ThreadHandle, Bzm2ThreadRuntimeMetrics, Bzm2UartController, }, hash_thread::{ HashTask, HashThread, HashThreadCapabilities, HashThreadError, HashThreadEvent, HashThreadStatus, HashThreadTelemetryUpdate, }, }, + board::power::{ + FileGpioPin, FilePowerRail, GpioResetLine, PowerRail, VoltageStackBringupPlan, + VoltageStackStep, + }, tracing::prelude::*, transport::{SerialControl, SerialStream}, tuning::blockscale::{ @@ -215,7 +218,7 @@ pub struct Bzm2BringupConfig { pub rail_temperature: Vec, pub reset_path: Option, pub reset_active_low: bool, - pub plan: Bzm2BringupPlan, + pub plan: VoltageStackBringupPlan, } impl Default for Bzm2BringupConfig { @@ -234,7 +237,7 @@ impl Default for Bzm2BringupConfig { rail_temperature: Vec::new(), reset_path: None, reset_active_low: true, - plan: Bzm2BringupPlan { + plan: VoltageStackBringupPlan { pre_power_delay: Duration::from_millis(DEFAULT_BRINGUP_PRE_POWER_MS), post_power_delay: Duration::from_millis(DEFAULT_BRINGUP_POST_POWER_MS), release_reset_delay: Duration::from_millis(DEFAULT_BRINGUP_RELEASE_RESET_MS), @@ -297,7 +300,7 @@ impl Bzm2BringupConfig { || !rail_set_paths.is_empty() || reset_path.is_some(); - let mut plan = Bzm2BringupPlan { + let mut plan = VoltageStackBringupPlan { assert_reset_before_power: env_flag_default_any( &["MUJINA_BZM2_ASSERT_RESET_BEFORE_POWER"], true, @@ -3362,7 +3365,7 @@ mod tests { rail_temperature: Vec::new(), reset_path: Some(reset_path.to_string_lossy().into_owned()), reset_active_low: true, - plan: Bzm2BringupPlan { + plan: VoltageStackBringupPlan { pre_power_delay: Duration::ZERO, post_power_delay: Duration::ZERO, release_reset_delay: Duration::ZERO, diff --git a/mujina-miner/src/board/mod.rs b/mujina-miner/src/board/mod.rs index 67ce6626..b25bc1cf 100644 --- a/mujina-miner/src/board/mod.rs +++ b/mujina-miner/src/board/mod.rs @@ -3,6 +3,7 @@ pub(crate) mod bzm2; pub mod cpu; pub(crate) mod emberone; pub mod pattern; +pub mod power; use anyhow::Result; use async_trait::async_trait; diff --git a/mujina-miner/src/board/power.rs b/mujina-miner/src/board/power.rs new file mode 100644 index 00000000..e46df5d7 --- /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.as_deref_mut() { + reset_line.enable().await?; + sleep(self.release_reset_delay).await; + } + + Ok(()) + } + + pub async fn shutdown( + &self, + rails: &mut [R], + mut reset_line: Option<&mut GpioResetLine>, + ) -> Result<()> + where + R: PowerRail, + PIN: GpioPin, + { + if let Some(reset_line) = reset_line.as_deref_mut() { + 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]); + } +} From a66cbde61e760b5d91c1c9f9b52c56a6d106a898 Mon Sep 17 00:00:00 2001 From: Reckless Apotheosis <125509978+recklessnode@users.noreply.github.com> Date: Wed, 18 Mar 2026 19:38:57 -0700 Subject: [PATCH 19/24] refactor(bzm2): drop upstream-only debug and virtual transport layers --- README.md | 20 +- .../bzm2/blockscale-asic-integration-guide.md | 3 +- .../blockscale-uart-protocol-reference.md | 19 +- docs/bzm2/bzm2-port.md | 16 +- docs/bzm2/bzm2-uart-debug.md | 449 ------ mujina-miner/Cargo.toml | 5 - mujina-miner/src/backplane.rs | 141 +- mujina-miner/src/bin/bzm2-debug.rs | 1391 ----------------- mujina-miner/src/board/bzm2.rs | 24 +- mujina-miner/src/daemon.rs | 33 +- mujina-miner/src/transport/mod.rs | 5 - mujina-miner/src/transport/virtual_device.rs | 23 - 12 files changed, 101 insertions(+), 2028 deletions(-) delete mode 100644 docs/bzm2/bzm2-uart-debug.md delete mode 100644 mujina-miner/src/bin/bzm2-debug.rs delete mode 100644 mujina-miner/src/transport/virtual_device.rs diff --git a/README.md b/README.md index 979352da..8eda4736 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ reviving the original split `cgminer` plus `bzmd` process model. and a single [Bitaxe](mujina-miner/src/board/bitaxe_gamma.md) board is enough to contribute meaningfully - **BZM2 Port In Progress**: Native Rust BZM2 support with direct UART work - dispatch, result parsing, telemetry, debug tooling, and startup tuning flows + dispatch, result parsing, telemetry, API diagnostics, and startup tuning flows ## Supported Hardware @@ -53,8 +53,8 @@ Experimental support in this repository: - direct UART mining path - PLL and DLL diagnostics - DTS/VS telemetry through the API - - on-demand DTS/VS query support through CLI and HTTP API - - silicon-validation helpers adapted from the legacy silicon validation stack + - on-demand DTS/VS query support through the HTTP API + - board/API diagnostics for chain state, register access, and clock reporting Planned support: - **EmberOne** with BM1362 ASIC @@ -75,8 +75,6 @@ ASIC path. - [REST API](docs/api.md) - API contract, conventions, and endpoints - [BZM2 Port Note](docs/bzm2/bzm2-port.md) - Architecture, implemented behavior, telemetry, and current scope boundaries for the BZM2 port -- [BZM2 UART Debug Guide](docs/bzm2/bzm2-uart-debug.md) - CLI usage for UART, - telemetry queries, TDM observation, clock diagnostics, and validation flows - [BZM2 Tuning Planner](docs/bzm2/bzm2-pnp.md) - Tuning-planner behavior and current calibration scope - [BZM2 Opcode Grounding](docs/bzm2/bzm2-opcode-grounding.md) - Source-grounded UART @@ -171,25 +169,17 @@ Enable the BZM2 path by pointing Mujina at one or more serial devices: MUJINA_BZM2_SERIAL="/dev/ttyUSB0" \ MUJINA_BZM2_BAUD="5000000" \ MUJINA_BZM2_DTS_VS_GEN="2" \ -cargo run -p mujina-miner --bin minerd +cargo run -p mujina-miner --bin mujina-minerd ``` -Useful companion tooling: +Query refreshed ASIC telemetry over HTTP: ```bash -# Query one ASIC's DTS/VS telemetry directly -cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ - dts-vs-query /dev/ttyUSB0 2 gen2 1500 5000000 - -# Read refreshed board telemetry over HTTP curl -X POST http://127.0.0.1:7785/api/v0/boards/bzm2-0/bzm2/dts-vs-query \ -H "Content-Type: application/json" \ -d '{"thread_index":0,"asic":2}' ``` -See [BZM2 UART Debug Guide](docs/bzm2/bzm2-uart-debug.md) for the full command -surface. - ### API Server The REST API listens on `127.0.0.1:7785` by default. To listen diff --git a/docs/bzm2/blockscale-asic-integration-guide.md b/docs/bzm2/blockscale-asic-integration-guide.md index ee460c50..867cf428 100644 --- a/docs/bzm2/blockscale-asic-integration-guide.md +++ b/docs/bzm2/blockscale-asic-integration-guide.md @@ -536,11 +536,10 @@ ASIC behavior discussed above: - DTS/VS telemetry - on-demand sensor query support - startup tuning and saved operating-point replay -- silicon-validation CLI flows +- board and API diagnostics for low-level validation Relevant follow-on documents: - [UART and TDM Reference](blockscale-uart-protocol-reference.md) - [BZM2 Port Note](bzm2-port.md) -- [BZM2 UART Debug Guide](bzm2-uart-debug.md) - [BZM2 Tuning Planner](bzm2-pnp.md) diff --git a/docs/bzm2/blockscale-uart-protocol-reference.md b/docs/bzm2/blockscale-uart-protocol-reference.md index 517dd93d..1591d806 100644 --- a/docs/bzm2/blockscale-uart-protocol-reference.md +++ b/docs/bzm2/blockscale-uart-protocol-reference.md @@ -351,8 +351,8 @@ packet class in TDM operation. `DTS_VS` can also be used as a direct query opcode when explicit on-demand sensor retrieval is needed. -This is the model now exposed in the Rust debug CLI and HTTP API in this -repository. +This is the model now exposed through the runtime thread path and HTTP API in +this repository. ## DTS / VS Payload Layout @@ -433,16 +433,11 @@ That is also how the Rust tooling in this repository is structured. Relevant follow-on references in this repository: - [ASIC Integration Guide](blockscale-asic-integration-guide.md) -- [BZM2 UART Debug Guide](bzm2-uart-debug.md) - [BZM2 Port Note](bzm2-port.md) -The Rust debug CLI already exposes: +The Rust implementation already exposes: -- `NOOP` scans -- loopback scans -- TDM watch mode -- direct `DTS_VS` queries -- result polling -- PLL and DLL diagnostics -- broadcast and multicast helpers -- silicon-validation flows +- board and API diagnostics for `NOOP`, loopback, register reads and writes, + and clock reporting +- direct `DTS_VS` telemetry query through the board API +- result parsing and engine-map discovery through the runtime thread path diff --git a/docs/bzm2/bzm2-port.md b/docs/bzm2/bzm2-port.md index 3bc25914..68209a93 100644 --- a/docs/bzm2/bzm2-port.md +++ b/docs/bzm2/bzm2-port.md @@ -11,8 +11,8 @@ The legacy split looked like this: In Mujina, those responsibilities map cleanly onto existing abstractions: -- `Daemon` injects a virtual `bzm2` board when configured -- `Backplane` instantiates the virtual board through `inventory` +- `Daemon` can attach a configured `bzm2` board directly from serial-path configuration +- `Backplane` instantiates the board through the virtual-board registry - `board::bzm2::Bzm2Board` opens serial transports and creates hash threads - `asic::bzm2::Bzm2Thread` performs direct UART job dispatch, telemetry parsing, and share validation - `board::power` provides reusable GPIO-reset and PMBus/I2C rail sequencing primitives @@ -60,7 +60,6 @@ The BZM2 Mujina thread now reimplements the core legacy data path and the genera - reusable multi-rail bring-up and shutdown sequencing for single-rail, small-stack, and larger multi-stack designs - UART-register-based PLL diagnostic/control flow for divider programming, enable/disable, lock polling, and readback - UART-register-based DLL diagnostic/control flow for duty-cycle programming, enable/disable, lock polling, and fincon validation -- developer-facing UART debug CLI documented in [bzm2-uart-debug.md](bzm2-uart-debug.md) with unicast, multicast, and broadcast examples - domain-aware BZM2 tuning planner documented in [bzm2-pnp.md](bzm2-pnp.md) for operating-class and performance-mode target selection, search-space generation, and per-domain plus per-ASIC tuning ## Configuration @@ -208,10 +207,9 @@ This is useful when: - one ASIC is misbehaving and needs targeted inspection - developers want a direct sensor read without enabling a full TDM watch session -Two access paths are implemented: +The query path is exposed through the HTTP API: -- CLI: `mujina-bzm2-debug dts-vs-query` and `mujina-bzm2-debug dts-vs-scan` -- HTTP API: `POST /api/v0/boards/{name}/bzm2/dts-vs-query` +- `POST /api/v0/boards/{name}/bzm2/dts-vs-query` The query path runs through the live BZM2 hash-thread actor so UART ownership remains correct. Queried frames are converted through the same telemetry code path used for passive DTS/VS reporting, so the returned values land in normal `BoardState` telemetry. @@ -223,8 +221,6 @@ curl -X POST http://127.0.0.1:3000/api/v0/boards/bzm2-0/bzm2/dts-vs-query \ -d '{"thread_index":0,"asic":2}' ``` -See [bzm2-uart-debug.md](bzm2-uart-debug.md) for CLI usage examples and expected output shape. - ## On-Demand Engine Discovery Mujina also supports explicit per-ASIC engine-map discovery when the thread is @@ -290,8 +286,8 @@ Current safety boundary: If either condition is false, the command is rejected rather than racing active mining traffic or background telemetry frames. -`clock-report` returns the same PLL/DLL status surface already exposed by the -standalone Rust debug CLI: +`clock-report` returns the same PLL/DLL status surface used by the low-level +UART diagnostics during development: - PLL enable register - PLL misc register diff --git a/docs/bzm2/bzm2-uart-debug.md b/docs/bzm2/bzm2-uart-debug.md deleted file mode 100644 index ff8696e3..00000000 --- a/docs/bzm2/bzm2-uart-debug.md +++ /dev/null @@ -1,449 +0,0 @@ -# BZM2 UART Debug Guide - -This guide documents the direct BZM2 UART and silicon-validation interface added to Mujina. - -## Intent - -The current CLI folds in the portable parts of the legacy silicon validation surface: - -- chain enumeration and `ASIC_ID` assignment from the default bus id -- ASIC discovery and liveness scans -- loopback data-path validation -- explicit TDM enable and disable control -- live TDM result, register, noop, and DTS/VS observation -- broadcast TDM register-read observation across many ASICs -- engine presence probing and full physical engine-map discovery -- engine-wide timestamp, target, and leading-zero programming -- deterministic grid-job exercisers for single-phase and back-to-back job dispatch -- existing PLL and DLL diagnostics and bring-up helpers - -What is deliberately not ported here: - -- legacy board-reset and board-count orchestration -- BCH-vector and nonce-golden-data regression harnesses -- opaque manufacturing hooks and carrier-specific glue - -## Routing Modes - -- unicast: one ASIC, one destination address -- broadcast: all ASICs on the UART bus via ASIC id `0xff` -- multicast: one ASIC, one engine-row group - -## Binary - -Use [bzm2-debug.rs](../mujina-miner/src/bin/bzm2-debug.rs) through Cargo: - -```text -cargo run -p mujina-miner --bin mujina-bzm2-debug -- ... -``` - -## Legacy Test Mapping - -The most useful silicon validation tests map to the following commands: - -- `test_noop_all_asic` -> `noop-scan` -- `test_loopback` -> `loopback-scan` -- `test_effbst_tdm_selectable_leadingzeros` -> `engine-zeros-all` plus `job-grid-watch` -- `test_effbst_tdm_jobsubmit_with_writejobcmd` -> `job-grid` or `job-grid-watch` -- `test_effbst_tdm_continuous_b2b_jobs` -> `job-grid-2phase-watch` -- `uart_command_broadcast_readreg_tdm_async` flows -> `tdm-broadcast-read-watch` -- general TDM callback validation -> `tdm-watch` -- historical engine-detect flow -> `engine-probe` or `discover-engine-map` - -The old multicasted-EFFBST and auto-clock-gating tests depended on the legacy BCH vector library and runtime board harness. The new CLI does not claim golden nonce validation there; it provides deterministic packet generation and live observation so developers can still exercise the same ASIC paths when debugging hardware. - -## Direct UART Examples - -Read one ASIC-local register: - -```text -cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ - uart-read /dev/ttyUSB0 2 notch 0x12 4 5000000 -``` - -Write one ASIC-local register: - -```text -cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ - uart-write /dev/ttyUSB0 2 notch 0x12 01000000 5000000 -``` - -Run a NOOP sanity check across an ASIC range: - -```text -cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ - noop-scan /dev/ttyUSB0 0 15 5000000 -``` - -Enumerate a powered chain from the default `ASIC_ID` (`0xFA`) and assign -incrementing ids starting at `0`: - -```text -cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ - enumerate-chain /dev/ttyUSB0 32 0 5000000 -``` - -This is the first generic bring-up step for a fresh chain where every ASIC is -still on the default id. - -The chain walk now uses a bounded `NOOP` probe internally, so the command stops -cleanly when no additional default-id ASIC is present instead of hanging at the -end of the bus. - -The same discovery flow can be enabled during Mujina board startup with: - -- `MUJINA_BZM2_ENUMERATE_CHAIN=true` -- optional `MUJINA_BZM2_ENUM_START_ID` -- optional `MUJINA_BZM2_ENUM_MAX_ASICS_PER_BUS` - -Run deterministic loopback validation across an ASIC range: - -```text -cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ - loopback-scan /dev/ttyUSB0 0 15 8 5000000 -``` - -Read one synchronous result frame from one ASIC: - -```text -cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ - uart-read-result /dev/ttyUSB0 2 gen2 5000000 -``` - -## API Visibility - -Passive Gen2 `DTS_VS` traffic is now reflected in the board API as named ASIC telemetry. - -Expected sensor names: - -- temperature: `ttyUSB0-asic-2-dts` -- voltage: `ttyUSB0-asic-2-vs-ch0`, `ttyUSB0-asic-2-vs-ch1`, `ttyUSB0-asic-2-vs-ch2` - -This is board-state telemetry, not a separate debug-only channel. If the miner is already receiving `DTS_VS` frames during operation, these entries appear in the normal board JSON under: - -- `temperatures` -- `powers` - -Use `tdm-watch` when you need to correlate the raw UART frames with the API-visible values. - -## On-Demand DTS/VS Queries - -Passive telemetry is useful when the miner is already receiving `DTS_VS` traffic. When hardware is misbehaving, you often need an explicit query path that forces a fresh ASIC sensor read. - -The debug CLI now supports direct on-demand DTS/VS reads: - -Query one ASIC: - -```text -cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ - dts-vs-query /dev/ttyUSB0 2 gen2 1500 5000000 -``` - -Scan an ASIC range and print each returned DTS/VS frame: - -```text -cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ - dts-vs-scan /dev/ttyUSB0 0 15 gen2 1500 5000000 -``` - -These commands: - -- enable the DTS/VS path if it is not already configured -- wait for a matching DTS/VS frame from the requested ASIC -- print temperature and voltage information in engineering units -- preserve the normal passive telemetry path so the same readings can still appear in board state later - -The `dts-gen` argument should match the ASIC telemetry generation in use: - -- `gen1` -- `gen2` - -Typical output includes: - -- ASIC id -- thermal status and trip bits -- Celsius temperature when available from the generation-specific decode path -- voltage channels in volts - -## HTTP API Query Example - -Mujina also exposes the same operation through the board API for live boards: - -```text -POST /api/v0/boards/{name}/bzm2/dts-vs-query -``` - -Example: - -```bash -curl -X POST http://127.0.0.1:3000/api/v0/boards/bzm2-0/bzm2/dts-vs-query \ - -H "Content-Type: application/json" \ - -d '{"thread_index":0,"asic":2}' -``` - -The response is the refreshed board JSON after the query completes. The relevant values appear in the normal board-state telemetry collections, for example: - -```json -{ - "temperatures": [ - { "name": "ttyUSB0-asic-2-dts", "temperature_c": 64.5 } - ], - "powers": [ - { "name": "ttyUSB0-asic-2-vs-ch0", "voltage_v": 0.78, "current_a": null, "power_w": null } - ] -} -``` - -Request fields: - -- `thread_index`: which BZM2 hash thread owns the target UART bus -- `asic`: ASIC id to query on that bus - -## HTTP API Diagnostics - -Mujina now exposes a first board/API parity slice for live UART diagnostics -without dropping to a separate serial tool. These operations route through the -live BZM2 thread actor, so they preserve UART ownership and operate against the -same board instance visible in the normal API. - -Available endpoints: - -- `POST /api/v0/boards/{name}/bzm2/noop` -- `POST /api/v0/boards/{name}/bzm2/loopback` -- `POST /api/v0/boards/{name}/bzm2/register-read` -- `POST /api/v0/boards/{name}/bzm2/register-write` -- `POST /api/v0/boards/{name}/bzm2/clock-report` - -Examples: - -```bash -curl -X POST http://127.0.0.1:3000/api/v0/boards/bzm2-0/bzm2/noop \ - -H "Content-Type: application/json" \ - -d '{"thread_index":0,"asic":2}' -``` - -```bash -curl -X POST http://127.0.0.1:3000/api/v0/boards/bzm2-0/bzm2/loopback \ - -H "Content-Type: application/json" \ - -d '{"thread_index":0,"asic":2,"payload_hex":"0102aabb"}' -``` - -```bash -curl -X POST http://127.0.0.1:3000/api/v0/boards/bzm2-0/bzm2/register-read \ - -H "Content-Type: application/json" \ - -d '{"thread_index":0,"asic":2,"engine_address":4095,"offset":18,"count":4}' -``` - -```bash -curl -X POST http://127.0.0.1:3000/api/v0/boards/bzm2-0/bzm2/register-write \ - -H "Content-Type: application/json" \ - -d '{"thread_index":0,"asic":2,"engine_address":4095,"offset":18,"value_hex":"deadbeef"}' -``` - -```bash -curl -X POST http://127.0.0.1:3000/api/v0/boards/bzm2-0/bzm2/clock-report \ - -H "Content-Type: application/json" \ - -d '{"thread_index":0,"asic":2}' -``` - -Representative responses: - -```json -{ "payload_hex": "425a32" } -``` - -```json -{ "payload_hex": "0102aabb" } -``` - -```json -{ "value_hex": "11223344" } -``` - -```json -{ "bytes_written": 4 } -``` - -Current safety boundary: - -- these live UART diagnostics require the target BZM2 thread to be idle -- they also require DTS/VS streaming to be inactive on that thread -- if those conditions are not met, the board command returns an error instead - of competing with active mining or background telemetry traffic - -## TDM Control and Observation - -The legacy `enable_tdm()` path is grounded in a local-register write to `LOCAL_REG_UART_TDM_CTL` (`0x07`) with control word: - -```text -(prediv_raw << 9) | (tdm_counter << 1) | enable_bit -``` - -Enable TDM explicitly: - -```text -cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ - tdm-enable /dev/ttyUSB0 0x12 16 5000000 -``` - -Disable TDM explicitly: - -```text -cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ - tdm-disable /dev/ttyUSB0 0x12 16 5000000 -``` - -Watch the mixed TDM stream for five seconds: - -```text -cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ - tdm-watch /dev/ttyUSB0 gen2 5 5000000 -``` - -Broadcast a register read and collect returned TDM read frames from ASICs `0` through `15`: - -```text -cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ - tdm-broadcast-read-watch /dev/ttyUSB0 gen2 0 15 notch 0x12 4 5000000 -``` - -Probe one physical engine coordinate using the historical TDM-sync -`ENGINE_REG_END_NONCE` detection rule: - -```text -cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ - engine-probe /dev/ttyUSB0 2 0 0 0x0f 16 100 5000000 -``` - -Scan the full `20 x 12` physical grid and report the discovered hole map for -one ASIC: - -```text -cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ - discover-engine-map /dev/ttyUSB0 2 0x0f 16 100 5000000 -``` - -Notes: - -- these commands intentionally scan all physical coordinates, not the legacy - default engine list -- discovery uses the source-grounded rule from the historical C path: - `ENGINE_REG_END_NONCE == 0xfffffffe` means the engine is present -- the CLI reports whether the discovered missing coordinates still match the - default four-hole BZM2 map or whether the ASIC diverges from that assumption - -## Engine-Wide Programming Helpers - -Set the target register across every engine row on one ASIC or the whole bus: - -```text -cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ - engine-target-all /dev/ttyUSB0 2 0x1705ffff 5000000 -``` - -Set timestamp count across all engine rows: - -```text -cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ - engine-timestamp-all /dev/ttyUSB0 2 60 5000000 -``` - -Program selectable leading zeros across all engine rows: - -```text -cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ - engine-zeros-all /dev/ttyUSB0 2 48 5000000 -``` - -`engine-zeros-all` accepts `32..=64`, matching the legacy validation flow. The actual register value written is `zeros_to_find - 32`, which is what the C source used. - -## Job Exercisers - -These commands intentionally generate deterministic synthetic job material. They are designed to exercise the ASIC job path and TDM-result machinery without pretending to replace the legacy golden-vector regression harness. - -Dispatch one full grid of `WRITEJOB` packets: - -```text -cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ - job-grid /dev/ttyUSB0 2 0 1700000000 60 5000000 -``` - -Dispatch one full grid and watch live TDM responses: - -```text -cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ - job-grid-watch /dev/ttyUSB0 2 0 1700000000 60 5 gen2 5000000 -``` - -Dispatch back-to-back grids with the second phase offset by the legacy `MAX_EFFBST_SUBJOBS` sequence spacing: - -```text -cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ - job-grid-2phase-watch /dev/ttyUSB0 2 0 1700000000 60 5 gen2 5000000 -``` - -## Broadcast, Multicast, and Unicast Reference - -Use unicast when you need one ASIC-local or one engine-local change: - -```text -cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ - uart-write /dev/ttyUSB0 2 notch 0x12 01000000 5000000 -``` - -Use broadcast when every ASIC on the bus should receive the same packet: - -```text -cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ - uart-write /dev/ttyUSB0 broadcast notch 0x12 01000000 5000000 -``` - -Use multicast when one ASIC needs the same engine-register update across a full row group: - -```text -cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ - uart-multicast-write /dev/ttyUSB0 2 7 0x48 3c 5000000 -``` - -The higher-level helpers such as `engine-timestamp-all`, `engine-zeros-all`, and `job-grid` are implemented on top of this same routing model so developers can either stay at the helper level or drop down to raw packet control when needed. - -## Clock Diagnostics - -Read PLL and DLL status for one ASIC: - -```text -cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ - clock-report /dev/ttyUSB0 2 5000000 -``` - -Program and lock one PLL on one ASIC: - -```text -cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ - pll-set /dev/ttyUSB0 2 pll1 625 0 5000000 -``` - -Program, enable, and validate one DLL on one ASIC: - -```text -cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ - dll-set /dev/ttyUSB0 2 dll1 55 5000000 -``` - -Broadcast one PLL program and verify lock across ASIC ids `0` through `15`: - -```text -cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ - pll-broadcast-lock-check /dev/ttyUSB0 pll0 625 0 0 15 5000000 -``` - -Broadcast one DLL program and verify lock and `fincon` validity across ASIC ids `0` through `15`: - -```text -cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ - dll-broadcast-lock-check /dev/ttyUSB0 dll0 50 0 15 5000000 -``` - -## Scope Boundary - -This interface is grounded in the legacy shipped UART path and the silicon validation source. It is not a JTAG debug interface and it is not a full BCH-vector regression harness. diff --git a/mujina-miner/Cargo.toml b/mujina-miner/Cargo.toml index 78db80f9..241f687c 100644 --- a/mujina-miner/Cargo.toml +++ b/mujina-miner/Cargo.toml @@ -64,11 +64,6 @@ path = "src/bin/cli.rs" [[bin]] name = "mujina-tui" path = "src/bin/tui.rs" - - -[[bin]] -name = "mujina-bzm2-debug" -path = "src/bin/bzm2-debug.rs" [features] default = [] skip-pty-tests = [] # Skip PTY-based serial tests that may hang in some environments diff --git a/mujina-miner/src/backplane.rs b/mujina-miner/src/backplane.rs index b9bbcb26..6a7994ba 100644 --- a/mujina-miner/src/backplane.rs +++ b/mujina-miner/src/backplane.rs @@ -16,7 +16,6 @@ use crate::{ transport::{ TransportEvent, UsbDeviceInfo, cpu::TransportEvent as CpuTransportEvent, usb::TransportEvent as UsbTransportEvent, - virtual_device::TransportEvent as VirtualTransportEvent, }, }; @@ -82,9 +81,6 @@ impl Backplane { TransportEvent::Cpu(cpu_event) => { self.handle_cpu_event(cpu_event).await?; } - TransportEvent::Virtual(virtual_event) => { - self.handle_virtual_event(virtual_event).await?; - } } } @@ -305,98 +301,77 @@ impl Backplane { Ok(()) } - /// Handle generic virtual device transport events. - async fn handle_virtual_event(&mut self, event: VirtualTransportEvent) -> Result<()> { - match event { - VirtualTransportEvent::VirtualDeviceConnected(device_info) => { - let Some(descriptor) = self.virtual_registry.find(&device_info.device_type) else { - error!( - device_type = %device_info.device_type, - "No virtual board descriptor found" - ); - return Ok(()); - }; - - info!( + /// 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 (mut board, registration) = match (descriptor.create_fn)().await { + Ok(result) => result, + Err(e) => { + error!( board = descriptor.name, - device_type = %device_info.device_type, - device_id = %device_info.device_id, - "Virtual board connected." + device_type = %device_type, + error = %e, + "Failed to create configured board" ); + return Ok(()); + } + }; - let (mut board, registration) = match (descriptor.create_fn)().await { - Ok(result) => result, - Err(e) => { - error!( - board = descriptor.name, - error = %e, - "Failed to create virtual board" - ); - return Ok(()); - } - }; - - let board_info = board.board_info(); - let board_id = device_info.device_id.clone(); - - if let Err(e) = self.board_reg_tx.send(registration).await { - error!( - board = %board_info.model, - error = %e, - "Failed to register board with API server" - ); - } + let board_info = board.board_info(); - match board.create_hash_threads().await { - Ok(threads) => { - let thread_count = threads.len(); - self.boards.insert(board_id.clone(), board); + if let Err(e) = self.board_reg_tx.send(registration).await { + error!( + board = %board_info.model, + error = %e, + "Failed to register board with API server" + ); + } - for thread in threads { - if let Err(e) = self.scheduler_tx.send(thread).await { - error!( - board = %board_info.model, - error = %e, - "Failed to send thread to scheduler" - ); - break; - } - } + match board.create_hash_threads().await { + Ok(threads) => { + let thread_count = threads.len(); + self.boards.insert(device_id.clone(), board); - info!( - board = %board_info.model, - device_id = %board_id, - threads = thread_count, - "Virtual board started." - ); - } - Err(e) => { + for thread in threads { + if let Err(e) = self.scheduler_tx.send(thread).await { error!( board = %board_info.model, - device_id = %board_id, error = %e, - "Virtual board failed to start." + "Failed to send thread to scheduler" ); + break; } } - } - VirtualTransportEvent::VirtualDeviceDisconnected { device_id } => { - if let Some(mut board) = self.boards.remove(&device_id) { - let model = board.board_info().model; - debug!(board = %model, id = %device_id, "Shutting down virtual board"); - match board.shutdown().await { - Ok(()) => { - info!(board = %model, id = %device_id, "Virtual board disconnected") - } - Err(e) => error!( - board = %model, - id = %device_id, - error = %e, - "Failed to shutdown virtual board" - ), - } - } + info!( + board = %board_info.model, + device_id = %device_id, + threads = thread_count, + "Configured board started." + ); + } + Err(e) => { + error!( + board = %board_info.model, + device_id = %device_id, + error = %e, + "Configured board failed to start." + ); } } diff --git a/mujina-miner/src/bin/bzm2-debug.rs b/mujina-miner/src/bin/bzm2-debug.rs deleted file mode 100644 index 1d20ca25..00000000 --- a/mujina-miner/src/bin/bzm2-debug.rs +++ /dev/null @@ -1,1391 +0,0 @@ -use std::collections::BTreeMap; -use std::env; -use std::ops::RangeInclusive; -use std::time::Duration; - -use anyhow::{Context, Result, bail}; -use mujina_miner::asic::bzm2::protocol::{ - DtsVsGeneration, ENGINE_REG_TARGET, ENGINE_REG_TIMESTAMP_COUNT, ENGINE_REG_ZEROS_TO_FIND, - TdmFrame, TdmFrameParser, default_engine_coordinates, default_excluded_engines, - encode_read_register, encode_read_result_command, logical_engine_address, -}; -use mujina_miner::asic::bzm2::{ - BROADCAST_GROUP_ASIC, Bzm2ClockController, Bzm2Dll, Bzm2DtsVsConfig, Bzm2Pll, - Bzm2UartController, DEFAULT_ASIC_ID, DEFAULT_DTS_VS_QUERY_TIMEOUT, NOTCH_REG, -}; -use mujina_miner::transport::{SerialReader, SerialStream, SerialWriter}; -use sha2::{Digest, Sha256}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::time::{Instant, timeout}; - -const DEFAULT_BAUD: u32 = 5_000_000; -const DEFAULT_WATCH_POLL_MS: u64 = 100; -const DEFAULT_BROADCAST_READ_TIMEOUT_MS: u64 = 2_000; -const DEFAULT_ENGINE_DISCOVERY_TIMEOUT_MS: u64 = 100; -const LOCAL_REG_UART_TDM_CTL: u8 = 0x07; -const MAX_EFFBST_SUBJOBS: u8 = 4; - -#[tokio::main] -async fn main() -> Result<()> { - let args: Vec = env::args().collect(); - if args.len() < 2 { - print_usage(); - std::process::exit(1); - } - - match args[1].as_str() { - "uart-read" => cmd_uart_read(&args[2..]).await, - "uart-write" => cmd_uart_write(&args[2..]).await, - "uart-multicast-write" => cmd_uart_multicast_write(&args[2..]).await, - "uart-noop" => cmd_uart_noop(&args[2..]).await, - "uart-loopback" => cmd_uart_loopback(&args[2..]).await, - "uart-read-result" => cmd_uart_read_result(&args[2..]).await, - "dts-vs-query" => cmd_dts_vs_query(&args[2..]).await, - "dts-vs-scan" => cmd_dts_vs_scan(&args[2..]).await, - "enumerate-chain" => cmd_enumerate_chain(&args[2..]).await, - "noop-scan" => cmd_noop_scan(&args[2..]).await, - "loopback-scan" => cmd_loopback_scan(&args[2..]).await, - "tdm-enable" => cmd_tdm_enable(&args[2..]).await, - "tdm-disable" => cmd_tdm_disable(&args[2..]).await, - "tdm-watch" => cmd_tdm_watch(&args[2..]).await, - "tdm-broadcast-read-watch" => cmd_tdm_broadcast_read_watch(&args[2..]).await, - "engine-probe" => cmd_engine_probe(&args[2..]).await, - "discover-engine-map" => cmd_discover_engine_map(&args[2..]).await, - "engine-target-all" => cmd_engine_target_all(&args[2..]).await, - "engine-timestamp-all" => cmd_engine_timestamp_all(&args[2..]).await, - "engine-zeros-all" => cmd_engine_zeros_all(&args[2..]).await, - "job-grid" => cmd_job_grid(&args[2..]).await, - "job-grid-watch" => cmd_job_grid_watch(&args[2..]).await, - "job-grid-2phase-watch" => cmd_job_grid_2phase_watch(&args[2..]).await, - "clock-report" => cmd_clock_report(&args[2..]).await, - "pll-set" => cmd_pll_set(&args[2..]).await, - "dll-set" => cmd_dll_set(&args[2..]).await, - "pll-broadcast-lock-check" => cmd_pll_broadcast_lock_check(&args[2..]).await, - "dll-broadcast-lock-check" => cmd_dll_broadcast_lock_check(&args[2..]).await, - other => bail!("unknown command: {other}"), - } -} - -fn print_usage() { - eprintln!("Usage: mujina-bzm2-debug [args]"); - eprintln!(); - eprintln!("Direct UART developer commands:"); - eprintln!(" uart-read [baud]"); - eprintln!(" uart-write [baud]"); - eprintln!( - " uart-multicast-write [baud]" - ); - eprintln!(" uart-noop [baud]"); - eprintln!(" uart-loopback [baud]"); - eprintln!(" uart-read-result [baud]"); - eprintln!(" dts-vs-query [timeout-ms] [baud]"); - eprintln!(" dts-vs-scan [timeout-ms] [baud]"); - eprintln!(" enumerate-chain [start-id] [baud]"); - eprintln!(" noop-scan [baud]"); - eprintln!(" loopback-scan [baud]"); - eprintln!(); - eprintln!("TDM control and observation:"); - eprintln!(" tdm-enable [baud]"); - eprintln!(" tdm-disable [baud]"); - eprintln!(" tdm-watch [baud]"); - eprintln!( - " tdm-broadcast-read-watch [baud]" - ); - eprintln!( - " engine-probe [timeout-ms] [baud]" - ); - eprintln!( - " discover-engine-map [timeout-ms] [baud]" - ); - eprintln!(); - eprintln!("Engine-wide validation helpers:"); - eprintln!(" engine-target-all [baud]"); - eprintln!(" engine-timestamp-all [baud]"); - eprintln!(" engine-zeros-all [baud]"); - eprintln!(" job-grid [baud]"); - eprintln!( - " job-grid-watch [baud]" - ); - eprintln!( - " job-grid-2phase-watch [baud]" - ); - eprintln!(); - eprintln!("Clock diagnostics:"); - eprintln!(" clock-report [baud]"); - eprintln!(" pll-set [baud]"); - eprintln!(" dll-set [baud]"); - eprintln!( - " pll-broadcast-lock-check [baud]" - ); - eprintln!( - " dll-broadcast-lock-check [baud]" - ); - eprintln!(); - eprintln!("Addressing examples:"); - eprintln!(" unicast : uart-write /dev/ttyUSB0 2 notch 0x12 01000000"); - eprintln!(" broadcast : uart-write /dev/ttyUSB0 broadcast notch 0x12 01000000"); - eprintln!(" multicast : uart-multicast-write /dev/ttyUSB0 2 7 0x49 3c"); - eprintln!(" enumerate : enumerate-chain /dev/ttyUSB0 32 0"); - eprintln!(" scan : noop-scan /dev/ttyUSB0 0 15"); - eprintln!(" TDM watch : tdm-watch /dev/ttyUSB0 gen2 5"); - eprintln!(" grid job : job-grid-watch /dev/ttyUSB0 2 0 1700000000 60 5 gen2"); -} - -fn parse_baud(raw: Option<&String>) -> Result { - match raw { - Some(raw) => Ok(parse_u32(raw)?), - None => Ok(DEFAULT_BAUD), - } -} - -fn parse_watch_duration(raw: &str) -> Result { - let seconds = parse_f32(raw)?; - if seconds <= 0.0 { - bail!("watch duration must be greater than zero"); - } - Ok(Duration::from_secs_f32(seconds)) -} - -fn parse_timeout_duration(raw: Option<&String>) -> Result { - match raw { - Some(raw) => { - let millis = parse_u32(raw)?; - if millis == 0 { - bail!("timeout-ms must be greater than zero"); - } - Ok(Duration::from_millis(millis as u64)) - } - None => Ok(DEFAULT_DTS_VS_QUERY_TIMEOUT), - } -} - -fn parse_engine_discovery_timeout(raw: Option<&String>) -> Result { - match raw { - Some(raw) => { - let millis = parse_u32(raw)?; - if millis == 0 { - bail!("timeout-ms must be greater than zero"); - } - Ok(Duration::from_millis(millis as u64)) - } - None => Ok(Duration::from_millis(DEFAULT_ENGINE_DISCOVERY_TIMEOUT_MS)), - } -} - -fn parse_dts_generation(raw: &str) -> Result { - DtsVsGeneration::from_env_value(raw) - .ok_or_else(|| anyhow::anyhow!("invalid DTS/VS generation: {raw}")) -} - -fn parse_u8(raw: &str) -> Result { - Ok(parse_u32(raw)? as u8) -} - -fn parse_u16(raw: &str) -> Result { - Ok(parse_u32(raw)? as u16) -} - -fn parse_u32(raw: &str) -> Result { - if let Some(stripped) = raw.strip_prefix("0x").or_else(|| raw.strip_prefix("0X")) { - Ok(u32::from_str_radix(stripped, 16)?) - } else { - Ok(raw.parse()?) - } -} - -fn parse_f32(raw: &str) -> Result { - Ok(raw.parse()?) -} - -fn parse_hex_bytes(raw: &str) -> Result> { - let sanitized: String = raw - .chars() - .filter(|c| !c.is_ascii_whitespace() && *c != '_' && *c != ':') - .collect(); - if sanitized.len() % 2 != 0 { - bail!("hex payload must have an even number of digits"); - } - Ok(hex::decode(sanitized)?) -} - -fn parse_asic_or_broadcast(raw: &str) -> Result { - if raw.eq_ignore_ascii_case("broadcast") { - Ok(BROADCAST_GROUP_ASIC) - } else { - parse_u8(raw) - } -} - -fn parse_asic_range(start: &str, end: &str) -> Result> { - let start = parse_u8(start)?; - let end = parse_u8(end)?; - if start > end { - bail!("asic-start must be less than or equal to asic-end"); - } - Ok(start..=end) -} - -fn parse_engine_address(raw: &str) -> Result { - if raw.eq_ignore_ascii_case("notch") { - Ok(NOTCH_REG) - } else { - parse_u16(raw) - } -} - -fn parse_pll(raw: &str) -> Result { - match raw.to_ascii_lowercase().as_str() { - "pll0" | "0" => Ok(Bzm2Pll::Pll0), - "pll1" | "1" => Ok(Bzm2Pll::Pll1), - _ => bail!("invalid PLL selector: {raw}"), - } -} - -fn parse_dll(raw: &str) -> Result { - match raw.to_ascii_lowercase().as_str() { - "dll0" | "0" => Ok(Bzm2Dll::Dll0), - "dll1" | "1" => Ok(Bzm2Dll::Dll1), - _ => bail!("invalid DLL selector: {raw}"), - } -} - -fn parse_zeros_to_find(raw: &str) -> Result { - let zeros = parse_u8(raw)?; - if !(32..=64).contains(&zeros) { - bail!("zeros-to-find must be in the inclusive range 32..=64"); - } - Ok(zeros) -} - -fn open_uart(serial: &str, baud: u32) -> Result { - let stream = SerialStream::new(serial, baud) - .with_context(|| format!("failed to open serial port {serial} at {baud} baud"))?; - let (reader, writer, _) = stream.split(); - Ok(Bzm2UartController::new(reader, writer)) -} - -fn open_clock(serial: &str, baud: u32) -> Result { - let stream = SerialStream::new(serial, baud) - .with_context(|| format!("failed to open serial port {serial} at {baud} baud"))?; - let (reader, writer, _) = stream.split(); - Ok(Bzm2ClockController::new(reader, writer)) -} - -fn open_raw(serial: &str, baud: u32) -> Result<(SerialReader, SerialWriter)> { - let stream = SerialStream::new(serial, baud) - .with_context(|| format!("failed to open serial port {serial} at {baud} baud"))?; - let (reader, writer, _) = stream.split(); - Ok((reader, writer)) -} - -async fn cmd_uart_read(args: &[String]) -> Result<()> { - if args.len() < 5 || args.len() > 6 { - bail!("usage: uart-read [baud]"); - } - let mut uart = open_uart(&args[0], parse_baud(args.get(5))?)?; - let asic = parse_u8(&args[1])?; - let engine = parse_engine_address(&args[2])?; - let offset = parse_u8(&args[3])?; - let count = parse_u8(&args[4])?; - let data = uart.read_register(asic, engine, offset, count).await?; - println!("{}", hex::encode(data)); - Ok(()) -} - -async fn cmd_uart_write(args: &[String]) -> Result<()> { - if args.len() < 5 || args.len() > 6 { - bail!( - "usage: uart-write [baud]" - ); - } - let mut uart = open_uart(&args[0], parse_baud(args.get(5))?)?; - let asic = parse_asic_or_broadcast(&args[1])?; - let engine = parse_engine_address(&args[2])?; - let offset = parse_u8(&args[3])?; - let value = parse_hex_bytes(&args[4])?; - uart.write_register(asic, engine, offset, &value).await?; - println!("ok"); - Ok(()) -} - -async fn cmd_uart_multicast_write(args: &[String]) -> Result<()> { - if args.len() < 5 || args.len() > 6 { - bail!( - "usage: uart-multicast-write [baud]" - ); - } - let mut uart = open_uart(&args[0], parse_baud(args.get(5))?)?; - let asic = parse_asic_or_broadcast(&args[1])?; - let group = parse_u16(&args[2])?; - let offset = parse_u8(&args[3])?; - let value = parse_hex_bytes(&args[4])?; - uart.multicast_write_register(asic, group, offset, &value) - .await?; - println!("ok"); - Ok(()) -} - -async fn cmd_uart_noop(args: &[String]) -> Result<()> { - if args.len() < 2 || args.len() > 3 { - bail!("usage: uart-noop [baud]"); - } - let mut uart = open_uart(&args[0], parse_baud(args.get(2))?)?; - let asic = parse_u8(&args[1])?; - let value = uart.noop(asic).await?; - println!( - "ascii={}{}{} hex={}", - value[0] as char, - value[1] as char, - value[2] as char, - hex::encode(value) - ); - Ok(()) -} - -async fn cmd_uart_loopback(args: &[String]) -> Result<()> { - if args.len() < 3 || args.len() > 4 { - bail!("usage: uart-loopback [baud]"); - } - let mut uart = open_uart(&args[0], parse_baud(args.get(3))?)?; - let asic = parse_u8(&args[1])?; - let payload = parse_hex_bytes(&args[2])?; - let echoed = uart.loopback(asic, &payload).await?; - println!("{}", hex::encode(echoed)); - Ok(()) -} - -async fn cmd_uart_read_result(args: &[String]) -> Result<()> { - if args.len() < 3 || args.len() > 4 { - bail!("usage: uart-read-result [baud]"); - } - - let asic = parse_u8(&args[1])?; - let generation = parse_dts_generation(&args[2])?; - let baud = parse_baud(args.get(3))?; - let (mut reader, mut writer) = open_raw(&args[0], baud)?; - writer - .write_all(&encode_read_result_command(asic)) - .await - .context("failed to send read-result command")?; - writer - .flush() - .await - .context("failed to flush serial stream")?; - - let mut parser = TdmFrameParser::new(generation); - let frames = read_tdm_frames_once( - &mut reader, - &mut parser, - Duration::from_millis(DEFAULT_BROADCAST_READ_TIMEOUT_MS), - ) - .await?; - - if frames.is_empty() { - bail!("no TDM frame returned for ASIC {asic}"); - } - - for frame in frames { - print_tdm_frame(&frame); - } - - Ok(()) -} - -async fn cmd_dts_vs_query(args: &[String]) -> Result<()> { - if args.len() < 3 || args.len() > 5 { - bail!("usage: dts-vs-query [timeout-ms] [baud]"); - } - - let mut uart = open_uart(&args[0], parse_baud(args.get(4))?)?; - let asic = parse_u8(&args[1])?; - let generation = parse_dts_generation(&args[2])?; - let timeout = parse_timeout_duration(args.get(3))?; - let frame = uart - .query_dts_vs(asic, generation, Bzm2DtsVsConfig::default(), timeout) - .await?; - print_dts_vs_query_frame(&frame); - Ok(()) -} - -async fn cmd_dts_vs_scan(args: &[String]) -> Result<()> { - if args.len() < 4 || args.len() > 6 { - bail!("usage: dts-vs-scan [timeout-ms] [baud]"); - } - - let mut uart = open_uart(&args[0], parse_baud(args.get(5))?)?; - let asics = parse_asic_range(&args[1], &args[2])?; - let generation = parse_dts_generation(&args[3])?; - let timeout = parse_timeout_duration(args.get(4))?; - - for asic in asics { - let frame = uart - .query_dts_vs(asic, generation, Bzm2DtsVsConfig::default(), timeout) - .await?; - print_dts_vs_query_frame(&frame); - } - - Ok(()) -} - -async fn cmd_enumerate_chain(args: &[String]) -> Result<()> { - if args.len() < 2 || args.len() > 4 { - bail!("usage: enumerate-chain [start-id] [baud]"); - } - - let max_asics = parse_u8(&args[1])?; - if max_asics == 0 { - bail!("max-asics must be greater than zero"); - } - - let (start_id, baud_arg) = match args.len() { - 2 => (0, None), - 3 => (parse_u8(&args[2])?, None), - 4 => (parse_u8(&args[2])?, args.get(3)), - _ => unreachable!(), - }; - let mut uart = open_uart(&args[0], parse_baud(baud_arg)?)?; - - let assigned = uart.enumerate_chain(max_asics, start_id).await?; - println!( - "enumerated {} ASIC(s) from default id 0x{DEFAULT_ASIC_ID:02x}", - assigned.len() - ); - for asic in &assigned { - println!(" assigned asic id {}", asic); - } - if assigned.is_empty() { - println!(" no ASIC responded on the default id"); - } - - Ok(()) -} - -async fn cmd_noop_scan(args: &[String]) -> Result<()> { - if args.len() < 3 || args.len() > 4 { - bail!("usage: noop-scan [baud]"); - } - - let mut uart = open_uart(&args[0], parse_baud(args.get(3))?)?; - let asics = parse_asic_range(&args[1], &args[2])?; - let mut found = Vec::new(); - - for asic in asics { - match uart.noop(asic).await { - Ok(value) => { - let ascii = String::from_utf8_lossy(&value); - println!("asic {asic}: ascii={ascii} hex={}", hex::encode(value)); - found.push(asic); - } - Err(err) => { - println!("asic {asic}: no response ({err})"); - } - } - } - - println!("responsive_asics={}", found.len()); - if !found.is_empty() { - println!("asic_ids={}", format_asic_ids(&found)); - } - - Ok(()) -} - -async fn cmd_loopback_scan(args: &[String]) -> Result<()> { - if args.len() < 4 || args.len() > 5 { - bail!("usage: loopback-scan [baud]"); - } - - let mut uart = open_uart(&args[0], parse_baud(args.get(4))?)?; - let asics = parse_asic_range(&args[1], &args[2])?; - let payload_len = parse_u8(&args[3])? as usize; - if payload_len == 0 { - bail!("payload-len must be greater than zero"); - } - - for asic in asics { - let payload = synthetic_loopback_payload(asic, payload_len); - let echoed = uart.loopback(asic, &payload).await?; - let matched = payload == echoed; - println!( - "asic {asic}: matched={} payload={} echoed={}", - matched, - hex::encode(&payload), - hex::encode(&echoed) - ); - if !matched { - bail!("loopback mismatch on ASIC {asic}"); - } - } - - Ok(()) -} - -async fn cmd_tdm_enable(args: &[String]) -> Result<()> { - if args.len() < 3 || args.len() > 4 { - bail!("usage: tdm-enable [baud]"); - } - - let mut uart = open_uart(&args[0], parse_baud(args.get(3))?)?; - let prediv = parse_u32(&args[1])?; - let counter = parse_u8(&args[2])?; - let control = encode_tdm_control(prediv, counter, true); - uart.write_local_reg_u32(BROADCAST_GROUP_ASIC, LOCAL_REG_UART_TDM_CTL, control) - .await?; - println!( - "broadcast local_reg=0x{LOCAL_REG_UART_TDM_CTL:02x} control={control:#010x} enabled=true" - ); - Ok(()) -} - -async fn cmd_tdm_disable(args: &[String]) -> Result<()> { - if args.len() < 3 || args.len() > 4 { - bail!("usage: tdm-disable [baud]"); - } - - let mut uart = open_uart(&args[0], parse_baud(args.get(3))?)?; - let prediv = parse_u32(&args[1])?; - let counter = parse_u8(&args[2])?; - let control = encode_tdm_control(prediv, counter, false); - uart.write_local_reg_u32(BROADCAST_GROUP_ASIC, LOCAL_REG_UART_TDM_CTL, control) - .await?; - println!( - "broadcast local_reg=0x{LOCAL_REG_UART_TDM_CTL:02x} control={control:#010x} enabled=false" - ); - Ok(()) -} - -async fn cmd_tdm_watch(args: &[String]) -> Result<()> { - if args.len() < 3 || args.len() > 4 { - bail!("usage: tdm-watch [baud]"); - } - - let generation = parse_dts_generation(&args[1])?; - let duration = parse_watch_duration(&args[2])?; - let baud = parse_baud(args.get(3))?; - let (mut reader, _) = open_raw(&args[0], baud)?; - let mut parser = TdmFrameParser::new(generation); - let stats = watch_tdm_frames(&mut reader, &mut parser, duration).await?; - println!( - "summary: result={} register={} noop={} dts_vs={}", - stats.result, stats.register, stats.noop, stats.dts_vs - ); - Ok(()) -} - -async fn cmd_tdm_broadcast_read_watch(args: &[String]) -> Result<()> { - if args.len() < 7 || args.len() > 8 { - bail!( - "usage: tdm-broadcast-read-watch [baud]" - ); - } - - let generation = parse_dts_generation(&args[1])?; - let asics = parse_asic_range(&args[2], &args[3])?; - let engine = parse_engine_address(&args[4])?; - let offset = parse_u8(&args[5])?; - let count = parse_u8(&args[6])?; - let baud = parse_baud(args.get(7))?; - let (mut reader, mut writer) = open_raw(&args[0], baud)?; - let mut parser = TdmFrameParser::new(generation); - let expected = asics.clone().count(); - - for asic in asics.clone() { - parser.expect_read_register_bytes(asic, count as usize); - } - - writer - .write_all(&encode_read_register( - BROADCAST_GROUP_ASIC, - engine, - offset, - count, - )) - .await - .context("failed to send broadcast read-register command")?; - writer - .flush() - .await - .context("failed to flush serial stream")?; - - let mut received = BTreeMap::new(); - let deadline = Instant::now() + Duration::from_millis(DEFAULT_BROADCAST_READ_TIMEOUT_MS); - while Instant::now() < deadline && received.len() < expected { - let frames = read_tdm_frames_once( - &mut reader, - &mut parser, - Duration::from_millis(DEFAULT_WATCH_POLL_MS), - ) - .await?; - for frame in frames { - print_tdm_frame(&frame); - if let TdmFrame::Register(register) = frame { - received.insert(register.asic, register.data); - } - } - } - - println!( - "broadcast-read summary: received={} expected={} missing={}", - received.len(), - expected, - expected.saturating_sub(received.len()) - ); - - if received.len() != expected { - let mut missing = Vec::new(); - for asic in asics { - if !received.contains_key(&asic) { - missing.push(asic); - } - } - if !missing.is_empty() { - println!("missing_asics={}", format_asic_ids(&missing)); - } - } - - Ok(()) -} - -async fn cmd_engine_probe(args: &[String]) -> Result<()> { - if args.len() < 6 || args.len() > 8 { - bail!( - "usage: engine-probe [timeout-ms] [baud]" - ); - } - let mut uart = open_uart(&args[0], parse_baud(args.get(7))?)?; - let asic = parse_u8(&args[1])?; - let row = parse_u8(&args[2])?; - let col = parse_u8(&args[3])?; - let prediv = parse_u32(&args[4])?; - let counter = parse_u8(&args[5])?; - let timeout = parse_engine_discovery_timeout(args.get(6))?; - - uart.enable_tdm(prediv, counter).await?; - let result = uart.detect_engine(asic, row, col, timeout).await; - let disable_result = uart.disable_tdm(prediv, counter).await; - let present = result?; - disable_result?; - - println!( - "asic={asic} row={row} col={col} engine=0x{:03x} present={present}", - logical_engine_address(row, col) - ); - Ok(()) -} - -async fn cmd_discover_engine_map(args: &[String]) -> Result<()> { - if args.len() < 4 || args.len() > 6 { - bail!( - "usage: discover-engine-map [timeout-ms] [baud]" - ); - } - let mut uart = open_uart(&args[0], parse_baud(args.get(5))?)?; - let asic = parse_u8(&args[1])?; - let prediv = parse_u32(&args[2])?; - let counter = parse_u8(&args[3])?; - let timeout = parse_engine_discovery_timeout(args.get(4))?; - - let discovery = uart - .discover_engine_map(asic, prediv, counter, timeout) - .await?; - - let legacy_missing = default_excluded_engines(); - let discovered_missing = discovery - .missing - .iter() - .map(|coord| (coord.row, coord.col)) - .collect::>(); - println!( - "asic={} present={} missing={} legacy_default_holes_match={}", - asic, - discovery.present_count(), - discovery.missing_count(), - discovered_missing.len() == legacy_missing.len() - && discovered_missing - .iter() - .all(|coord| legacy_missing.contains(coord)) - ); - if !discovery.missing.is_empty() { - println!("missing: {}", format_engine_coordinates(&discovery.missing)); - } - Ok(()) -} - -async fn cmd_engine_target_all(args: &[String]) -> Result<()> { - if args.len() < 3 || args.len() > 4 { - bail!("usage: engine-target-all [baud]"); - } - - let mut uart = open_uart(&args[0], parse_baud(args.get(3))?)?; - let asic = parse_asic_or_broadcast(&args[1])?; - let target = parse_u32(&args[2])?; - write_engine_reg_all_u32(&mut uart, asic, ENGINE_REG_TARGET, target).await?; - println!("programmed engine target across all rows: asic={asic:#04x} target={target:#010x}"); - Ok(()) -} - -async fn cmd_engine_timestamp_all(args: &[String]) -> Result<()> { - if args.len() < 3 || args.len() > 4 { - bail!("usage: engine-timestamp-all [baud]"); - } - - let mut uart = open_uart(&args[0], parse_baud(args.get(3))?)?; - let asic = parse_asic_or_broadcast(&args[1])?; - let value = parse_u8(&args[2])?; - write_engine_reg_all_u8(&mut uart, asic, ENGINE_REG_TIMESTAMP_COUNT, value).await?; - println!( - "programmed ENGINE_REG_TIMESTAMP_COUNT across all rows: asic={asic:#04x} value={value}" - ); - Ok(()) -} - -async fn cmd_engine_zeros_all(args: &[String]) -> Result<()> { - if args.len() < 3 || args.len() > 4 { - bail!("usage: engine-zeros-all [baud]"); - } - - let mut uart = open_uart(&args[0], parse_baud(args.get(3))?)?; - let asic = parse_asic_or_broadcast(&args[1])?; - let zeros = parse_zeros_to_find(&args[2])?; - let register_value = zeros - 32; - write_engine_reg_all_u8(&mut uart, asic, ENGINE_REG_ZEROS_TO_FIND, register_value).await?; - println!( - "programmed ENGINE_REG_ZEROS_TO_FIND across all rows: asic={asic:#04x} zeros_to_find={zeros} register_value={register_value}" - ); - Ok(()) -} - -async fn cmd_job_grid(args: &[String]) -> Result<()> { - if args.len() < 5 || args.len() > 6 { - bail!( - "usage: job-grid [baud]" - ); - } - - let mut uart = open_uart(&args[0], parse_baud(args.get(5))?)?; - let asic = parse_asic_or_broadcast(&args[1])?; - let sequence_base = parse_u8(&args[2])?; - let ntime = parse_u32(&args[3])?; - let timestamp_count = parse_u8(&args[4])?; - - dispatch_grid_jobs( - &mut uart, - asic, - sequence_base, - ntime, - timestamp_count, - false, - ) - .await?; - println!( - "dispatched grid job set: asic={asic:#04x} engines={} seq_base={} ntime={ntime:#010x} timestamp_count={timestamp_count}", - default_engine_coordinates().len(), - sequence_base - ); - Ok(()) -} - -async fn cmd_job_grid_watch(args: &[String]) -> Result<()> { - if args.len() < 7 || args.len() > 8 { - bail!( - "usage: job-grid-watch [baud]" - ); - } - - let asic = parse_asic_or_broadcast(&args[1])?; - let sequence_base = parse_u8(&args[2])?; - let ntime = parse_u32(&args[3])?; - let timestamp_count = parse_u8(&args[4])?; - let duration = parse_watch_duration(&args[5])?; - let generation = parse_dts_generation(&args[6])?; - let baud = parse_baud(args.get(7))?; - let (mut reader, writer) = open_raw(&args[0], baud)?; - let mut parser = TdmFrameParser::new(generation); - let mut uart = Bzm2UartController::new(reader.clone(), writer); - - dispatch_grid_jobs( - &mut uart, - asic, - sequence_base, - ntime, - timestamp_count, - false, - ) - .await?; - let stats = watch_tdm_frames(&mut reader, &mut parser, duration).await?; - println!( - "summary: result={} register={} noop={} dts_vs={}", - stats.result, stats.register, stats.noop, stats.dts_vs - ); - Ok(()) -} - -async fn cmd_job_grid_2phase_watch(args: &[String]) -> Result<()> { - if args.len() < 7 || args.len() > 8 { - bail!( - "usage: job-grid-2phase-watch [baud]" - ); - } - - let asic = parse_asic_or_broadcast(&args[1])?; - let sequence_base = parse_u8(&args[2])?; - let ntime = parse_u32(&args[3])?; - let timestamp_count = parse_u8(&args[4])?; - let duration = parse_watch_duration(&args[5])?; - let generation = parse_dts_generation(&args[6])?; - let baud = parse_baud(args.get(7))?; - let (mut reader, writer) = open_raw(&args[0], baud)?; - let mut parser = TdmFrameParser::new(generation); - let mut uart = Bzm2UartController::new(reader.clone(), writer); - - dispatch_grid_jobs( - &mut uart, - asic, - sequence_base, - ntime, - timestamp_count, - false, - ) - .await?; - dispatch_grid_jobs( - &mut uart, - asic, - sequence_base.wrapping_add(MAX_EFFBST_SUBJOBS), - ntime.wrapping_add(1), - timestamp_count, - true, - ) - .await?; - - let stats = watch_tdm_frames(&mut reader, &mut parser, duration).await?; - println!( - "summary: result={} register={} noop={} dts_vs={}", - stats.result, stats.register, stats.noop, stats.dts_vs - ); - Ok(()) -} - -async fn cmd_clock_report(args: &[String]) -> Result<()> { - if args.len() < 2 || args.len() > 3 { - bail!("usage: clock-report [baud]"); - } - let mut clock = open_clock(&args[0], parse_baud(args.get(2))?)?; - let asic = parse_u8(&args[1])?; - let report = clock.debug_report(asic).await?; - println!("ASIC {}", report.asic); - println!( - " PLL0: enabled={} locked={} enable={:#010x} misc={:#010x}", - report.pll0.enabled, - report.pll0.locked, - report.pll0.enable_register, - report.pll0.misc_register - ); - println!( - " PLL1: enabled={} locked={} enable={:#010x} misc={:#010x}", - report.pll1.enabled, - report.pll1.locked, - report.pll1.enable_register, - report.pll1.misc_register - ); - println!( - " DLL0: locked={} freeze_valid={} coarsecon={} fincon={:#04x} fincon_valid={} control2={:#04x} control5={:#04x}", - report.dll0.locked, - report.dll0.freeze_valid, - report.dll0.coarsecon, - report.dll0.fincon, - report.dll0.fincon_valid, - report.dll0.control2, - report.dll0.control5 - ); - println!( - " DLL1: locked={} freeze_valid={} coarsecon={} fincon={:#04x} fincon_valid={} control2={:#04x} control5={:#04x}", - report.dll1.locked, - report.dll1.freeze_valid, - report.dll1.coarsecon, - report.dll1.fincon, - report.dll1.fincon_valid, - report.dll1.control2, - report.dll1.control5 - ); - Ok(()) -} - -async fn cmd_pll_set(args: &[String]) -> Result<()> { - if args.len() < 5 || args.len() > 6 { - bail!( - "usage: pll-set [baud]" - ); - } - let mut clock = open_clock(&args[0], parse_baud(args.get(5))?)?; - let asic = parse_asic_or_broadcast(&args[1])?; - let pll = parse_pll(&args[2])?; - let freq = parse_f32(&args[3])?; - let post1 = parse_u8(&args[4])?; - - if asic == BROADCAST_GROUP_ASIC { - let config = clock.set_pll_frequency(asic, pll, freq, post1).await?; - clock.enable_pll(asic, pll).await?; - println!( - "broadcast {:?}: freq={}MHz fbdiv={} postdiv={:#x}", - pll, config.frequency_mhz, config.feedback_divider, config.packed_post_divider - ); - } else { - let (config, status) = clock - .configure_and_lock_pll(asic, pll, freq, post1, Duration::from_secs(3)) - .await?; - println!( - "asic {} {:?}: freq={}MHz fbdiv={} postdiv={:#x} locked={} enable={:#010x}", - asic, - pll, - config.frequency_mhz, - config.feedback_divider, - config.packed_post_divider, - status.locked, - status.enable_register - ); - } - Ok(()) -} - -async fn cmd_dll_set(args: &[String]) -> Result<()> { - if args.len() < 4 || args.len() > 5 { - bail!("usage: dll-set [baud]"); - } - let mut clock = open_clock(&args[0], parse_baud(args.get(4))?)?; - let asic = parse_asic_or_broadcast(&args[1])?; - let dll = parse_dll(&args[2])?; - let duty = parse_u8(&args[3])?; - - if asic == BROADCAST_GROUP_ASIC { - let config = clock.set_dll_duty_cycle(asic, dll, duty).await?; - clock.enable_dll(asic, dll).await?; - println!( - "broadcast {:?}: duty={} nde_dll={:#x} nde_clk={:#x} npi_clk={:#x}", - dll, config.duty_cycle, config.nde_dll, config.nde_clk, config.npi_clk - ); - } else { - let (config, status) = clock - .configure_and_lock_dll(asic, dll, duty, Duration::from_secs(2)) - .await?; - println!( - "asic {} {:?}: duty={} locked={} coarsecon={} fincon={:#04x} fincon_valid={}", - asic, - dll, - config.duty_cycle, - status.locked, - status.coarsecon, - status.fincon, - status.fincon_valid - ); - } - Ok(()) -} - -async fn cmd_pll_broadcast_lock_check(args: &[String]) -> Result<()> { - if args.len() < 6 || args.len() > 7 { - bail!( - "usage: pll-broadcast-lock-check [baud]" - ); - } - - let mut clock = open_clock(&args[0], parse_baud(args.get(6))?)?; - let pll = parse_pll(&args[1])?; - let freq = parse_f32(&args[2])?; - let post1 = parse_u8(&args[3])?; - let asics = parse_asic_range(&args[4], &args[5])?; - - let config = clock - .set_pll_frequency(BROADCAST_GROUP_ASIC, pll, freq, post1) - .await?; - clock.enable_pll(BROADCAST_GROUP_ASIC, pll).await?; - - println!( - "broadcast {:?}: freq={}MHz fbdiv={} postdiv={:#x}", - pll, config.frequency_mhz, config.feedback_divider, config.packed_post_divider - ); - - for asic in asics { - let status = clock - .wait_for_pll_lock( - asic, - pll, - Duration::from_secs(3), - Duration::from_millis(100), - ) - .await?; - println!( - "asic {} {:?}: locked={} enable={:#010x} misc={:#010x}", - asic, pll, status.locked, status.enable_register, status.misc_register - ); - } - - Ok(()) -} - -async fn cmd_dll_broadcast_lock_check(args: &[String]) -> Result<()> { - if args.len() < 5 || args.len() > 6 { - bail!( - "usage: dll-broadcast-lock-check [baud]" - ); - } - - let mut clock = open_clock(&args[0], parse_baud(args.get(5))?)?; - let dll = parse_dll(&args[1])?; - let duty = parse_u8(&args[2])?; - let asics = parse_asic_range(&args[3], &args[4])?; - - let config = clock - .set_dll_duty_cycle(BROADCAST_GROUP_ASIC, dll, duty) - .await?; - clock.enable_dll(BROADCAST_GROUP_ASIC, dll).await?; - - println!( - "broadcast {:?}: duty={} nde_dll={:#x} nde_clk={:#x} npi_clk={:#x}", - dll, config.duty_cycle, config.nde_dll, config.nde_clk, config.npi_clk - ); - - for asic in asics { - clock - .wait_for_dll_lock(asic, dll, Duration::from_secs(2), Duration::from_millis(10)) - .await?; - let status = clock.ensure_dll_fincon_valid(asic, dll).await?; - - println!( - "asic {} {:?}: locked={} coarsecon={} fincon={:#04x} fincon_valid={}", - asic, dll, status.locked, status.coarsecon, status.fincon, status.fincon_valid - ); - } - - Ok(()) -} - -async fn write_engine_reg_all_u8( - uart: &mut Bzm2UartController, - asic: u8, - offset: u8, - value: u8, -) -> Result<()> { - for row in 0..20u16 { - uart.multicast_write_reg_u8(asic, row, offset, value) - .await?; - } - Ok(()) -} - -async fn write_engine_reg_all_u32( - uart: &mut Bzm2UartController, - asic: u8, - offset: u8, - value: u32, -) -> Result<()> { - for row in 0..20u16 { - uart.multicast_write_register(asic, row, offset, &value.to_le_bytes()) - .await?; - } - Ok(()) -} - -async fn dispatch_grid_jobs( - uart: &mut Bzm2UartController, - asic: u8, - sequence_base: u8, - ntime: u32, - timestamp_count: u8, - second_phase: bool, -) -> Result<()> { - write_engine_reg_all_u8(uart, asic, ENGINE_REG_TIMESTAMP_COUNT, timestamp_count).await?; - - for (index, (row, col)) in default_engine_coordinates().into_iter().enumerate() { - let engine = logical_engine_address(row, col); - let sequence = sequence_base.wrapping_add(index as u8); - let (midstate, merkle_root_residue) = - synthetic_job_material(asic, engine, sequence, second_phase); - uart.write_job( - asic, - engine, - &midstate, - merkle_root_residue, - ntime.wrapping_add(u32::from(second_phase)), - sequence, - 0, - ) - .await?; - } - - Ok(()) -} - -async fn watch_tdm_frames( - reader: &mut SerialReader, - parser: &mut TdmFrameParser, - duration: Duration, -) -> Result { - let deadline = Instant::now() + duration; - let mut stats = TdmStats::default(); - - while Instant::now() < deadline { - let frames = - read_tdm_frames_once(reader, parser, Duration::from_millis(DEFAULT_WATCH_POLL_MS)) - .await?; - for frame in frames { - stats.record(&frame); - print_tdm_frame(&frame); - } - } - - Ok(stats) -} - -async fn read_tdm_frames_once( - reader: &mut SerialReader, - parser: &mut TdmFrameParser, - wait: Duration, -) -> Result> { - let mut buf = [0u8; 1024]; - match timeout(wait, reader.read(&mut buf)).await { - Ok(Ok(0)) => bail!("serial stream closed while waiting for TDM data"), - Ok(Ok(read)) => Ok(parser.push(&buf[..read])), - Ok(Err(err)) => Err(err).context("failed to read TDM data"), - Err(_) => Ok(Vec::new()), - } -} -fn print_tdm_frame(frame: &TdmFrame) { - match frame { - TdmFrame::Result(result) => { - println!( - "tdm result: asic={} engine={:#05x} row={} col={} status={:#x} nonce={:#010x} seq={} time={}", - result.asic, - result.engine_address, - result.row(), - result.col(), - result.status, - result.nonce, - result.sequence_id, - result.reported_time - ); - } - TdmFrame::Register(register) => { - println!( - "tdm readreg: asic={} data={}", - register.asic, - hex::encode(®ister.data) - ); - } - TdmFrame::Noop(noop) => { - let ascii = String::from_utf8_lossy(&noop.data); - println!( - "tdm noop: asic={} ascii={} hex={}", - noop.asic, - ascii, - hex::encode(noop.data) - ); - } - TdmFrame::DtsVs(dts_vs) => match dts_vs { - mujina_miner::asic::bzm2::protocol::TdmDtsVsFrame::Gen1(frame) => { - println!( - "tdm dts_vs gen1: asic={} voltage={} thermal_tune_code={} voltage_enabled={} thermal_validity={} thermal_enabled={}", - frame.asic, - frame.voltage, - frame.thermal_tune_code, - frame.voltage_enabled, - frame.thermal_validity, - frame.thermal_enabled - ); - } - mujina_miner::asic::bzm2::protocol::TdmDtsVsFrame::Gen2(frame) => { - println!( - "tdm dts_vs gen2: asic={} ch0={} ch1={} ch2={} thermal_code={} thermal_trip={} thermal_fault={} voltage_fault={} pll_lock={} dll0_lock={} dll1_lock={}", - frame.asic, - frame.ch0_voltage, - frame.ch1_voltage, - frame.ch2_voltage, - frame.thermal_tune_code, - frame.thermal_trip_status, - frame.thermal_fault, - frame.voltage_fault, - frame.pll_lock, - frame.dll0_lock, - frame.dll1_lock - ); - } - }, - } -} - -fn print_dts_vs_query_frame(frame: &mujina_miner::asic::bzm2::protocol::TdmDtsVsFrame) { - match frame { - mujina_miner::asic::bzm2::protocol::TdmDtsVsFrame::Gen1(frame) => { - println!( - "asic={} gen=1 voltage_v={} temperature_c={} thermal_valid={} voltage_enabled={}", - frame.asic, - frame - .voltage_enabled - .then(|| format!("{:.4}", legacy_tune_code_to_voltage_v(frame.voltage))) - .unwrap_or_else(|| "n/a".into()), - if frame.thermal_enabled && frame.thermal_validity { - format!( - "{:.2}", - legacy_gen1_tune_code_to_temperature_c(frame.thermal_tune_code) - ) - } else { - "n/a".into() - }, - frame.thermal_validity, - frame.voltage_enabled, - ); - } - mujina_miner::asic::bzm2::protocol::TdmDtsVsFrame::Gen2(frame) => { - let temperature = if frame.thermal_enabled && frame.thermal_validity { - format!( - "{:.2}", - legacy_tune_code_to_temperature_c(frame.thermal_tune_code) - ) - } else { - "n/a".into() - }; - let ch0 = frame - .voltage_enabled - .then(|| format!("{:.4}", legacy_tune_code_to_voltage_v(frame.ch0_voltage))) - .unwrap_or_else(|| "n/a".into()); - let ch1 = frame - .voltage_enabled - .then(|| format!("{:.4}", legacy_tune_code_to_voltage_v(frame.ch1_voltage))) - .unwrap_or_else(|| "n/a".into()); - let ch2 = frame - .voltage_enabled - .then(|| format!("{:.4}", legacy_tune_code_to_voltage_v(frame.ch2_voltage))) - .unwrap_or_else(|| "n/a".into()); - println!( - "asic={} gen=2 temperature_c={} voltage_ch0_v={} voltage_ch1_v={} voltage_ch2_v={} thermal_trip={} thermal_fault={} voltage_fault={} pll_lock={} dll0_lock={} dll1_lock={}", - frame.asic, - temperature, - ch0, - ch1, - ch2, - frame.thermal_trip_status, - frame.thermal_fault, - frame.voltage_fault, - frame.pll_lock, - frame.dll0_lock, - frame.dll1_lock, - ); - } - } -} - -fn legacy_gen1_tune_code_to_temperature_c(tune_code: u8) -> f32 { - -293.8 + 631.8 * ((tune_code as f32) - (2048.0 / 4096.0)) / 4096.0 -} - -fn legacy_tune_code_to_temperature_c(tune_code: u16) -> f32 { - -293.8 + 631.8 * ((tune_code as f32) - (2048.0 / 4096.0)) / 4096.0 -} - -fn legacy_tune_code_to_voltage_v(tune_code: u16) -> f32 { - 0.4 * 0.7067 * (6.0 * (tune_code as f32) / 16384.0 - 3.0 / 16384.0 - 1.0) -} - -fn synthetic_job_material( - asic: u8, - engine_address: u16, - sequence_id: u8, - second_phase: bool, -) -> ([u8; 32], u32) { - let phase = if second_phase { 1u8 } else { 0u8 }; - let seed = format!("{asic}:{engine_address}:{sequence_id}:{phase}"); - let digest = Sha256::digest(seed.as_bytes()); - let residue_digest = Sha256::digest(digest); - - let mut midstate = [0u8; 32]; - midstate.copy_from_slice(&digest); - - let mut residue_bytes = [0u8; 4]; - residue_bytes.copy_from_slice(&residue_digest[..4]); - let merkle_root_residue = u32::from_le_bytes(residue_bytes); - - (midstate, merkle_root_residue) -} - -fn synthetic_loopback_payload(asic: u8, payload_len: usize) -> Vec { - let mut payload = Vec::with_capacity(payload_len); - let mut block = Sha256::digest([asic]); - while payload.len() < payload_len { - let take = (payload_len - payload.len()).min(block.len()); - payload.extend_from_slice(&block[..take]); - block = Sha256::digest(block); - } - payload -} - -fn encode_tdm_control(prediv_raw: u32, counter: u8, enable: bool) -> u32 { - (prediv_raw << 9) | ((counter as u32) << 1) | u32::from(enable) -} - -fn format_asic_ids(asics: &[u8]) -> String { - asics - .iter() - .map(|asic| asic.to_string()) - .collect::>() - .join(",") -} - -fn format_engine_coordinates(coords: &[mujina_miner::asic::bzm2::Bzm2EngineCoordinate]) -> String { - coords - .iter() - .map(|coord| { - format!( - "r{}c{}(0x{:03x})", - coord.row, coord.col, coord.engine_address - ) - }) - .collect::>() - .join(",") -} - -#[derive(Debug, Default, Clone, Copy)] -struct TdmStats { - result: usize, - register: usize, - noop: usize, - dts_vs: usize, -} - -impl TdmStats { - fn record(&mut self, frame: &TdmFrame) { - match frame { - TdmFrame::Result(_) => self.result += 1, - TdmFrame::Register(_) => self.register += 1, - TdmFrame::Noop(_) => self.noop += 1, - TdmFrame::DtsVs(_) => self.dts_vs += 1, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn zeros_to_find_is_range_checked() { - assert!(parse_zeros_to_find("32").is_ok()); - assert!(parse_zeros_to_find("64").is_ok()); - assert!(parse_zeros_to_find("31").is_err()); - assert!(parse_zeros_to_find("65").is_err()); - } - - #[test] - fn tdm_control_word_matches_legacy_layout() { - let control = encode_tdm_control(0x12, 0x0f, true); - assert_eq!(control, (0x12 << 9) | (0x0f << 1) | 1); - } - - #[test] - fn synthetic_jobs_change_per_engine() { - let first = synthetic_job_material(2, logical_engine_address(0, 0), 0, false); - let second = synthetic_job_material(2, logical_engine_address(1, 0), 1, false); - let third = synthetic_job_material(2, logical_engine_address(0, 0), 0, true); - - assert_ne!(first, second); - assert_ne!(first, third); - } -} diff --git a/mujina-miner/src/board/bzm2.rs b/mujina-miner/src/board/bzm2.rs index 03e20dc1..56864592 100644 --- a/mujina-miner/src/board/bzm2.rs +++ b/mujina-miner/src/board/bzm2.rs @@ -65,7 +65,7 @@ const DEFAULT_BRINGUP_RELEASE_RESET_MS: u64 = 25; const DEFAULT_ENGINE_DISCOVERY_TIMEOUT_MS: u64 = 100; #[derive(Debug, Clone)] -pub struct Bzm2VirtualDeviceConfig { +pub struct Bzm2RuntimeConfig { pub serial_paths: Vec, pub baud_rate: u32, pub timestamp_count: u8, @@ -79,7 +79,7 @@ pub struct Bzm2VirtualDeviceConfig { pub bringup: Bzm2BringupConfig, } -impl Bzm2VirtualDeviceConfig { +impl Bzm2RuntimeConfig { pub fn from_env() -> Option { let raw_paths = env::var("MUJINA_BZM2_SERIAL") .ok() @@ -944,7 +944,7 @@ struct Bzm2RuntimeMeasurementCache { } pub struct Bzm2Board { - config: Bzm2VirtualDeviceConfig, + config: Bzm2RuntimeConfig, bringup_applied: bool, shutdown_handles: Vec, serial_controls: Vec, @@ -961,7 +961,7 @@ pub struct Bzm2Board { impl Bzm2Board { pub fn new( - config: Bzm2VirtualDeviceConfig, + config: Bzm2RuntimeConfig, state_tx: watch::Sender, command_rx: mpsc::Receiver, ) -> Self { @@ -2935,7 +2935,7 @@ fn calibration_error(serial_path: &str, err: impl std::fmt::Display) -> BoardErr async fn create_bzm2_board() -> crate::error::Result<(Box, super::BoardRegistration)> { - let config = Bzm2VirtualDeviceConfig::from_env().ok_or_else(|| { + let config = Bzm2RuntimeConfig::from_env().ok_or_else(|| { crate::error::Error::Config("BZM2 not configured (MUJINA_BZM2_SERIAL not set)".into()) })?; @@ -3041,7 +3041,7 @@ mod tests { let rail0_path = std::env::temp_dir().join(format!("bzm2-domain-rail0-{unique}.txt")); let rail1_path = std::env::temp_dir().join(format!("bzm2-domain-rail1-{unique}.txt")); - let config = Bzm2VirtualDeviceConfig { + let config = Bzm2RuntimeConfig { serial_paths: vec![serial_path], baud_rate: DEFAULT_BAUD_RATE, timestamp_count: crate::asic::bzm2::protocol::DEFAULT_TIMESTAMP_COUNT, @@ -3159,7 +3159,7 @@ mod tests { let original = serde_json::to_string_pretty(&persisted).unwrap(); fs::write(&profile_path, &original).unwrap(); - let config = Bzm2VirtualDeviceConfig { + let config = Bzm2RuntimeConfig { serial_paths: vec![serial_path], baud_rate: DEFAULT_BAUD_RATE, timestamp_count: crate::asic::bzm2::protocol::DEFAULT_TIMESTAMP_COUNT, @@ -3227,7 +3227,7 @@ mod tests { .into_owned(); let emulator = spawn_chain_emulator(master, 2, 0); - let config = Bzm2VirtualDeviceConfig { + let config = Bzm2RuntimeConfig { serial_paths: vec![serial_path], baud_rate: DEFAULT_BAUD_RATE, timestamp_count: crate::asic::bzm2::protocol::DEFAULT_TIMESTAMP_COUNT, @@ -3283,7 +3283,7 @@ mod tests { .unwrap(); }); - let config = Bzm2VirtualDeviceConfig { + let config = Bzm2RuntimeConfig { serial_paths: vec![serial_path], baud_rate: DEFAULT_BAUD_RATE, timestamp_count: crate::asic::bzm2::protocol::DEFAULT_TIMESTAMP_COUNT, @@ -3335,7 +3335,7 @@ mod tests { let enable1_path = std::env::temp_dir().join(format!("bzm2-enable1-{unique}.txt")); let reset_path = std::env::temp_dir().join(format!("bzm2-reset-{unique}.txt")); - let config = Bzm2VirtualDeviceConfig { + let config = Bzm2RuntimeConfig { serial_paths: vec![serial_path], baud_rate: DEFAULT_BAUD_RATE, timestamp_count: crate::asic::bzm2::protocol::DEFAULT_TIMESTAMP_COUNT, @@ -3438,7 +3438,7 @@ mod tests { fs::write(&power_path, "1275\n").unwrap(); fs::write(&temp_path, "47000\n").unwrap(); - let config = Bzm2VirtualDeviceConfig { + let config = Bzm2RuntimeConfig { serial_paths: vec![serial_path], baud_rate: DEFAULT_BAUD_RATE, timestamp_count: crate::asic::bzm2::protocol::DEFAULT_TIMESTAMP_COUNT, @@ -3536,7 +3536,7 @@ mod tests { )); fs::write(&sensor_path, "90\n").unwrap(); - let config = Bzm2VirtualDeviceConfig { + let config = Bzm2RuntimeConfig { serial_paths: vec![serial_path], baud_rate: DEFAULT_BAUD_RATE, timestamp_count: crate::asic::bzm2::protocol::DEFAULT_TIMESTAMP_COUNT, diff --git a/mujina-miner/src/daemon.rs b/mujina-miner/src/daemon.rs index 5e767b17..5ff2eea7 100644 --- a/mujina-miner/src/daemon.rs +++ b/mujina-miner/src/daemon.rs @@ -15,7 +15,7 @@ use crate::{ api::{self, ApiConfig, commands::SchedulerCommand}, asic::hash_thread::HashThread, backplane::Backplane, - board::bzm2::Bzm2VirtualDeviceConfig, + board::bzm2::Bzm2RuntimeConfig, cpu_miner::CpuMinerConfig, job_source::{ SourceCommand, SourceEvent, @@ -25,10 +25,7 @@ use crate::{ }, scheduler::{self, SourceRegistration}, stratum_v1::{PoolConfig as StratumPoolConfig, TcpConnector}, - transport::{ - CpuDeviceInfo, TransportEvent, UsbTransport, VirtualDeviceInfo, cpu as cpu_transport, - virtual_device, - }, + transport::{CpuDeviceInfo, TransportEvent, UsbTransport, cpu as cpu_transport}, }; /// The main daemon. @@ -82,28 +79,22 @@ impl Daemon { } } - if let Some(config) = Bzm2VirtualDeviceConfig::from_env() { - info!( - serials = config.serial_paths.len(), - baud = config.baud_rate, - "BZM2 virtual board enabled" - ); - let event = TransportEvent::Virtual( - virtual_device::TransportEvent::VirtualDeviceConnected(VirtualDeviceInfo { - device_type: "bzm2".into(), - device_id: config.device_id(), - }), - ); - if let Err(e) = transport_tx.send(event).await { - error!("Failed to send BZM2 virtual board event: {}", e); - } - } // Board registration channel: backplane forwards board // registrations here, the API server collects and serves them. let (board_reg_tx, board_reg_rx) = mpsc::channel(10); // Create and start backplane let mut backplane = Backplane::new(transport_rx, thread_tx, board_reg_tx); + if let Some(config) = Bzm2RuntimeConfig::from_env() { + info!( + serials = config.serial_paths.len(), + baud = config.baud_rate, + "BZM2 board enabled from configured serial paths" + ); + backplane + .attach_configured_board("bzm2", config.device_id()) + .await?; + } self.tracker.spawn({ let shutdown = self.shutdown.clone(); async move { diff --git a/mujina-miner/src/transport/mod.rs b/mujina-miner/src/transport/mod.rs index d49cc517..c54019c2 100644 --- a/mujina-miner/src/transport/mod.rs +++ b/mujina-miner/src/transport/mod.rs @@ -10,7 +10,6 @@ use anyhow::Result; pub mod cpu; pub mod serial; pub mod usb; -pub mod virtual_device; // Re-export transport implementations pub use cpu::CpuDeviceInfo; @@ -19,7 +18,6 @@ pub use serial::{ SerialWriter, }; pub use usb::{UsbDeviceInfo, UsbTransport}; -pub use virtual_device::VirtualDeviceInfo; /// Generic transport event that can represent different transport types. #[derive(Debug)] @@ -29,9 +27,6 @@ pub enum TransportEvent { /// CPU miner virtual device event Cpu(cpu::TransportEvent), - - /// Generic virtual device event - Virtual(virtual_device::TransportEvent), } /// Common trait for transport discovery (future enhancement). diff --git a/mujina-miner/src/transport/virtual_device.rs b/mujina-miner/src/transport/virtual_device.rs deleted file mode 100644 index 0f2109b7..00000000 --- a/mujina-miner/src/transport/virtual_device.rs +++ /dev/null @@ -1,23 +0,0 @@ -//! Generic virtual device transport. -//! -//! Virtual boards are injected by configuration rather than hardware discovery. - -/// Transport events for generic virtual devices. -#[derive(Debug)] -pub enum TransportEvent { - /// A virtual device was connected. - VirtualDeviceConnected(VirtualDeviceInfo), - - /// A virtual device was disconnected. - VirtualDeviceDisconnected { device_id: String }, -} - -/// Information about a generic virtual device. -#[derive(Debug, Clone)] -pub struct VirtualDeviceInfo { - /// Virtual board type identifier. - pub device_type: String, - - /// Unique identifier for this virtual instance. - pub device_id: String, -} From 7a96b545d7b8a771853504ab5ccfc612e256b89c Mon Sep 17 00:00:00 2001 From: Reckless Apotheosis <125509978+recklessnode@users.noreply.github.com> Date: Wed, 18 Mar 2026 19:41:46 -0700 Subject: [PATCH 20/24] chore(bzm2): remove superseded ASIC-local planner and control modules --- mujina-miner/src/asic/bzm2/control.rs | 425 ----------- mujina-miner/src/asic/bzm2/pnp.rs | 998 -------------------------- 2 files changed, 1423 deletions(-) delete mode 100644 mujina-miner/src/asic/bzm2/control.rs delete mode 100644 mujina-miner/src/asic/bzm2/pnp.rs diff --git a/mujina-miner/src/asic/bzm2/control.rs b/mujina-miner/src/asic/bzm2/control.rs deleted file mode 100644 index 2b96e693..00000000 --- a/mujina-miner/src/asic/bzm2/control.rs +++ /dev/null @@ -1,425 +0,0 @@ -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 Bzm2PowerRail: 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 Bzm2PowerRail 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<()> { - Bzm2PowerRail::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 Bzm2BringupPlan { - 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 Bzm2PowerRail 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 Bzm2BringupPlan { - 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 Bzm2BringupPlan { - pub async fn apply( - &self, - rails: &mut [R], - mut reset_line: Option<&mut GpioResetLine>, - ) -> Result<()> - where - R: Bzm2PowerRail, - 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.as_deref_mut() { - reset_line.enable().await?; - sleep(self.release_reset_delay).await; - } - - Ok(()) - } - - pub async fn shutdown( - &self, - rails: &mut [R], - mut reset_line: Option<&mut GpioResetLine>, - ) -> Result<()> - where - R: Bzm2PowerRail, - PIN: GpioPin, - { - if let Some(reset_line) = reset_line.as_deref_mut() { - 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 Bzm2PowerRail 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 = Bzm2BringupPlan { - 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 = Bzm2BringupPlan::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/asic/bzm2/pnp.rs b/mujina-miner/src/asic/bzm2/pnp.rs deleted file mode 100644 index ffa6fbe7..00000000 --- a/mujina-miner/src/asic/bzm2/pnp.rs +++ /dev/null @@ -1,998 +0,0 @@ -use serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; - -const CALI_VOLTAGE_MV: u32 = 50; -const CALI_FREQ_MHZ: f32 = 25.0; -const CALI_PASS_RATE_STEP: f32 = 0.0025; -const TARGET_VOLTAGE_MAX_MV: u32 = 21_000; -const TARGET_VOLTAGE_MIN_MV: u32 = 16_950; -const TARGET_FREQ_MAX_MHZ: f32 = 2_000.0; -const TARGET_FREQ_MIN_MHZ: f32 = 800.0; -const TARGET_FREQ_HIGH_PLUS_MHZ: f32 = 1_312.5; -const TARGET_FREQ_HIGH_MHZ: f32 = 1_200.0; -const TARGET_FREQ_BALANCED_MHZ: f32 = 1_150.0; -const TARGET_FREQ_LOW_MHZ: f32 = 1_000.0; -const FREQ_RANGE_MHZ: f32 = 100.0; -const MAX_FREQ_RANGE_MHZ: f32 = 150.0; -const ACCEPT_RATIO_BAND_MAX_THROUGHPUT: f32 = 0.02; -const ACCEPT_RATIO_BAND_STANDARD: f32 = 0.02; -const ACCEPT_RATIO_BAND_EFFICIENCY: f32 = 0.02; -const MIN_ACCEPT_RATIO: f32 = 0.90; -const DESIRED_ACCEPT_RATIO_MAX_THROUGHPUT: f32 = 0.975; -const DESIRED_ACCEPT_RATIO_STANDARD: f32 = 0.975; -const DESIRED_ACCEPT_RATIO_EFFICIENCY: f32 = 0.975; -const STARTUP_VOLTAGE_BIAS_MV: i32 = 50; -const SITE_TEMP_COLD_SOAK_C: f32 = -2.5; -const SITE_TEMP_COOL_C: f32 = 7.5; -const SITE_TEMP_NOMINAL_C: f32 = 17.5; -const SITE_TEMP_WARM_C: f32 = 27.5; -const DEFAULT_THERMAL_THRESHOLD_C: f32 = 100.0; -const DEFAULT_AVG_THERMAL_THRESHOLD_C: f32 = 85.0; -const DEFAULT_CURRENT_THRESHOLD_A: f32 = 260.0; -const DEFAULT_POWER_THRESHOLD_W: f32 = 4_900.0; -const DEFAULT_FREQ_INCREASE_RATIO_HIGH: f32 = 0.28; -const DEFAULT_FREQ_INCREASE_RATIO_LOW: f32 = 0.24; -const DEFAULT_RECALIBRATE_THROUGHPUT_RATIO: f32 = 0.80; -const NOMINAL_ACTIVE_ENGINE_COUNT: u16 = 236; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -pub enum Bzm2PerformanceMode { - MaxThroughput, - Standard, - Efficiency, -} - -impl Bzm2PerformanceMode { - fn pass_rate_range(self) -> f32 { - match self { - Self::MaxThroughput => ACCEPT_RATIO_BAND_MAX_THROUGHPUT, - Self::Standard => ACCEPT_RATIO_BAND_STANDARD, - Self::Efficiency => ACCEPT_RATIO_BAND_EFFICIENCY, - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Bzm2OperatingClass { - Generic, - EarlyValidation, - ProductionValidation, - StackTunedA, - StackTunedB, - ExtendedHeadroom, - ExtendedHeadroomB, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub struct Bzm2CalibrationMode { - pub sweep_strategy: bool, - pub sweep_voltage: bool, - pub sweep_frequency: bool, - pub sweep_pass_rate: bool, -} - -#[derive(Debug, Clone)] -pub struct Bzm2CalibrationConstraints { - pub max_power_w: f32, - pub max_current_a: f32, - pub max_thermal_c: f32, - pub max_avg_thermal_c: f32, - pub freq_range_mhz: f32, - pub max_freq_range_mhz: f32, - pub recalibrate_throughput_ratio: f32, -} - -impl Default for Bzm2CalibrationConstraints { - fn default() -> Self { - Self { - max_power_w: DEFAULT_POWER_THRESHOLD_W, - max_current_a: DEFAULT_CURRENT_THRESHOLD_A, - max_thermal_c: DEFAULT_THERMAL_THRESHOLD_C, - max_avg_thermal_c: DEFAULT_AVG_THERMAL_THRESHOLD_C, - freq_range_mhz: FREQ_RANGE_MHZ, - max_freq_range_mhz: MAX_FREQ_RANGE_MHZ, - recalibrate_throughput_ratio: DEFAULT_RECALIBRATE_THROUGHPUT_RATIO, - } - } -} - -#[derive(Debug, Clone)] -pub struct Bzm2CalibrationSweepRequest { - pub operating_class: Bzm2OperatingClass, - pub target_mode: Bzm2PerformanceMode, - pub mode: Bzm2CalibrationMode, - pub voltage_steps: u8, - pub frequency_steps: u8, - pub pass_rate_steps: u8, -} - -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct Bzm2SavedOperatingPoint { - pub board_voltage_mv: u32, - pub board_throughput_ths: f32, - #[serde(default)] - pub per_domain_voltage_mv: BTreeMap, - #[serde(default)] - pub per_asic_engine_topology: BTreeMap, - pub per_asic_pll_mhz: BTreeMap, -} - -#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)] -pub struct Bzm2SavedEngineCoordinate { - pub row: u8, - pub col: u8, -} - -#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)] -pub struct Bzm2SavedEngineTopology { - #[serde(default)] - pub active_engine_count: u16, - #[serde(default)] - pub missing_engines: Vec, -} - -#[derive(Debug, Clone)] -pub struct Bzm2AsicTopology { - pub asic_id: u16, - pub domain_id: u16, - pub pll_count: usize, - pub alive: bool, - pub active_engine_count: u16, - pub missing_engines: Vec, -} - -#[derive(Debug, Clone)] -pub struct Bzm2VoltageDomain { - pub domain_id: u16, - pub asic_ids: Vec, - pub voltage_offset_mv: i32, - pub max_power_w: Option, -} - -#[derive(Debug, Clone, Default)] -pub struct Bzm2DomainMeasurement { - pub domain_id: u16, - pub measured_voltage_mv: Option, - pub measured_power_w: Option, -} - -#[derive(Debug, Clone, Default)] -pub struct Bzm2AsicMeasurement { - pub asic_id: u16, - pub temperature_c: Option, - pub throughput_ths: Option, - pub average_pass_rate: Option, - pub pll_pass_rates: [Option; 2], -} - -#[derive(Debug, Clone)] -pub struct Bzm2BoardCalibrationInput { - pub operating_class: Bzm2OperatingClass, - pub site_temp_c: f32, - pub target_mode: Bzm2PerformanceMode, - pub mode: Bzm2CalibrationMode, - pub per_stack_clocking: bool, - pub voltage_domains: Vec, - pub asics: Vec, - pub saved_operating_point: Option, - pub domain_measurements: Vec, - pub asic_measurements: Vec, - pub constraints: Bzm2CalibrationConstraints, - pub force_retune: bool, -} - -#[derive(Debug, Clone)] -pub struct Bzm2DomainPlan { - pub domain_id: u16, - pub voltage_mv: u32, - pub average_frequency_mhz: f32, - pub guarded: bool, - pub notes: Vec, -} - -#[derive(Debug, Clone)] -pub struct Bzm2AsicPlan { - pub asic_id: u16, - pub domain_id: u16, - pub pll_frequencies_mhz: [f32; 2], - pub notes: Vec, -} - -#[derive(Debug, Clone)] -pub struct Bzm2CalibrationPlan { - pub reuse_saved_operating_point: bool, - pub needs_retune: bool, - pub desired_voltage_mv: u32, - pub desired_clock_mhz: f32, - pub desired_accept_ratio: f32, - pub initial_voltage_mv: u32, - pub initial_frequency_mhz: f32, - pub freq_increase_threshold_mhz: f32, - pub search_space: Vec, - pub domain_plans: Vec, - pub asic_plans: Vec, - pub notes: Vec, -} - -#[derive(Debug, Clone)] -pub struct Bzm2ParameterSet { - pub mode: Bzm2PerformanceMode, - pub desired_voltage_mv: u32, - pub desired_clock_mhz: f32, - pub desired_accept_ratio: f32, -} - -#[derive(Debug, Default)] -pub struct Bzm2CalibrationPlanner; - -impl Bzm2CalibrationPlanner { - pub fn build_search_space( - &self, - request: &Bzm2CalibrationSweepRequest, - ) -> Vec { - let modes = if request.mode.sweep_strategy { - vec![ - Bzm2PerformanceMode::MaxThroughput, - Bzm2PerformanceMode::Standard, - Bzm2PerformanceMode::Efficiency, - ] - } else { - vec![request.target_mode] - }; - - let mut parameters = Vec::new(); - for mode in modes { - let target = operating_targets(request.operating_class, mode); - let voltage_offsets = build_offsets(request.mode.sweep_voltage, request.voltage_steps); - let frequency_offsets = - build_frequency_offsets(request.mode.sweep_frequency, request.frequency_steps); - let pass_rate_offsets = - build_pass_rate_offsets(request.mode.sweep_pass_rate, request.pass_rate_steps); - - for voltage_offset in &voltage_offsets { - for frequency_offset in &frequency_offsets { - for pass_rate_offset in &pass_rate_offsets { - parameters.push(Bzm2ParameterSet { - mode, - desired_voltage_mv: clamp_voltage(apply_i32( - target.voltage_mv, - *voltage_offset, - )), - desired_clock_mhz: clamp_frequency( - target.frequency_mhz + *frequency_offset, - ), - desired_accept_ratio: clamp_pass_rate( - target.pass_rate + *pass_rate_offset, - ), - }); - } - } - } - } - - parameters.sort_by(|a, b| { - a.mode - .cmp(&b.mode) - .then(a.desired_voltage_mv.cmp(&b.desired_voltage_mv)) - .then_with(|| a.desired_clock_mhz.total_cmp(&b.desired_clock_mhz)) - .then_with(|| a.desired_accept_ratio.total_cmp(&b.desired_accept_ratio)) - }); - parameters.dedup_by(|a, b| { - a.mode == b.mode - && a.desired_voltage_mv == b.desired_voltage_mv - && (a.desired_clock_mhz - b.desired_clock_mhz).abs() < f32::EPSILON - && (a.desired_accept_ratio - b.desired_accept_ratio).abs() < f32::EPSILON - }); - parameters - } - - pub fn plan(&self, input: &Bzm2BoardCalibrationInput) -> Bzm2CalibrationPlan { - let target = operating_targets(input.operating_class, input.target_mode); - let search_space = self.build_search_space(&Bzm2CalibrationSweepRequest { - operating_class: input.operating_class, - target_mode: input.target_mode, - mode: input.mode, - voltage_steps: 4, - frequency_steps: 4, - pass_rate_steps: 2, - }); - - let current_throughput = input - .asic_measurements - .iter() - .filter_map(|asic| asic.throughput_ths) - .sum::(); - let has_live_throughput = input - .asic_measurements - .iter() - .any(|asic| asic.throughput_ths.is_some()); - let live_active_engines = total_active_engine_count(&input.asics); - let reuse_saved_operating_point = - input.saved_operating_point.as_ref().is_some_and(|stored| { - let current_normalized_throughput = - normalize_throughput(current_throughput, live_active_engines); - let stored_normalized_throughput = normalize_throughput( - stored.board_throughput_ths, - stored_total_active_engine_count( - stored, - input.asics.iter().filter(|asic| asic.alive).count(), - ), - ); - !input.force_retune - && stored.per_asic_pll_mhz.len() - == input.asics.iter().filter(|asic| asic.alive).count() - && (!has_live_throughput - || current_normalized_throughput - >= stored_normalized_throughput - * input.constraints.recalibrate_throughput_ratio) - }); - let needs_retune = input.force_retune - || (has_live_throughput - && input.saved_operating_point.as_ref().is_some_and(|stored| { - normalize_throughput(current_throughput, live_active_engines) - < normalize_throughput( - stored.board_throughput_ths, - stored_total_active_engine_count( - stored, - input.asics.iter().filter(|asic| asic.alive).count(), - ), - ) * input.constraints.recalibrate_throughput_ratio - })); - - let (initial_voltage_mv, freq_increase_threshold_mhz) = initial_voltage_and_threshold( - target.voltage_mv, - input.site_temp_c, - input.mode.sweep_frequency, - ); - let initial_frequency_mhz = clamp_frequency( - (target.frequency_mhz - input.constraints.freq_range_mhz).max(TARGET_FREQ_MIN_MHZ), - ); - - let domain_measurements: BTreeMap = input - .domain_measurements - .iter() - .map(|measurement| (measurement.domain_id, measurement)) - .collect(); - let asic_measurements: BTreeMap = input - .asic_measurements - .iter() - .map(|measurement| (measurement.asic_id, measurement)) - .collect(); - - let mut domain_plans = Vec::new(); - let mut asic_plans = Vec::new(); - let mut notes = Vec::new(); - - for domain in &input.voltage_domains { - let domain_target_voltage = - clamp_voltage(apply_i32(initial_voltage_mv, domain.voltage_offset_mv)); - let domain_power = domain_measurements - .get(&domain.domain_id) - .and_then(|measurement| measurement.measured_power_w) - .unwrap_or_default(); - let domain_guarded = domain.max_power_w.is_some_and(|limit| domain_power > limit) - || domain_power > input.constraints.max_power_w; - - let domain_asics: Vec<&Bzm2AsicTopology> = input - .asics - .iter() - .filter(|asic| asic.alive && asic.domain_id == domain.domain_id) - .collect(); - let domain_avg_temp = average( - domain_asics - .iter() - .filter_map(|asic| asic_measurements.get(&asic.asic_id)) - .filter_map(|measurement| measurement.temperature_c), - ); - let domain_avg_pass_rate = average( - domain_asics - .iter() - .filter_map(|asic| asic_measurements.get(&asic.asic_id)) - .filter_map(|measurement| measurement.average_pass_rate), - ); - - let mut domain_frequency = initial_frequency_mhz; - let mut domain_notes = Vec::new(); - if let Some(pass_rate) = domain_avg_pass_rate { - if pass_rate >= target.pass_rate && !domain_guarded { - domain_frequency = clamp_frequency( - target - .frequency_mhz - .min(initial_frequency_mhz + input.constraints.max_freq_range_mhz), - ); - domain_notes.push(format!( - "domain average pass rate {:.2}% supports target frequency", - pass_rate * 100.0 - )); - } else { - domain_notes.push(format!( - "domain average pass rate {:.2}% below target {:.2}%", - pass_rate * 100.0, - target.pass_rate * 100.0 - )); - } - } - if let Some(temp) = domain_avg_temp { - if temp >= input.constraints.max_avg_thermal_c { - domain_frequency = clamp_frequency(domain_frequency - CALI_FREQ_MHZ); - domain_notes.push(format!( - "domain average temperature {:.1}C triggered thermal guard", - temp - )); - } - } - if domain_guarded { - domain_frequency = clamp_frequency(domain_frequency - CALI_FREQ_MHZ); - domain_notes.push("domain power guard active".into()); - } - - domain_plans.push(Bzm2DomainPlan { - domain_id: domain.domain_id, - voltage_mv: domain_target_voltage, - average_frequency_mhz: domain_frequency, - guarded: domain_guarded, - notes: domain_notes.clone(), - }); - - for asic in domain_asics { - let measurement = asic_measurements.get(&asic.asic_id).copied(); - let mut pll_frequencies = [domain_frequency; 2]; - let mut asic_notes = Vec::new(); - - if reuse_saved_operating_point { - if let Some(stored) = input - .saved_operating_point - .as_ref() - .and_then(|stored| stored.per_asic_pll_mhz.get(&asic.asic_id)) - { - pll_frequencies = *stored; - asic_notes.push("reusing stored per-ASIC calibration".into()); - } - } else if let Some(measurement) = measurement { - if let Some(temp) = measurement.temperature_c { - if temp >= input.constraints.max_thermal_c { - pll_frequencies = - [clamp_frequency(domain_frequency - CALI_FREQ_MHZ); 2]; - asic_notes.push(format!( - "ASIC temperature {:.1}C exceeded thermal threshold", - temp - )); - } - } - - if input.per_stack_clocking { - for (pll_index, pass_rate) in measurement.pll_pass_rates.iter().enumerate() - { - if let Some(pass_rate) = pass_rate { - let low = target.pass_rate - input.target_mode.pass_rate_range(); - let high = target.pass_rate + input.target_mode.pass_rate_range(); - if *pass_rate < low { - pll_frequencies[pll_index] = - clamp_frequency(pll_frequencies[pll_index] - CALI_FREQ_MHZ); - asic_notes.push(format!( - "PLL {} pass rate {:.2}% below window", - pll_index, - pass_rate * 100.0 - )); - } else if *pass_rate > high && !domain_guarded { - pll_frequencies[pll_index] = clamp_frequency( - pll_frequencies[pll_index] + CALI_FREQ_MHZ / 2.0, - ); - asic_notes.push(format!( - "PLL {} pass rate {:.2}% above window", - pll_index, - pass_rate * 100.0 - )); - } - } - } - } else if let Some(pass_rate) = measurement.average_pass_rate { - if pass_rate < target.pass_rate - input.target_mode.pass_rate_range() { - pll_frequencies = - [clamp_frequency(domain_frequency - CALI_FREQ_MHZ); 2]; - asic_notes.push(format!( - "ASIC pass rate {:.2}% below target window", - pass_rate * 100.0 - )); - } - } - } - - if domain_guarded { - asic_notes.push("bounded by domain power guard".into()); - } - if asic.active_engine_count < NOMINAL_ACTIVE_ENGINE_COUNT { - asic_notes.push(format!( - "ASIC has {} active engines and {} missing coordinates", - asic.active_engine_count, - asic.missing_engines.len() - )); - } - - asic_plans.push(Bzm2AsicPlan { - asic_id: asic.asic_id, - domain_id: asic.domain_id, - pll_frequencies_mhz: pll_frequencies, - notes: asic_notes, - }); - } - } - - if reuse_saved_operating_point { - notes.push("saved operating point is consistent with current throughput".into()); - } else if needs_retune { - notes.push( - "saved operating point is missing or underperforming; full retune required".into(), - ); - } else { - notes.push("building fresh domain-aware calibration plan".into()); - } - - if live_active_engines < total_nominal_engine_capacity(&input.asics) { - notes.push(format!( - "tuning normalized throughput against {:.1}% active engine capacity", - (live_active_engines as f32 / total_nominal_engine_capacity(&input.asics) as f32) - * 100.0 - )); - } - - if input.voltage_domains.len() > 1 { - notes.push("domain-first planning enabled for multi-domain hardware".into()); - } - if input.asics.len() >= 100 { - notes.push("planner uses one domain aggregation pass and one ASIC tuning pass".into()); - } - - Bzm2CalibrationPlan { - reuse_saved_operating_point, - needs_retune, - desired_voltage_mv: target.voltage_mv, - desired_clock_mhz: target.frequency_mhz, - desired_accept_ratio: target.pass_rate, - initial_voltage_mv, - initial_frequency_mhz, - freq_increase_threshold_mhz, - search_space, - domain_plans, - asic_plans, - notes, - } - } -} - -fn total_active_engine_count(asics: &[Bzm2AsicTopology]) -> u32 { - asics - .iter() - .filter(|asic| asic.alive) - .map(|asic| u32::from(asic.active_engine_count.max(1))) - .sum::() - .max(1) -} - -fn total_nominal_engine_capacity(asics: &[Bzm2AsicTopology]) -> u32 { - (asics.iter().filter(|asic| asic.alive).count() as u32 * u32::from(NOMINAL_ACTIVE_ENGINE_COUNT)) - .max(1) -} - -fn stored_total_active_engine_count(stored: &Bzm2SavedOperatingPoint, alive_asics: usize) -> u32 { - if stored.per_asic_engine_topology.is_empty() { - return (alive_asics as u32 * u32::from(NOMINAL_ACTIVE_ENGINE_COUNT)).max(1); - } - - stored - .per_asic_engine_topology - .values() - .map(|topology| u32::from(topology.active_engine_count.max(1))) - .sum::() - .max(1) -} - -fn normalize_throughput(throughput_ths: f32, active_engine_count: u32) -> f32 { - throughput_ths / active_engine_count.max(1) as f32 -} - -#[derive(Debug, Clone, Copy)] -struct OperatingTarget { - voltage_mv: u32, - frequency_mhz: f32, - pass_rate: f32, -} - -fn operating_targets( - operating_class: Bzm2OperatingClass, - mode: Bzm2PerformanceMode, -) -> OperatingTarget { - let (high_voltage, balanced_voltage, low_voltage, high_freq) = match operating_class { - Bzm2OperatingClass::Generic => (17_600, 17_500, 17_150, TARGET_FREQ_HIGH_MHZ), - Bzm2OperatingClass::EarlyValidation => (17_800, 17_700, 17_350, TARGET_FREQ_HIGH_MHZ), - Bzm2OperatingClass::ProductionValidation => (17_550, 17_450, 17_100, TARGET_FREQ_HIGH_MHZ), - Bzm2OperatingClass::StackTunedA => (17_300, 17_200, 16_850, TARGET_FREQ_HIGH_MHZ), - Bzm2OperatingClass::StackTunedB => (17_600, 17_500, 17_150, TARGET_FREQ_HIGH_MHZ), - Bzm2OperatingClass::ExtendedHeadroom => (17_900, 17_450, 17_100, TARGET_FREQ_HIGH_PLUS_MHZ), - Bzm2OperatingClass::ExtendedHeadroomB => { - (18_050, 17_550, 17_150, TARGET_FREQ_HIGH_PLUS_MHZ) - } - }; - - match mode { - Bzm2PerformanceMode::MaxThroughput => OperatingTarget { - voltage_mv: high_voltage, - frequency_mhz: high_freq, - pass_rate: DESIRED_ACCEPT_RATIO_MAX_THROUGHPUT, - }, - Bzm2PerformanceMode::Standard => OperatingTarget { - voltage_mv: balanced_voltage, - frequency_mhz: TARGET_FREQ_BALANCED_MHZ, - pass_rate: DESIRED_ACCEPT_RATIO_STANDARD, - }, - Bzm2PerformanceMode::Efficiency => OperatingTarget { - voltage_mv: low_voltage, - frequency_mhz: TARGET_FREQ_LOW_MHZ, - pass_rate: DESIRED_ACCEPT_RATIO_EFFICIENCY, - }, - } -} - -fn build_offsets(enabled: bool, steps: u8) -> Vec { - if !enabled { - return vec![0]; - } - - let steps = steps.min(20) as i32; - (-steps..=steps) - .map(|step| step * CALI_VOLTAGE_MV as i32) - .collect() -} - -fn build_frequency_offsets(enabled: bool, steps: u8) -> Vec { - if !enabled { - return vec![0.0]; - } - - let steps = steps.min(16) as i32; - (-steps..=steps) - .map(|step| step as f32 * CALI_FREQ_MHZ) - .collect() -} - -fn build_pass_rate_offsets(enabled: bool, steps: u8) -> Vec { - if !enabled { - return vec![0.0]; - } - - let steps = steps.min(4) as i32; - (-steps..=steps) - .map(|step| step as f32 * CALI_PASS_RATE_STEP) - .collect() -} - -fn initial_voltage_and_threshold( - desired_voltage_mv: u32, - site_temp_c: f32, - frequency_mode: bool, -) -> (u32, f32) { - let (offset, ratio) = if site_temp_c < SITE_TEMP_COLD_SOAK_C { - (STARTUP_VOLTAGE_BIAS_MV * 2, DEFAULT_FREQ_INCREASE_RATIO_LOW) - } else if site_temp_c < SITE_TEMP_COOL_C { - (STARTUP_VOLTAGE_BIAS_MV, DEFAULT_FREQ_INCREASE_RATIO_HIGH) - } else if site_temp_c < SITE_TEMP_NOMINAL_C { - (0, DEFAULT_FREQ_INCREASE_RATIO_HIGH) - } else if site_temp_c < SITE_TEMP_WARM_C { - (-STARTUP_VOLTAGE_BIAS_MV, DEFAULT_FREQ_INCREASE_RATIO_HIGH) - } else { - ( - -STARTUP_VOLTAGE_BIAS_MV * 2, - DEFAULT_FREQ_INCREASE_RATIO_LOW, - ) - }; - - let threshold = if frequency_mode { - CALI_FREQ_MHZ * DEFAULT_FREQ_INCREASE_RATIO_LOW - } else { - CALI_FREQ_MHZ * ratio - }; - - ( - clamp_voltage(apply_i32(desired_voltage_mv, offset)), - threshold, - ) -} - -fn clamp_voltage(voltage_mv: u32) -> u32 { - voltage_mv.clamp(TARGET_VOLTAGE_MIN_MV, TARGET_VOLTAGE_MAX_MV) -} - -fn clamp_frequency(frequency_mhz: f32) -> f32 { - frequency_mhz.clamp(TARGET_FREQ_MIN_MHZ, TARGET_FREQ_MAX_MHZ) -} - -fn clamp_pass_rate(pass_rate: f32) -> f32 { - pass_rate.clamp(MIN_ACCEPT_RATIO, DESIRED_ACCEPT_RATIO_MAX_THROUGHPUT) -} - -fn apply_i32(value: u32, offset: i32) -> u32 { - if offset >= 0 { - value.saturating_add(offset as u32) - } else { - value.saturating_sub(offset.unsigned_abs()) - } -} - -fn average(values: impl Iterator) -> Option { - let mut total = 0.0; - let mut count = 0usize; - for value in values { - total += value; - count += 1; - } - (count > 0).then_some(total / count as f32) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn search_space_expands_requested_axes() { - let planner = Bzm2CalibrationPlanner; - let parameters = planner.build_search_space(&Bzm2CalibrationSweepRequest { - operating_class: Bzm2OperatingClass::Generic, - target_mode: Bzm2PerformanceMode::Standard, - mode: Bzm2CalibrationMode { - sweep_strategy: true, - sweep_voltage: true, - sweep_frequency: true, - sweep_pass_rate: true, - }, - voltage_steps: 1, - frequency_steps: 1, - pass_rate_steps: 1, - }); - - assert!(parameters.len() > 20); - assert!( - parameters - .iter() - .any(|p| p.mode == Bzm2PerformanceMode::MaxThroughput) - ); - assert!(parameters.iter().any(|p| p.desired_voltage_mv < 17_500)); - assert!( - parameters - .iter() - .any(|p| p.desired_clock_mhz > TARGET_FREQ_BALANCED_MHZ) - ); - } - - #[test] - fn single_asic_plan_prefers_saved_operating_point_when_consistent() { - let planner = Bzm2CalibrationPlanner; - let mut stored = BTreeMap::new(); - stored.insert(0, [1_075.0, 1_075.0]); - let plan = planner.plan(&Bzm2BoardCalibrationInput { - operating_class: Bzm2OperatingClass::Generic, - site_temp_c: 15.0, - target_mode: Bzm2PerformanceMode::Standard, - mode: Bzm2CalibrationMode::default(), - per_stack_clocking: false, - voltage_domains: vec![Bzm2VoltageDomain { - domain_id: 0, - asic_ids: vec![0], - voltage_offset_mv: 0, - max_power_w: None, - }], - asics: vec![Bzm2AsicTopology { - asic_id: 0, - domain_id: 0, - pll_count: 2, - alive: true, - active_engine_count: NOMINAL_ACTIVE_ENGINE_COUNT, - missing_engines: Vec::new(), - }], - saved_operating_point: Some(Bzm2SavedOperatingPoint { - board_voltage_mv: 17_500, - board_throughput_ths: 42.0, - per_domain_voltage_mv: BTreeMap::new(), - per_asic_engine_topology: BTreeMap::new(), - per_asic_pll_mhz: stored, - }), - domain_measurements: vec![Bzm2DomainMeasurement { - domain_id: 0, - measured_voltage_mv: Some(17_480), - measured_power_w: Some(320.0), - }], - asic_measurements: vec![Bzm2AsicMeasurement { - asic_id: 0, - temperature_c: Some(72.0), - throughput_ths: Some(40.0), - average_pass_rate: Some(0.98), - pll_pass_rates: [Some(0.98), Some(0.98)], - }], - constraints: Bzm2CalibrationConstraints::default(), - force_retune: false, - }); - - assert!(plan.reuse_saved_operating_point); - assert!(!plan.needs_retune); - assert_eq!(plan.asic_plans[0].pll_frequencies_mhz, [1_075.0, 1_075.0]); - } - - #[test] - fn planner_requests_retune_when_throughput_drops() { - let planner = Bzm2CalibrationPlanner; - let mut stored = BTreeMap::new(); - stored.insert(0, [1_150.0, 1_150.0]); - let plan = planner.plan(&Bzm2BoardCalibrationInput { - operating_class: Bzm2OperatingClass::Generic, - site_temp_c: 20.0, - target_mode: Bzm2PerformanceMode::Standard, - mode: Bzm2CalibrationMode::default(), - per_stack_clocking: false, - voltage_domains: vec![Bzm2VoltageDomain { - domain_id: 0, - asic_ids: vec![0], - voltage_offset_mv: 0, - max_power_w: None, - }], - asics: vec![Bzm2AsicTopology { - asic_id: 0, - domain_id: 0, - pll_count: 2, - alive: true, - active_engine_count: NOMINAL_ACTIVE_ENGINE_COUNT, - missing_engines: Vec::new(), - }], - saved_operating_point: Some(Bzm2SavedOperatingPoint { - board_voltage_mv: 17_500, - board_throughput_ths: 50.0, - per_domain_voltage_mv: BTreeMap::new(), - per_asic_engine_topology: BTreeMap::new(), - per_asic_pll_mhz: stored, - }), - domain_measurements: vec![], - asic_measurements: vec![Bzm2AsicMeasurement { - asic_id: 0, - temperature_c: Some(74.0), - throughput_ths: Some(20.0), - average_pass_rate: Some(0.94), - pll_pass_rates: [Some(0.94), Some(0.94)], - }], - constraints: Bzm2CalibrationConstraints::default(), - force_retune: false, - }); - - assert!(!plan.reuse_saved_operating_point); - assert!(plan.needs_retune); - } - - #[test] - fn planner_normalizes_saved_throughput_by_active_engine_capacity() { - let planner = Bzm2CalibrationPlanner; - let mut stored = BTreeMap::new(); - stored.insert(0, [1_075.0, 1_075.0]); - let plan = planner.plan(&Bzm2BoardCalibrationInput { - operating_class: Bzm2OperatingClass::Generic, - site_temp_c: 15.0, - target_mode: Bzm2PerformanceMode::Standard, - mode: Bzm2CalibrationMode::default(), - per_stack_clocking: false, - voltage_domains: vec![Bzm2VoltageDomain { - domain_id: 0, - asic_ids: vec![0], - voltage_offset_mv: 0, - max_power_w: None, - }], - asics: vec![Bzm2AsicTopology { - asic_id: 0, - domain_id: 0, - pll_count: 2, - alive: true, - active_engine_count: NOMINAL_ACTIVE_ENGINE_COUNT / 2, - missing_engines: vec![Bzm2SavedEngineCoordinate { row: 0, col: 1 }], - }], - saved_operating_point: Some(Bzm2SavedOperatingPoint { - board_voltage_mv: 17_500, - board_throughput_ths: 42.0, - per_domain_voltage_mv: BTreeMap::new(), - per_asic_engine_topology: BTreeMap::from([( - 0, - Bzm2SavedEngineTopology { - active_engine_count: NOMINAL_ACTIVE_ENGINE_COUNT, - missing_engines: Vec::new(), - }, - )]), - per_asic_pll_mhz: stored, - }), - domain_measurements: vec![], - asic_measurements: vec![Bzm2AsicMeasurement { - asic_id: 0, - temperature_c: Some(70.0), - throughput_ths: Some(21.0), - average_pass_rate: Some(0.98), - pll_pass_rates: [Some(0.98), Some(0.98)], - }], - constraints: Bzm2CalibrationConstraints::default(), - force_retune: false, - }); - - assert!(plan.reuse_saved_operating_point); - assert!(!plan.needs_retune); - assert!( - plan.notes - .iter() - .any(|note| note.contains("active engine capacity")) - ); - } - - #[test] - fn multi_domain_plan_scales_to_large_topology() { - let planner = Bzm2CalibrationPlanner; - let domains: Vec = (0..25) - .map(|domain_id| Bzm2VoltageDomain { - domain_id, - asic_ids: (0..4).map(|offset| domain_id * 4 + offset).collect(), - voltage_offset_mv: if domain_id % 2 == 0 { 0 } else { 25 }, - max_power_w: Some(450.0), - }) - .collect(); - let asics: Vec = (0..100) - .map(|asic_id| Bzm2AsicTopology { - asic_id, - domain_id: asic_id / 4, - pll_count: 2, - alive: true, - active_engine_count: NOMINAL_ACTIVE_ENGINE_COUNT, - missing_engines: Vec::new(), - }) - .collect(); - let domain_measurements: Vec = (0..25) - .map(|domain_id| Bzm2DomainMeasurement { - domain_id, - measured_voltage_mv: Some(17_450), - measured_power_w: Some(if domain_id == 3 { 500.0 } else { 300.0 }), - }) - .collect(); - let asic_measurements: Vec = (0..100) - .map(|asic_id| Bzm2AsicMeasurement { - asic_id, - temperature_c: Some(if asic_id == 13 { 101.0 } else { 74.0 }), - throughput_ths: Some(0.4), - average_pass_rate: Some(if asic_id % 9 == 0 { 0.93 } else { 0.98 }), - pll_pass_rates: [Some(0.97), Some(0.98)], - }) - .collect(); - - let plan = planner.plan(&Bzm2BoardCalibrationInput { - operating_class: Bzm2OperatingClass::ExtendedHeadroom, - site_temp_c: 10.0, - target_mode: Bzm2PerformanceMode::Standard, - mode: Bzm2CalibrationMode::default(), - per_stack_clocking: true, - voltage_domains: domains, - asics, - saved_operating_point: None, - domain_measurements, - asic_measurements, - constraints: Bzm2CalibrationConstraints::default(), - force_retune: false, - }); - - assert_eq!(plan.domain_plans.len(), 25); - assert_eq!(plan.asic_plans.len(), 100); - assert!( - plan.domain_plans - .iter() - .find(|domain| domain.domain_id == 3) - .unwrap() - .guarded - ); - assert!( - plan.asic_plans - .iter() - .find(|asic| asic.asic_id == 13) - .unwrap() - .pll_frequencies_mhz[0] - < plan.initial_frequency_mhz + 1.0 - ); - assert!(plan.notes.iter().any(|note| note.contains("domain-first"))); - } -} From 2748397ab417bbce88cc00ccb23d8471f03689ef Mon Sep 17 00:00:00 2001 From: Reckless Apotheosis <125509978+recklessnode@users.noreply.github.com> Date: Wed, 18 Mar 2026 20:12:22 -0700 Subject: [PATCH 21/24] fix(bzm2): align rebased branch with upstream interfaces --- mujina-miner/src/asic/bzm2/thread.rs | 9 +++++--- mujina-miner/src/board/bzm2.rs | 19 +++++++++-------- mujina-miner/src/board/mod.rs | 31 ++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 12 deletions(-) diff --git a/mujina-miner/src/asic/bzm2/thread.rs b/mujina-miner/src/asic/bzm2/thread.rs index 11858a76..fd831b44 100644 --- a/mujina-miner/src/asic/bzm2/thread.rs +++ b/mujina-miner/src/asic/bzm2/thread.rs @@ -498,7 +498,7 @@ impl HashThread for Bzm2Thread { async fn update_task( &mut self, new_task: HashTask, - ) -> Result, HashThreadError> { + ) -> anyhow::Result> { let (response_tx, response_rx) = oneshot::channel(); self.command_tx .send(ThreadCommand::UpdateTask { @@ -510,12 +510,13 @@ impl HashThread for Bzm2Thread { response_rx .await .map_err(|_| HashThreadError::WorkAssignmentFailed("thread dropped response".into()))? + .map_err(Into::into) } async fn replace_task( &mut self, new_task: HashTask, - ) -> Result, HashThreadError> { + ) -> anyhow::Result> { let (response_tx, response_rx) = oneshot::channel(); self.command_tx .send(ThreadCommand::ReplaceTask { @@ -527,9 +528,10 @@ impl HashThread for Bzm2Thread { response_rx .await .map_err(|_| HashThreadError::WorkAssignmentFailed("thread dropped response".into()))? + .map_err(Into::into) } - async fn go_idle(&mut self) -> Result, HashThreadError> { + async fn go_idle(&mut self) -> anyhow::Result> { let (response_tx, response_rx) = oneshot::channel(); self.command_tx .send(ThreadCommand::GoIdle { response_tx }) @@ -538,6 +540,7 @@ impl HashThread for Bzm2Thread { response_rx .await .map_err(|_| HashThreadError::WorkAssignmentFailed("thread dropped response".into()))? + .map_err(Into::into) } fn take_event_receiver(&mut self) -> Option> { diff --git a/mujina-miner/src/board/bzm2.rs b/mujina-miner/src/board/bzm2.rs index 56864592..5522296c 100644 --- a/mujina-miner/src/board/bzm2.rs +++ b/mujina-miner/src/board/bzm2.rs @@ -5,6 +5,7 @@ use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use std::time::Duration; +use anyhow::Result as AnyhowResult; use async_trait::async_trait; use serde::{Deserialize, Serialize}; use tokio::sync::{mpsc, watch}; @@ -22,8 +23,8 @@ use crate::{ Bzm2ThreadHandle, Bzm2ThreadRuntimeMetrics, Bzm2UartController, }, hash_thread::{ - HashTask, HashThread, HashThreadCapabilities, HashThreadError, HashThreadEvent, - HashThreadStatus, HashThreadTelemetryUpdate, + HashTask, HashThread, HashThreadCapabilities, HashThreadEvent, HashThreadStatus, + HashThreadTelemetryUpdate, }, }, board::power::{ @@ -1832,7 +1833,7 @@ impl Board for Bzm2Board { } } - async fn shutdown(&mut self) -> Result<(), BoardError> { + async fn shutdown(&mut self) -> AnyhowResult<()> { if let Some(tx) = self.monitor_shutdown.take() { let _ = tx.send(true); } @@ -1861,7 +1862,7 @@ impl Board for Bzm2Board { Ok(()) } - async fn create_hash_threads(&mut self) -> Result>, BoardError> { + async fn create_hash_threads(&mut self) -> AnyhowResult>> { let mut threads: Vec> = Vec::new(); let mut thread_states = Vec::new(); self.apply_bringup_sequence().await?; @@ -1967,7 +1968,7 @@ impl HashThread for Bzm2ManagedThread { async fn update_task( &mut self, new_task: HashTask, - ) -> Result, HashThreadError> { + ) -> AnyhowResult> { let result = self.inner.update_task(new_task).await; self.publish_status(&self.inner.status()); result @@ -1976,13 +1977,13 @@ impl HashThread for Bzm2ManagedThread { async fn replace_task( &mut self, new_task: HashTask, - ) -> Result, HashThreadError> { + ) -> AnyhowResult> { let result = self.inner.replace_task(new_task).await; self.publish_status(&self.inner.status()); result } - async fn go_idle(&mut self) -> Result, HashThreadError> { + async fn go_idle(&mut self) -> AnyhowResult> { let result = self.inner.go_idle().await; self.publish_status(&self.inner.status()); result @@ -2934,9 +2935,9 @@ fn calibration_error(serial_path: &str, err: impl std::fmt::Display) -> BoardErr } async fn create_bzm2_board() --> crate::error::Result<(Box, super::BoardRegistration)> { +-> AnyhowResult<(Box, super::BoardRegistration)> { let config = Bzm2RuntimeConfig::from_env().ok_or_else(|| { - crate::error::Error::Config("BZM2 not configured (MUJINA_BZM2_SERIAL not set)".into()) + anyhow::anyhow!("BZM2 not configured (MUJINA_BZM2_SERIAL not set)") })?; let serial = config.device_id(); diff --git a/mujina-miner/src/board/mod.rs b/mujina-miner/src/board/mod.rs index b25bc1cf..77caea61 100644 --- a/mujina-miner/src/board/mod.rs +++ b/mujina-miner/src/board/mod.rs @@ -52,6 +52,37 @@ pub struct BoardInfo { pub serial_number: Option, } +/// Board-specific errors used for board command/query paths. +#[derive(Debug)] +pub enum BoardError { + /// Hardware initialization failed + InitializationFailed(String), + /// Communication error with board + Communication(std::io::Error), + /// GPIO or hardware control error + HardwareControl(String), +} + +impl fmt::Display for BoardError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + BoardError::InitializationFailed(msg) => { + write!(f, "Board initialization failed: {}", msg) + } + BoardError::Communication(err) => write!(f, "Board communication error: {}", err), + BoardError::HardwareControl(msg) => write!(f, "Hardware control error: {}", msg), + } + } +} + +impl Error for BoardError {} + +impl From for BoardError { + fn from(err: std::io::Error) -> Self { + BoardError::Communication(err) + } +} + /// Registration data returned by board factory functions. /// /// Bundles the channels needed for the rest of the system to communicate From 897fa34e5d30b4f72cf384e8448e746d662b079a Mon Sep 17 00:00:00 2001 From: Reckless Apotheosis <125509978+recklessnode@users.noreply.github.com> Date: Fri, 3 Apr 2026 21:19:21 -0700 Subject: [PATCH 22/24] fix(bzm2): program legacy nonce window and timestamp control --- mujina-miner/src/asic/bzm2/protocol.rs | 2 + mujina-miner/src/asic/bzm2/thread.rs | 107 ++++++++++++++++++------- mujina-miner/src/board/bzm2.rs | 21 ++--- 3 files changed, 89 insertions(+), 41 deletions(-) diff --git a/mujina-miner/src/asic/bzm2/protocol.rs b/mujina-miner/src/asic/bzm2/protocol.rs index 90d8f169..578b4a64 100644 --- a/mujina-miner/src/asic/bzm2/protocol.rs +++ b/mujina-miner/src/asic/bzm2/protocol.rs @@ -13,12 +13,14 @@ pub const BROADCAST_ASIC: u8 = 0xff; pub const TARGET_BYTE: u8 = 0x08; pub const ENGINE_REG_TARGET: u8 = 0x44; +pub const ENGINE_REG_START_NONCE: u8 = 0x3c; pub const ENGINE_REG_TIMESTAMP_COUNT: u8 = 0x48; pub const ENGINE_REG_ZEROS_TO_FIND: u8 = 0x49; pub const ENGINE_REG_END_NONCE: u8 = 0x40; pub const DEFAULT_TIMESTAMP_COUNT: u8 = 60; pub const DEFAULT_NONCE_GAP: u32 = 0x28; +pub const DEFAULT_BOARD_END_NONCE: u32 = 0xffff_ffff; pub const LOGICAL_ENGINE_ROWS: u8 = 20; pub const LOGICAL_ENGINE_COLS: u8 = 12; diff --git a/mujina-miner/src/asic/bzm2/thread.rs b/mujina-miner/src/asic/bzm2/thread.rs index fd831b44..52e3d2b6 100644 --- a/mujina-miner/src/asic/bzm2/thread.rs +++ b/mujina-miner/src/asic/bzm2/thread.rs @@ -24,11 +24,12 @@ use super::clock::{ Bzm2ClockDebugReport, Bzm2Dll, Bzm2DllStatus, Bzm2Pll, Bzm2PllStatus, fincon_is_valid, }; use super::protocol::{ - self, BROADCAST_ASIC, Bzm2EngineLayout, DEFAULT_NONCE_GAP, DEFAULT_TIMESTAMP_COUNT, - DtsVsGeneration, ENGINE_REG_TARGET, ENGINE_REG_TIMESTAMP_COUNT, ENGINE_REG_ZEROS_TO_FIND, - OPCODE_UART_LOOPBACK, OPCODE_UART_NOOP, OPCODE_UART_READREG, TdmDtsVsFrame, TdmFrame, - TdmFrameParser, encode_loopback, encode_noop, encode_read_register, encode_write_job, - encode_write_register, leading_zero_threshold, logical_engine_address, + self, BROADCAST_ASIC, Bzm2EngineLayout, DEFAULT_BOARD_END_NONCE, DEFAULT_NONCE_GAP, + DEFAULT_TIMESTAMP_COUNT, DtsVsGeneration, ENGINE_REG_END_NONCE, ENGINE_REG_START_NONCE, + ENGINE_REG_TARGET, ENGINE_REG_TIMESTAMP_COUNT, ENGINE_REG_ZEROS_TO_FIND, OPCODE_UART_LOOPBACK, + OPCODE_UART_NOOP, OPCODE_UART_READREG, TdmDtsVsFrame, TdmFrame, TdmFrameParser, + encode_loopback, encode_noop, encode_read_register, encode_write_job, encode_write_register, + leading_zero_threshold, logical_engine_address, }; use super::uart::{ Bzm2DiscoveredEngineMap, Bzm2DtsVsConfig, DEFAULT_DTS_VS_QUERY_TIMEOUT, @@ -64,6 +65,7 @@ const RUNTIME_MEASUREMENT_WINDOW: Duration = Duration::from_secs(5 * 60); // Legacy source treats rows 0-9 as the bottom stack (PLL0) and rows 10-19 as // the top stack (PLL1). const PLL_STACK_SPLIT_ROW: u8 = 10; +const TIMESTAMP_COUNT_AUTO_CLOCK_UNGATE: u8 = 0x80; #[derive(Debug, Clone, Default, PartialEq)] pub struct Bzm2PllRuntimeMetrics { @@ -495,10 +497,7 @@ impl HashThread for Bzm2Thread { &self.capabilities } - async fn update_task( - &mut self, - new_task: HashTask, - ) -> anyhow::Result> { + async fn update_task(&mut self, new_task: HashTask) -> anyhow::Result> { let (response_tx, response_rx) = oneshot::channel(); self.command_tx .send(ThreadCommand::UpdateTask { @@ -513,10 +512,7 @@ impl HashThread for Bzm2Thread { .map_err(Into::into) } - async fn replace_task( - &mut self, - new_task: HashTask, - ) -> anyhow::Result> { + async fn replace_task(&mut self, new_task: HashTask) -> anyhow::Result> { let (response_tx, response_rx) = oneshot::channel(); self.command_tx .send(ThreadCommand::ReplaceTask { @@ -1283,8 +1279,10 @@ async fn dispatch_task_to_board( }); let merkle_root_residue = u32::from_le_bytes(header_bytes[64..68].try_into().unwrap()); let lead_zeros = leading_zero_threshold(task.share_target).saturating_sub(32); - let timestamp_count = config.timestamp_count; + let timestamp_count = config.timestamp_count | TIMESTAMP_COUNT_AUTO_CLOCK_UNGATE; let bits = task.template.bits.to_consensus(); + let start_nonce = 0u32; + let end_nonce = DEFAULT_BOARD_END_NONCE; for &(row, col) in engine_layout.active_coordinates() { let engine_address = logical_engine_address(row, col); @@ -1327,6 +1325,30 @@ async fn dispatch_task_to_board( HashThreadError::WorkAssignmentFailed(format!("Failed to write target bits: {err}")) })?; + writer + .write_all(&encode_write_register( + BROADCAST_ASIC, + engine_address, + ENGINE_REG_START_NONCE, + &start_nonce.to_le_bytes(), + )) + .await + .map_err(|err| { + HashThreadError::WorkAssignmentFailed(format!("Failed to write start nonce: {err}")) + })?; + + writer + .write_all(&encode_write_register( + BROADCAST_ASIC, + engine_address, + ENGINE_REG_END_NONCE, + &end_nonce.to_le_bytes(), + )) + .await + .map_err(|err| { + HashThreadError::WorkAssignmentFailed(format!("Failed to write end nonce: {err}")) + })?; + let seq_start = (base_sequence % 2) * 4; for (micro_job_id, midstate) in midstates.iter().enumerate() { let job_control = if micro_job_id == 3 { 3 } else { 0 }; @@ -1687,7 +1709,7 @@ mod tests { .await .unwrap(); - let expected_bytes_per_engine = 8 + 8 + 11 + (48 * 4); + let expected_bytes_per_engine = 8 + 8 + 11 + 11 + 11 + (48 * 4); let expected_total = expected_bytes_per_engine * engine_coords.len(); let deadline = tokio::time::Instant::now() + Duration::from_millis(250); let mut buf = vec![0u8; 512]; @@ -1711,8 +1733,37 @@ mod tests { assert_eq!(bytes.len(), expected_total); assert_eq!(engine_dispatches.len(), engine_coords.len()); - let first_packet_len = u16::from_le_bytes([bytes[0], bytes[1]]) as usize; - assert_eq!(first_packet_len, 8); + let packet_lengths = bytes + .chunks_exact(expected_bytes_per_engine) + .map(|chunk| { + [ + u16::from_le_bytes([chunk[0], chunk[1]]) as usize, + u16::from_le_bytes([chunk[8], chunk[9]]) as usize, + u16::from_le_bytes([chunk[16], chunk[17]]) as usize, + u16::from_le_bytes([chunk[27], chunk[28]]) as usize, + u16::from_le_bytes([chunk[38], chunk[39]]) as usize, + ] + }) + .collect::>(); + assert!( + packet_lengths + .iter() + .all(|lens| *lens == [8, 8, 11, 11, 11]) + ); + + for chunk in bytes.chunks_exact(expected_bytes_per_engine) { + assert_eq!( + chunk[7], + leading_zero_threshold(task.share_target).saturating_sub(32) + ); + assert_eq!(chunk[15], 0x80 | DEFAULT_TIMESTAMP_COUNT); + assert_eq!( + &chunk[23..27], + &task.template.bits.to_consensus().to_le_bytes() + ); + assert_eq!(&chunk[34..38], &0u32.to_le_bytes()); + assert_eq!(&chunk[45..49], &DEFAULT_BOARD_END_NONCE.to_le_bytes()); + } let last_packet_start = bytes.len() - 48; assert_eq!( @@ -2059,7 +2110,8 @@ mod tests { let mut buf = vec![0u8; 512]; let mut bytes = Vec::new(); let deadline = tokio::time::Instant::now() + Duration::from_millis(250); - while bytes.len() < (8 + 8 + 11 + (48 * 4)) * engine_layout.active_engine_count() { + let expected_bytes_per_engine = 8 + 8 + 11 + 11 + 11 + (48 * 4); + while bytes.len() < expected_bytes_per_engine * engine_layout.active_engine_count() { let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); let n = tokio::time::timeout(remaining, reader.read(&mut buf)) .await @@ -2071,15 +2123,16 @@ mod tests { bytes.extend_from_slice(&buf[..n]); } - let first_engine = logical_engine_address(0, 0).to_be_bytes(); - let second_engine = logical_engine_address(19, 10).to_be_bytes(); - assert!(bytes.windows(2).any(|window| window == first_engine)); - assert!(bytes.windows(2).any(|window| window == second_engine)); - assert!( - !bytes - .windows(2) - .any(|window| window == logical_engine_address(0, 1).to_be_bytes()) - ); + let first_engine = logical_engine_address(0, 0); + let second_engine = logical_engine_address(19, 10); + let touched_engines = bytes + .chunks_exact(expected_bytes_per_engine) + .map(|chunk| u32::from_be_bytes([chunk[2], chunk[3], chunk[4], chunk[5]])) + .map(|header| ((header >> 8) & 0x0fff) as u16) + .collect::>(); + assert!(touched_engines.contains(&first_engine)); + assert!(touched_engines.contains(&second_engine)); + assert!(!touched_engines.contains(&logical_engine_address(0, 1))); assert_eq!(engine_dispatches.len(), 2); assert!(engine_dispatches.contains_key(&0)); assert!(engine_dispatches.contains_key(&1)); diff --git a/mujina-miner/src/board/bzm2.rs b/mujina-miner/src/board/bzm2.rs index 5522296c..a4bcdac1 100644 --- a/mujina-miner/src/board/bzm2.rs +++ b/mujina-miner/src/board/bzm2.rs @@ -56,6 +56,7 @@ const DEFAULT_CALIBRATION_SITE_TEMP_C: f32 = 20.0; const DEFAULT_CALIBRATION_POST1_DIVIDER: u8 = 0; const DEFAULT_CALIBRATION_LOCK_TIMEOUT_MS: u64 = 1_000; const DEFAULT_CALIBRATION_LOCK_POLL_MS: u64 = 100; +const DEFAULT_CALIBRATION_REPLAY_FREQ_MHZ: f32 = 800.0; const DEFAULT_CALIBRATION_ENGINE_DISCOVERY_TDM_PREDIV_RAW: u32 = 0x0f; const DEFAULT_CALIBRATION_ENGINE_DISCOVERY_TDM_COUNTER: u8 = 16; const DEFAULT_CALIBRATION_ENGINE_DISCOVERY_TIMEOUT_MS: u64 = 100; @@ -1629,7 +1630,7 @@ impl Bzm2Board { .filter_map(|asic_id| profile.saved_state.per_asic_pll_mhz.get(&asic_id)) .map(|frequencies| frequencies[pll_index]), ) - .unwrap_or(DEFAULT_CALIBRATION_SITE_TEMP_C) + .unwrap_or(DEFAULT_CALIBRATION_REPLAY_FREQ_MHZ) }); self.apply_bus_frequency_map( bus, @@ -1965,19 +1966,13 @@ impl HashThread for Bzm2ManagedThread { self.inner.capabilities() } - async fn update_task( - &mut self, - new_task: HashTask, - ) -> AnyhowResult> { + async fn update_task(&mut self, new_task: HashTask) -> AnyhowResult> { let result = self.inner.update_task(new_task).await; self.publish_status(&self.inner.status()); result } - async fn replace_task( - &mut self, - new_task: HashTask, - ) -> AnyhowResult> { + async fn replace_task(&mut self, new_task: HashTask) -> AnyhowResult> { let result = self.inner.replace_task(new_task).await; self.publish_status(&self.inner.status()); result @@ -2934,11 +2929,9 @@ fn calibration_error(serial_path: &str, err: impl std::fmt::Display) -> BoardErr )) } -async fn create_bzm2_board() --> AnyhowResult<(Box, super::BoardRegistration)> { - let config = Bzm2RuntimeConfig::from_env().ok_or_else(|| { - anyhow::anyhow!("BZM2 not configured (MUJINA_BZM2_SERIAL not set)") - })?; +async fn create_bzm2_board() -> AnyhowResult<(Box, super::BoardRegistration)> { + let config = Bzm2RuntimeConfig::from_env() + .ok_or_else(|| anyhow::anyhow!("BZM2 not configured (MUJINA_BZM2_SERIAL not set)"))?; let serial = config.device_id(); let initial_state = BoardState { From 4d4d9c753d59c60797b8c50867c66db146212031 Mon Sep 17 00:00:00 2001 From: Reckless Apotheosis <125509978+recklessnode@users.noreply.github.com> Date: Fri, 3 Apr 2026 21:29:03 -0700 Subject: [PATCH 23/24] fix(rebase): restore upstream API and board compatibility --- mujina-miner/src/api_client/types.rs | 6 ++++++ mujina-miner/src/board/bitaxe.rs | 6 ++++-- mujina-miner/src/board/emberone.rs | 18 ++++++++++-------- 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/mujina-miner/src/api_client/types.rs b/mujina-miner/src/api_client/types.rs index 56e74a61..a096e181 100644 --- a/mujina-miner/src/api_client/types.rs +++ b/mujina-miner/src/api_client/types.rs @@ -20,6 +20,8 @@ pub struct MinerState { pub sources: Vec, } +pub type MinerTelemetry = MinerState; + /// Board status. #[derive(Clone, Debug, Default, Deserialize, Serialize, ToSchema)] pub struct BoardState { @@ -37,6 +39,8 @@ pub struct BoardState { pub bzm2_tuning: Option, } +pub type BoardTelemetry = BoardState; + /// Fan status. #[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] pub struct Fan { @@ -282,3 +286,5 @@ pub struct SourceState { #[serde(skip_serializing_if = "Option::is_none")] pub difficulty: Option, } + +pub type SourceTelemetry = SourceState; diff --git a/mujina-miner/src/board/bitaxe.rs b/mujina-miner/src/board/bitaxe.rs index 3587314c..86a672df 100644 --- a/mujina-miner/src/board/bitaxe.rs +++ b/mujina-miner/src/board/bitaxe.rs @@ -29,6 +29,7 @@ use crate::{ mgmt_protocol::{ ControlChannel, bitaxe_raw::{ + ResponseFormat, gpio::{BitaxeRawGpioController, BitaxeRawGpioPin}, i2c::BitaxeRawI2c, }, @@ -178,7 +179,7 @@ impl BitaxeBoard { state_tx: watch::Sender, ) -> Result { // Create control channel and I2C controller - let control_channel = ControlChannel::new(control); + let control_channel = ControlChannel::new(control, ResponseFormat::V0); let i2c = BitaxeRawI2c::new(control_channel.clone()); // Create SerialStream for data channel at initial baud rate @@ -932,7 +933,7 @@ async fn create_from_usb( use tokio_serial::SerialPortBuilderExt; // Get serial ports - let serial_ports = device.serial_ports()?; + let serial_ports = device.get_serial_ports(2).await?; // Bitaxe Gamma requires exactly 2 serial ports if serial_ports.len() != 2 { @@ -995,6 +996,7 @@ inventory::submit! { pattern: crate::board::pattern::BoardPattern { vid: Match::Any, pid: Match::Any, + bcd_device: Match::Any, manufacturer: Match::Specific(StringMatch::Exact("OSMU")), product: Match::Specific(StringMatch::Exact("Bitaxe")), serial_pattern: Match::Any, diff --git a/mujina-miner/src/board/emberone.rs b/mujina-miner/src/board/emberone.rs index 1ec2c362..18c33bbb 100644 --- a/mujina-miner/src/board/emberone.rs +++ b/mujina-miner/src/board/emberone.rs @@ -84,6 +84,7 @@ inventory::submit! { pattern: BoardPattern { vid: Match::Any, pid: Match::Any, + bcd_device: Match::Any, manufacturer: Match::Specific(StringMatch::Exact("256F")), product: Match::Specific(StringMatch::Exact("EmberOne00")), serial_pattern: Match::Any, @@ -99,14 +100,15 @@ mod tests { #[test] fn test_board_creation() { - let device = UsbDeviceInfo::new_for_test( - 0xc0de, - 0xcafe, - Some("TEST001".to_string()), - Some("EmberOne".to_string()), - Some("Mining Board".to_string()), - "/sys/devices/test".to_string(), - ); + let device = UsbDeviceInfo { + vid: 0xc0de, + pid: 0xcafe, + bcd_device: 0x0100, + serial_number: Some("TEST001".to_string()), + manufacturer: Some("EmberOne".to_string()), + product: Some("Mining Board".to_string()), + device_path: "/sys/devices/test".to_string(), + }; let (state_tx, _state_rx) = watch::channel(BoardState { name: format!( From 61164e8e5ebbebee23b479f3be337a1e2fe9af62 Mon Sep 17 00:00:00 2001 From: Reckless Apotheosis <125509978+recklessnode@users.noreply.github.com> Date: Fri, 3 Apr 2026 22:30:14 -0700 Subject: [PATCH 24/24] refactor(bzm2): scrub provenance cues and clarify generic breakout --- README.md | 5 ++-- docs/bzm2/blockscale-reference-roadmap.md | 34 ++++++++++++++++++++++- docs/bzm2/bzm2-opcode-grounding.md | 14 +++++----- docs/bzm2/bzm2-pnp.md | 6 ++-- docs/bzm2/bzm2-port.md | 13 ++++----- mujina-miner/src/tuning/blockscale.rs | 8 +++--- 6 files changed, 56 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 8eda4736..4ca6be0a 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,9 @@ interfaces. Part of the larger Mujina OS project, an open source, Debian-based embedded Linux distribution optimized for Bitcoin mining hardware. This repository also includes an active Rust port of the Intel BZM2 mining -stack. The goal of the port is to keep BZM2 support inside Mujina rather than -reviving the original split `cgminer` plus `bzmd` process model. +stack. The goal of the port is to keep BZM2 support inside Mujina as a native +board and ASIC implementation rather than depending on an external companion +process model. ## Features diff --git a/docs/bzm2/blockscale-reference-roadmap.md b/docs/bzm2/blockscale-reference-roadmap.md index 3e875fce..7528bcca 100644 --- a/docs/bzm2/blockscale-reference-roadmap.md +++ b/docs/bzm2/blockscale-reference-roadmap.md @@ -48,6 +48,39 @@ The biggest missing pieces are: 2. closed-loop calibration and retune 3. board/API diagnostics parity with the CLI +## Generic Breakout Targets + +The long-term goal is not just a strong BZM2 port, but a reusable mining-board +reference architecture inside Mujina. + +These pieces should remain shaped so they can become general-purpose facilities: + +- `board::power` + - rail sequencing + - reset-line orchestration + - reusable rail telemetry adapters +- `asic::hash_thread` + - thread telemetry events + - idle-only diagnostics gatekeeping + - generic scheduler-facing lifecycle hooks +- `tuning` + - board-independent tuning inputs and outputs + - reusable search-space generation + - reusable replay / pending / validated operating-point states +- board command plumbing + - generic board-side diagnostics RPC pattern + - per-board command enums only for truly ASIC-specific operations +- protocol documentation pattern + - keep wire-level references vendor-specific + - keep planning, telemetry, and power-control descriptions reusable + +The pieces that should stay BZM2-specific are: + +- UART opcode packing and TDM parsing +- BZM2 engine topology model +- BZM2 PLL/DLL register programming +- BZM2 DTS/VS payload decoding + ## Phase 1: Discoverable Bring-Up Objective: @@ -330,4 +363,3 @@ Reason: - enumeration removes a major assumption from the current board runtime - it is ASIC-generic - it is directly grounded in documented and legacy UART behavior - diff --git a/docs/bzm2/bzm2-opcode-grounding.md b/docs/bzm2/bzm2-opcode-grounding.md index 7b3a94e2..63a320b9 100644 --- a/docs/bzm2/bzm2-opcode-grounding.md +++ b/docs/bzm2/bzm2-opcode-grounding.md @@ -1,17 +1,17 @@ -# BZM2 Opcode And JTAG Grounding +# BZM2 Opcode And Interface Grounding ## Scope This note captures only behavior that is grounded in material included in this repository: -- legacy UART implementation in [uart.h](../bzm2_cgminer/feeds/mining_src/bzmd/uart.h) and [uart.c](../bzm2_cgminer/feeds/mining_src/bzmd/uart.c) -- legacy exercised behavior in [test.c](../bzm2_cgminer/feeds/mining_src/bzmd/tests/test.c) +- repository-visible historical UART implementation behavior +- repository-visible historical exercised UART behavior Anything not evidenced there is intentionally excluded from the Mujina port. ## What The Legacy Source Proved -The legacy `bzmd` source gives a concrete UART wire contract for these opcodes: +The historical shipped C implementation gives a concrete UART wire contract for these opcodes: - `WRITEJOB` - `READRESULT` @@ -22,7 +22,7 @@ The legacy `bzmd` source gives a concrete UART wire contract for these opcodes: - `LOOPBACK` - `NOOP` -Grounded request/response behavior from [uart.c](../bzm2_cgminer/feeds/mining_src/bzmd/uart.c): +Grounded request/response behavior from the repository-visible historical UART implementation: - `WRITEREG`: request is `len(2 LE) + header(4 BE) + count_minus_one + payload` - `MULTICAST_WRITE`: same framing as `WRITEREG`, but opcode `0x4` @@ -32,7 +32,7 @@ Grounded request/response behavior from [uart.c](../bzm2_cgminer/feeds/mining_sr - `LOOPBACK`: request is `len + header + count_minus_one + payload`; response echoes `asic + opcode + payload` - `DTS_VS`: in TDM mode, payload is 4 bytes for gen1 and 8 bytes for gen2 -Grounded concurrency and parser behavior from [uart.h](../bzm2_cgminer/feeds/mining_src/bzmd/uart.h), [uart.c](../bzm2_cgminer/feeds/mining_src/bzmd/uart.c), and [test.c](../bzm2_cgminer/feeds/mining_src/bzmd/tests/test.c): +Grounded concurrency and parser behavior from the repository-visible historical implementation and tests: - TDM parsing is byte-stream oriented and must resynchronize after unknown prefixes - TDM `READREG` response size is caller-driven and tracked per ASIC @@ -61,5 +61,5 @@ Not implemented from the docs side: Reason: -- the available source in this workspace proves the UART mining/control path +- the repository-visible implementation proves the UART mining/control path - the repository-visible sources do not provide enough packet-level JTAG detail to implement anything defensible diff --git a/docs/bzm2/bzm2-pnp.md b/docs/bzm2/bzm2-pnp.md index 733c7e06..5564b923 100644 --- a/docs/bzm2/bzm2-pnp.md +++ b/docs/bzm2/bzm2-pnp.md @@ -1,6 +1,6 @@ -# BZM2 PnP Calibration In Mujina +# BZM2 Tuning Calibration In Mujina -This note captures the current BZM2 PnP state in Mujina, what the legacy `bzmd` implementation did, and what is now implemented in the Rust port. +This note captures the current BZM2 tuning state in Mujina, what the historical C implementation did, and what is now implemented in the Rust port. ## Current Gap @@ -22,7 +22,7 @@ What it did not have was a native Mujina tuning planner for BZM2: - domain-aware planning for hardware with multiple voltage domains - per-ASIC or per-stack frequency fine-tuning around a target pass-rate window -## Legacy `pnp.c` Behavior +## Historical C Tuning Behavior The original C implementation mixed: diff --git a/docs/bzm2/bzm2-port.md b/docs/bzm2/bzm2-port.md index 68209a93..fcf8bd42 100644 --- a/docs/bzm2/bzm2-port.md +++ b/docs/bzm2/bzm2-port.md @@ -2,12 +2,12 @@ ## Architecture -This port keeps BZM2 support inside Mujina rather than reviving the original split `cgminer` + `bzmd` process model. +This port keeps BZM2 support inside Mujina rather than depending on an external split-process design. -The legacy split looked like this: +The earlier implementation split responsibilities across separate components: -- `cgminer` handled scheduling, pool interaction, and IPC to `bzmd` -- `bzmd` owned UART transport, job fanout, result validation, and board-management glue +- one component handled scheduling and pool interaction +- one component owned UART transport, job fanout, result validation, and board-management glue In Mujina, those responsibilities map cleanly onto existing abstractions: @@ -194,7 +194,7 @@ Example JSON fragment: Notes: - these ASIC-originated entries are merged into board state and do not replace host-file telemetry -- Celsius and voltage scaling follow the legacy `bzmd` DTS/VS conversion formulas +- Celsius and voltage scaling follow the historical DTS/VS conversion formulas preserved in the repository-visible implementation - Gen1 currently exposes voltage through this path, but not a Celsius temperature reading ## On-Demand ASIC Sensor Queries @@ -319,7 +319,7 @@ configuration alone. ## Design Boundary -The legacy `bzmd` board-power path mixes three different concerns: +The historical board-power path mixed three different concerns: - genuinely reusable sequencing concepts - generic peripheral protocols like PMBus/I2C regulators and reset GPIOs @@ -365,4 +365,3 @@ This port currently implements the opcode surface that is evidenced in the legac See also: - [bzm2-opcode-grounding.md](bzm2-opcode-grounding.md) for the source-grounded opcode matrix and the current JTAG evidence boundary - diff --git a/mujina-miner/src/tuning/blockscale.rs b/mujina-miner/src/tuning/blockscale.rs index ffa6fbe7..5b52ecf1 100644 --- a/mujina-miner/src/tuning/blockscale.rs +++ b/mujina-miner/src/tuning/blockscale.rs @@ -43,7 +43,7 @@ pub enum Bzm2PerformanceMode { } impl Bzm2PerformanceMode { - fn pass_rate_range(self) -> f32 { + fn acceptance_band(self) -> f32 { match self { Self::MaxThroughput => ACCEPT_RATIO_BAND_MAX_THROUGHPUT, Self::Standard => ACCEPT_RATIO_BAND_STANDARD, @@ -464,8 +464,8 @@ impl Bzm2CalibrationPlanner { for (pll_index, pass_rate) in measurement.pll_pass_rates.iter().enumerate() { if let Some(pass_rate) = pass_rate { - let low = target.pass_rate - input.target_mode.pass_rate_range(); - let high = target.pass_rate + input.target_mode.pass_rate_range(); + let low = target.pass_rate - input.target_mode.acceptance_band(); + let high = target.pass_rate + input.target_mode.acceptance_band(); if *pass_rate < low { pll_frequencies[pll_index] = clamp_frequency(pll_frequencies[pll_index] - CALI_FREQ_MHZ); @@ -487,7 +487,7 @@ impl Bzm2CalibrationPlanner { } } } else if let Some(pass_rate) = measurement.average_pass_rate { - if pass_rate < target.pass_rate - input.target_mode.pass_rate_range() { + if pass_rate < target.pass_rate - input.target_mode.acceptance_band() { pll_frequencies = [clamp_frequency(domain_frequency - CALI_FREQ_MHZ); 2]; asic_notes.push(format!(