diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..340ce963 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,13 @@ +* text=auto eol=lf + +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.webp binary +*.ico binary +*.pdf binary +*.woff binary +*.woff2 binary +*.ttf binary +*.otf binary \ No newline at end of file diff --git a/mujina-miner/src/api/registry.rs b/mujina-miner/src/api/registry.rs index c5325878..66b565ad 100644 --- a/mujina-miner/src/api/registry.rs +++ b/mujina-miner/src/api/registry.rs @@ -1,7 +1,9 @@ //! Dynamic board registration tracking. +use tokio::sync::{mpsc, watch}; + +use crate::api::commands::BoardCommand; use crate::api_client::types::BoardTelemetry; -use tokio::sync::watch; /// Dynamic collection of board registrations. /// @@ -23,23 +25,49 @@ impl BoardRegistry { self.boards.push(reg); } + /// Remove boards whose sender has been dropped (board disconnected). + fn prune_disconnected(&mut self) { + self.boards + .retain(|reg| reg.telemetry_rx.has_changed().is_ok()); + } + /// Snapshot all connected boards. /// /// Removes boards whose sender has been dropped (board disconnected) /// and returns the current state of each. pub fn boards(&mut self) -> Vec { - self.boards - .retain(|reg| reg.telemetry_rx.has_changed().is_ok()); + self.prune_disconnected(); self.boards .iter() .map(|reg| reg.telemetry_rx.borrow().clone()) .collect() } + + /// Snapshot a single connected board by name. + pub fn board(&mut self, name: &str) -> Option { + self.prune_disconnected(); + self.boards + .iter() + .find(|reg| reg.telemetry_rx.borrow().name == name) + .map(|reg| reg.telemetry_rx.borrow().clone()) + } + + /// Look up the command sender for a board by name. `None` if the + /// board is unknown or accepts no commands. + pub fn command_tx(&mut self, name: &str) -> Option> { + self.prune_disconnected(); + self.boards + .iter() + .find(|reg| reg.telemetry_rx.borrow().name == name) + .and_then(|reg| reg.command_tx.clone()) + } } /// A board's registration with the API server. pub struct BoardRegistration { pub telemetry_rx: watch::Receiver, + /// Sender for board commands. `None` if the board accepts no commands. + pub command_tx: Option>, } #[cfg(test)] @@ -57,7 +85,13 @@ mod tests { ..Default::default() }; let (tx, rx) = watch::channel(telemetry); - (tx, BoardRegistration { telemetry_rx: rx }) + ( + tx, + BoardRegistration { + telemetry_rx: rx, + command_tx: None, + }, + ) } #[test] @@ -109,4 +143,34 @@ mod tests { tx.send_modify(|s| s.model = "Updated".into()); assert_eq!(registry.boards()[0].model, "Updated"); } + + #[test] + fn returns_single_board_by_name() { + let mut registry = BoardRegistry::new(); + + let (_keep, reg) = make_board("board-a"); + registry.push(reg); + + assert_eq!(registry.board("board-a").unwrap().name, "board-a"); + assert!(registry.board("missing").is_none()); + } + + #[test] + fn returns_command_sender_for_named_board() { + use tokio::sync::mpsc; + + let mut registry = BoardRegistry::new(); + + let (_keep, mut reg) = make_board("board-a"); + let (cmd_tx, _cmd_rx) = mpsc::channel::(1); + reg.command_tx = Some(cmd_tx); + registry.push(reg); + + assert!(registry.command_tx("board-a").is_some()); + assert!(registry.command_tx("missing").is_none()); + + let (_keep_b, reg_b) = make_board("no-commands"); + registry.push(reg_b); + assert!(registry.command_tx("no-commands").is_none()); + } } diff --git a/mujina-miner/src/api/server.rs b/mujina-miner/src/api/server.rs index e7e0a16c..9554e067 100644 --- a/mujina-miner/src/api/server.rs +++ b/mujina-miner/src/api/server.rs @@ -166,7 +166,10 @@ mod tests { let mut board_senders = Vec::new(); for state in board_states { let (tx, rx) = watch::channel(state); - registry.push(BoardRegistration { telemetry_rx: rx }); + registry.push(BoardRegistration { + telemetry_rx: rx, + command_tx: None, + }); board_senders.push(tx); } @@ -362,4 +365,111 @@ mod tests { let (status, _body) = get(fixtures.router.clone(), "/api/v0/nope").await; assert_eq!(status, 404); } + + async fn post_json( + app: Router, + method: &str, + uri: &str, + body: &T, + ) -> (http::StatusCode, String) { + let req = Request::builder() + .method(method) + .uri(uri) + .header("content-type", "application/json") + .body(axum::body::Body::from(serde_json::to_vec(body).unwrap())) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + let status = resp.status(); + let body = resp.into_body().collect().await.unwrap().to_bytes(); + (status, String::from_utf8(body.to_vec()).unwrap()) + } + + #[tokio::test] + async fn set_fan_target_round_trips_board_command() { + use crate::api::commands::BoardCommand; + use crate::api_client::types::{Fan, SetFanTargetRequest}; + + let (miner_tx, miner_rx) = watch::channel(MinerTelemetry::default()); + let (cmd_tx, _cmd_rx) = mpsc::channel::(16); + let mut registry = BoardRegistry::new(); + let (telemetry_tx, telemetry_rx) = watch::channel(BoardTelemetry { + name: "fan-board".into(), + model: "Test".into(), + ..Default::default() + }); + let (board_cmd_tx, mut board_cmd_rx) = mpsc::channel(1); + registry.push(BoardRegistration { + telemetry_rx, + command_tx: Some(board_cmd_tx), + }); + let router = build_router(miner_rx, Arc::new(Mutex::new(registry)), cmd_tx); + + // Clone for the task; the original must stay alive or the registry + // prunes the board before the handler's post-command re-read. + let telemetry_tx_for_command = telemetry_tx.clone(); + tokio::spawn(async move { + if let Some(BoardCommand::SetFanTarget { + board, + fan, + percent, + reply, + }) = board_cmd_rx.recv().await + { + assert_eq!(board, "fan-board"); + assert_eq!(fan, "fan0"); + assert_eq!(percent, Some(75)); + telemetry_tx_for_command.send_modify(|t| { + t.fans.push(Fan { + name: "fan0".into(), + rpm: None, + percent: None, + target_percent: percent, + }); + }); + let _ = reply.send(Ok(())); + } + }); + + let (status, body) = post_json( + router.clone(), + "PATCH", + "/api/v0/boards/fan-board/fans/fan0", + &SetFanTargetRequest { + target_percent: Some(75), + }, + ) + .await; + assert_eq!(status, 200); + let board: BoardTelemetry = serde_json::from_str(&body).unwrap(); + assert_eq!(board.fans[0].target_percent, Some(75)); + + // A board with no command channel answers 400. + let (_keep, no_cmd_rx) = watch::channel(BoardTelemetry { + name: "no-commands".into(), + model: "Test".into(), + ..Default::default() + }); + let (miner_tx2, miner_rx2) = watch::channel(MinerTelemetry::default()); + let (cmd_tx2, _cmd_rx2) = mpsc::channel::(16); + let mut registry2 = BoardRegistry::new(); + registry2.push(BoardRegistration { + telemetry_rx: no_cmd_rx, + command_tx: None, + }); + let router2 = build_router(miner_rx2, Arc::new(Mutex::new(registry2)), cmd_tx2); + let (status, _body) = post_json( + router2, + "PATCH", + "/api/v0/boards/no-commands/fans/fan0", + &SetFanTargetRequest { + target_percent: Some(50), + }, + ) + .await; + assert_eq!(status, 400); + + drop(miner_tx); + drop(miner_tx2); + drop(telemetry_tx); + } } diff --git a/mujina-miner/src/api/v0.rs b/mujina-miner/src/api/v0.rs index 5778fe5d..d3d3dc59 100644 --- a/mujina-miner/src/api/v0.rs +++ b/mujina-miner/src/api/v0.rs @@ -13,10 +13,10 @@ use std::time::Duration; use tokio::sync::oneshot; use utoipa_axum::{router::OpenApiRouter, routes}; -use super::commands::SchedulerCommand; +use super::commands::{BoardCommand, SchedulerCommand}; use super::server::SharedState; use crate::api_client::types::{ - BoardTelemetry, MinerPatchRequest, MinerTelemetry, SourceTelemetry, + BoardTelemetry, MinerPatchRequest, MinerTelemetry, SetFanTargetRequest, SourceTelemetry, }; /// Build the v0 API routes with OpenAPI metadata. @@ -26,6 +26,7 @@ pub fn routes() -> OpenApiRouter { .routes(routes!(get_miner, patch_miner)) .routes(routes!(get_boards)) .routes(routes!(get_board)) + .routes(routes!(set_fan_target)) .routes(routes!(get_sources)) .routes(routes!(get_source)) } @@ -132,9 +133,68 @@ async fn get_board( .board_registry .lock() .unwrap_or_else(|e| e.into_inner()) - .boards() - .into_iter() - .find(|b| b.name == name) + .board(&name) + .map(Json) + .ok_or(StatusCode::NOT_FOUND) +} + +/// Set a fan's target duty cycle on a board, or return it to automatic +/// control. +#[utoipa::path( + patch, + path = "/boards/{name}/fans/{fan}", + tag = "boards", + params( + ("name" = String, Path, description = "Board name"), + ("fan" = String, Path, description = "Fan name"), + ), + request_body = SetFanTargetRequest, + responses( + (status = OK, description = "Updated board telemetry", body = BoardTelemetry), + (status = NOT_FOUND, description = "Board not found"), + (status = BAD_REQUEST, description = "Board accepts no commands"), + (status = INTERNAL_SERVER_ERROR, description = "Command channel error"), + ), +)] +async fn set_fan_target( + State(state): State, + Path((name, fan)): Path<(String, String)>, + Json(req): Json, +) -> Result, StatusCode> { + let (board_exists, command_tx) = { + let mut registry = state + .board_registry + .lock() + .unwrap_or_else(|e| e.into_inner()); + (registry.board(&name).is_some(), registry.command_tx(&name)) + }; + if !board_exists { + return Err(StatusCode::NOT_FOUND); + } + let Some(command_tx) = command_tx else { + return Err(StatusCode::BAD_REQUEST); + }; + + let (tx, rx) = oneshot::channel(); + command_tx + .send(BoardCommand::SetFanTarget { + board: name.clone(), + fan, + percent: req.target_percent, + reply: tx, + }) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + // Result layers: timeout / channel-closed / command-error. + let Ok(Ok(Ok(()))) = tokio::time::timeout(Duration::from_secs(5), rx).await else { + return Err(StatusCode::INTERNAL_SERVER_ERROR); + }; + + state + .board_registry + .lock() + .unwrap_or_else(|e| e.into_inner()) + .board(&name) .map(Json) .ok_or(StatusCode::NOT_FOUND) } diff --git a/mujina-miner/src/api_client/types.rs b/mujina-miner/src/api_client/types.rs index 6042ba0b..bb888b57 100644 --- a/mujina-miner/src/api_client/types.rs +++ b/mujina-miner/src/api_client/types.rs @@ -33,6 +33,9 @@ pub struct BoardTelemetry { pub temperatures: Vec, pub powers: Vec, pub threads: Vec, + /// Per-ASIC topology/diagnostics state (multi-ASIC boards only). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub asics: Vec, } /// Fan status. @@ -74,6 +77,27 @@ pub struct ThreadTelemetry { pub is_active: bool, } +/// Per-ASIC runtime topology or diagnostics state. +#[derive(Clone, Debug, Default, Deserialize, Serialize, ToSchema)] +pub struct AsicState { + pub id: u8, + #[serde(skip_serializing_if = "Option::is_none")] + pub thread_index: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub serial_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub discovered_engine_count: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub missing_engines: Vec, +} + +/// Physical engine coordinate on one ASIC. +#[derive(Clone, Debug, Deserialize, Serialize, ToSchema, PartialEq, Eq)] +pub struct EngineCoordinate { + pub row: u8, + pub col: u8, +} + /// Writable fields for `PATCH /api/v0/miner`. /// /// All fields are optional; only those present in the request body are diff --git a/mujina-miner/src/asic/bzm2/clock.rs b/mujina-miner/src/asic/bzm2/clock.rs new file mode 100644 index 00000000..58c12039 --- /dev/null +++ b/mujina-miner/src/asic/bzm2/clock.rs @@ -0,0 +1,606 @@ +use std::time::Duration; + +use tokio::time::{Instant, sleep}; + +use crate::transport::{SerialReader, SerialWriter}; + +use super::uart::{Bzm2UartController, Bzm2UartError}; + +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; + +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(transparent)] + Uart(#[from] Bzm2UartError), + + #[error("invalid desired PLL frequency {0} MHz")] + InvalidFrequency(f32), + + #[error("invalid PLL post divider {0}")] + InvalidPostDivider(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}" + )] + PllLockTimeout { + asic: u8, + 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)] +pub enum Bzm2Pll { + Pll0, + Pll1, +} + +impl Bzm2Pll { + pub(crate) 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, + ), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Bzm2Dll { + Dll0, + Dll1, +} + +impl Bzm2Dll { + pub(crate) fn registers(self) -> (u8, u8, u8, u8, u8) { + match self { + 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, + ), + } + } + + pub(crate) fn fincon_register(self) -> u8 { + match self { + Self::Dll0 => LOCAL_REG_CKDLLR_1_0, + Self::Dll1 => LOCAL_REG_CKDLLR_1_1, + } + } +} + +#[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 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, + pub enable_register: u32, + pub misc_register: u32, + pub enabled: bool, + 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 { + uart: Bzm2UartController, +} + +impl Bzm2ClockController { + pub fn new(reader: SerialReader, writer: SerialWriter) -> Self { + Self { + uart: Bzm2UartController::new(reader, writer), + } + } + + pub fn from_uart(uart: Bzm2UartController) -> Self { + Self { uart } + } + + pub fn into_uart(self) -> Bzm2UartController { + self.uart + } + + pub async fn program_pll( + &mut self, + asic: u8, + pll: Bzm2Pll, + config: Bzm2PllConfig, + ) -> Result<(), Bzm2ClockError> { + let (postdiv_reg, fbdiv_reg, _, _) = pll.register_block(); + self.uart + .write_local_reg_u32(asic, fbdiv_reg, config.feedback_divider as u32) + .await?; + 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.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.uart.write_local_reg_u32(asic, enable_reg, 0).await?; + Ok(()) + } + + 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 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, + pll: Bzm2Pll, + timeout: Duration, + poll_interval: Duration, + ) -> Result { + let (_, _, enable_reg, _) = pll.register_block(); + let start = Instant::now(); + + loop { + 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); + } + 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, 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, + misc_register: misc, + enabled: (enable & 0x1) != 0, + locked: (enable & 0x4) != 0, + }) + } + + 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(()) + } + + pub async fn set_dll_duty_cycle( + &mut self, + asic: u8, + 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, + }); + } + sleep(poll_interval).await; + } + } + + 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) + } + + 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), + }) + } + + 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?, + }) + } +} + +pub(crate) fn fincon_is_valid(fincon: u8) -> bool { + !matches!(fincon & 0xf0, 0xf0 | 0x00) && !matches!(fincon & 0xe0, 0xe0 | 0x00) +} + +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 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 new file mode 100644 index 00000000..8c606d91 --- /dev/null +++ b/mujina-miner/src/asic/bzm2/mod.rs @@ -0,0 +1,18 @@ +pub mod clock; +pub mod protocol; +pub mod thread; +pub mod uart; + +pub use clock::{ + Bzm2ClockController, Bzm2ClockDebugReport, Bzm2ClockError, Bzm2Dll, Bzm2DllConfig, + Bzm2DllStatus, Bzm2Pll, Bzm2PllConfig, Bzm2PllStatus, +}; +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 new file mode 100644 index 00000000..0c2be134 --- /dev/null +++ b/mujina-miner/src/asic/bzm2/protocol.rs @@ -0,0 +1,776 @@ +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_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_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; + +#[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, + 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(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 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) >= 2 { + let asic = self.buffer[cursor]; + let opcode = self.buffer[cursor + 1]; + + // Resync heuristic: an id this large in the asic position is almost + // always line noise in the supported small-id chains, so skip it as + // a stray byte. DTS/VS frames are exempt: they carry the + // thermal-trip / voltage-fault bits that drive the protective + // shutdown and must never be silently dropped by an ad-hoc id bound + // (an operator can also legitimately push ids >= 100 via + // MUJINA_BZM2_ENUM_START_ID). A fault frame that reaches the handler + // is logged loudly there before any shutdown action is taken. + if asic >= 100 && opcode != OPCODE_UART_DTS_VS { + cursor += 1; + continue; + } + + 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 { + // No READREG was requested for this id, so these bytes are + // stray/noise (the long-lived streaming parser never sets + // an expected read length). Resync one byte forward like + // the unknown-opcode arm instead of breaking: a bare + // READREG-looking prefix sitting at cursor 0 would + // otherwise wedge framing forever and grow `self.buffer` + // without bound on every subsequent push. + cursor += 1; + continue; + }; + 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 { + self.buffer.drain(..cursor); + } + + frames + } +} + +#[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) + | ((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_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, + 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 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 +} + +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 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(); + 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 +} + +#[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; + + '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) +} + +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::*; + + #[test] + fn write_register_encoder_matches_legacy_wire_format() { + let encoded = encode_write_register(0x12, 0x0345, 0x67, &[0x78, 0x56, 0x34, 0x12]); + assert_eq!( + encoded, + vec![ + 0x0b, 0x00, 0x12, 0x23, 0x45, 0x67, 0x03, 0x78, 0x56, 0x34, 0x12 + ] + ); + } + + #[test] + fn read_register_encoder_matches_legacy_wire_format() { + let encoded = encode_read_register(0x12, 0x0345, 0x67, 4); + assert_eq!( + encoded, + vec![0x08, 0x00, 0x12, 0x33, 0x45, 0x67, 0x03, TARGET_BYTE] + ); + } + + #[test] + fn noop_and_loopback_encoders_match_legacy_wire_format() { + assert_eq!(encode_noop(0x12), vec![0x04, 0x00, 0x12, 0xf0]); + assert_eq!( + encode_loopback(0x12, &[0xaa, 0xbb, 0xcc]), + vec![0x08, 0x00, 0x12, 0xe0, 0x02, 0xaa, 0xbb, 0xcc] + ); + } + + #[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); + } + + #[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_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); + 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 parser_keeps_dts_vs_trip_frame_from_high_asic_id() { + let mut parser = TdmFrameParser::new(DtsVsGeneration::Gen2); + // A Gen2 DTS/VS frame from asic id 100 with the thermal-trip bit set. + // The `asic >= 100` resync heuristic must NOT drop it: silently + // discarding it would disable over-temp protection for that device. + let parsed = parser.push(&[100, OPCODE_UART_DTS_VS, 0, 0, 0, 0, 0, 0, 0, 0x10]); + assert_eq!(parsed.len(), 1); + match &parsed[0] { + TdmFrame::DtsVs(TdmDtsVsFrame::Gen2(frame)) => { + assert_eq!(frame.asic, 100); + assert!(frame.thermal_trip_status); + } + other => panic!("unexpected frame: {other:?}"), + } + } + + #[test] + fn parser_resyncs_after_unknown_prefix_and_partial_frames() { + let mut parser = TdmFrameParser::new(DtsVsGeneration::Gen2); + parser.expect_read_register_bytes(0x03, 4); + + let first = parser.push(&[0xfe, 0xaa, 0x03, OPCODE_UART_READREG, 0x78, 0x56]); + assert!(first.is_empty()); + + let second = parser.push(&[0x34, 0x12, 0x01, OPCODE_UART_NOOP, 0xaa, 0xbb, 0xcc]); + assert_eq!(second.len(), 2); + match &second[0] { + TdmFrame::Register(frame) => assert_eq!(frame.data, vec![0x78, 0x56, 0x34, 0x12]), + other => panic!("unexpected frame: {other:?}"), + } + match &second[1] { + TdmFrame::Noop(frame) => assert_eq!(frame.data, [0xaa, 0xbb, 0xcc]), + other => panic!("unexpected frame: {other:?}"), + } + } + + #[test] + fn parser_bounds_buffer_and_recovers_on_readreg_without_pending_count() { + let mut parser = TdmFrameParser::new(DtsVsGeneration::Gen2); + + // A READREG-looking prefix that never has a pending read length is pure + // line noise on the long-lived streaming parser. Before the fix this hit + // a no-count `break` at cursor 0, wedging framing and growing the buffer + // by two bytes on every push. Feed it many times and confirm the buffer + // stays bounded rather than accumulating ~2000 bytes. + for _ in 0..1000 { + assert!(parser.push(&[0x02, OPCODE_UART_READREG]).is_empty()); + } + assert!( + parser.buffer.len() <= 4, + "buffer grew unbounded on malformed READREG noise: {} bytes", + parser.buffer.len() + ); + + // Framing is not wedged: a subsequent well-formed NOOP frame is still + // decoded once the noise resyncs forward. + let recovered = parser.push(&[0x05, OPCODE_UART_NOOP, 0xaa, 0xbb, 0xcc]); + assert_eq!(recovered.len(), 1); + match &recovered[0] { + 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 new file mode 100644 index 00000000..0649fa8d --- /dev/null +++ b/mujina-miner/src/asic/bzm2/thread.rs @@ -0,0 +1,2290 @@ +use std::collections::{BTreeMap, HashMap}; +use std::path::Path; +use std::sync::{Arc, RwLock}; +use std::time::{Duration, Instant}; + +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, + HashThreadPowerReading, HashThreadStatus, HashThreadTelemetryUpdate, + HashThreadTemperatureReading, Share, +}; +use crate::job_source::{GeneralPurposeBits, MerkleRootKind}; +use crate::tracing::prelude::*; +use crate::transport::serial::{SerialControl, SerialReader, SerialWriter}; +use crate::types::{Difficulty, HashRate, HashrateEstimator, Work}; + +use super::clock::{ + Bzm2ClockDebugReport, Bzm2Dll, Bzm2DllStatus, Bzm2Pll, Bzm2PllStatus, fincon_is_valid, +}; +use super::protocol::{ + 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, + configure_dts_vs_stream, discover_engine_map_stream, +}; + +#[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, + pub dts_vs_generation: DtsVsGeneration, +} + +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, + dts_vs_generation: DtsVsGeneration::Gen2, + } + } +} + +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 { + 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, +} + +impl Bzm2ThreadHandle { + pub fn shutdown(&self) { + 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, + ) -> 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()))? + } + + 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, + 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()))? + } + + 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)] +enum ThreadCommand { + /// Declare expected hashrate and ready the thread for work + Configure, + + UpdateTask { + new_task: HashTask, + response_tx: oneshot::Sender, HashThreadError>>, + }, + ReplaceTask { + new_task: HashTask, + response_tx: oneshot::Sender, HashThreadError>>, + }, + 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>, + }, + DiscoverEngineMap { + asic: u8, + tdm_prediv_raw: u32, + tdm_counter: u8, + timeout: Duration, + response_tx: oneshot::Sender>, + }, + QueryRuntimeMetrics { + response_tx: oneshot::Sender>, + }, + 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); + + 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::default(), + 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 configure(&mut self) -> anyhow::Result<()> { + self.command_tx + .send(ThreadCommand::Configure) + .await + .map_err(|_| HashThreadError::ChannelClosed("command channel closed".into()))?; + Ok(()) + } + + async fn update_task(&mut self, new_task: HashTask) -> anyhow::Result> { + 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()))? + .map_err(Into::into) + } + + async fn replace_task(&mut self, new_task: HashTask) -> anyhow::Result> { + 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()))? + .map_err(Into::into) + } + + async fn go_idle(&mut self) -> anyhow::Result> { + 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()))? + .map_err(Into::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 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(); + 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]; + let mut dts_vs_configured = false; + let mut runtime_measurements = ThreadRuntimeMeasurementState::new(); + + loop { + tokio::select! { + Some(command) = command_rx.recv() => { + match command { + ThreadCommand::Configure => { + // Nameplate chain rate; a rough stand-in until the + // board layer supplies a calibration-derived figure. + let expected = HashRate::from_terahashes(config.nominal_hashrate_ths); + if event_tx.send(HashThreadEvent::ExpectedHashRate(expected)).await.is_err() { + debug!("Event channel closed during configure"); + } + } + + 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_layout, + &mut engine_dispatches, + &config, + ).await { + let _ = response_tx.send(Err(err)); + continue; + } + 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)); + } + 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_layout, + &mut engine_dispatches, + &config, + ).await { + let _ = response_tx.send(Err(err)); + continue; + } + 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)); + } + ThreadCommand::GoIdle { response_tx } => { + 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, + &mut reader, + &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); + } + 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 + .inspect(|discovery| { + engine_layout = Bzm2EngineLayout::from_active_coordinates( + discovery.present.iter().map(|engine| (engine.row, engine.col)), + ); + }) + .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, + } + } + read_result = reader.read(&mut read_buf) => { + match read_result { + Ok(0) => break, + Ok(n) => { + let mut should_shutdown = false; + for frame in parser.push(&read_buf[..n]) { + match frame { + TdmFrame::Result(frame) => { + handle_result_frame( + &frame, + &engine_dispatches, + &engine_layout, + &config, + &status, + &event_tx, + &mut runtime_measurements, + ) + .await; + } + TdmFrame::DtsVs(frame) => { + should_shutdown = handle_dts_vs_frame(&frame, &config, &status, &event_tx).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; + } + } + } + _ = 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_layout, + &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"); + record_hardware_error(&status); + } + } + } + } + _ = 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() => { + 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; +} + +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!( + 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, + 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" + ); + + // Validity gate: a fault/trip bit is only authoritative when the + // sensor that produced it is enabled. All of the trip / fault / + // validity / enabled bits live in a single payload byte, so a + // stray or mis-framed frame can set one at random; acting on that + // unconditionally lets line noise on a shared bus force a permanent, + // unrecoverable shutdown (DoS-by-noise). The sensor-enable bits + // reflect host configuration (see `configure_dts_vs_stream`) and are + // set on every genuine frame, so a real over-temp still stops + // immediately -- no debounce, no delayed protection. + // + // Residual risk: the DTS/VS frame carries no checksum, so a single + // noise frame that happens to set BOTH the enable bit and a fault + // bit in the same byte can still trip. This gate removes the far + // more probable single-bit case; closing it fully would need a + // protocol-level integrity field the silicon does not provide. + let thermal_shutdown = + frame.thermal_enabled && (frame.thermal_trip_status || frame.thermal_fault); + let voltage_shutdown = + frame.voltage_enabled && (frame.voltage_fault || frame.voltage_shutdown_status); + if thermal_shutdown || voltage_shutdown { + 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 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)) +} + +/// Bound for a single diagnostic UART read. The actor is one task, so a silent +/// or short-answering chip must not wedge it (including a pending `Shutdown`) on +/// an unbounded `read_exact`; on expiry the diagnostic fails instead of hanging. +const DIAGNOSTIC_READ_TIMEOUT: Duration = Duration::from_secs(2); + +async fn read_exact_diagnostic( + reader: &mut SerialReader, + buf: &mut [u8], +) -> Result<(), HashThreadError> { + tokio::time::timeout(DIAGNOSTIC_READ_TIMEOUT, reader.read_exact(buf)) + .await + .map_err(|_| { + HashThreadError::DiagnosticsFailed(format!( + "timed out after {} ms waiting for UART response", + DIAGNOSTIC_READ_TIMEOUT.as_millis() + )) + })? + .map_err(|err| HashThreadError::DiagnosticsFailed(err.to_string()))?; + Ok(()) +} + +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]; + read_exact_diagnostic(reader, &mut response).await?; + 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]; + read_exact_diagnostic(reader, &mut response).await?; + 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]; + read_exact_diagnostic(reader, &mut response).await?; + 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(()) +} + +// Takes the actor's working state piecemeal; bundling it into a struct would +// just relocate the argument list without simplifying the call site. +#[allow(clippy::too_many_arguments)] +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 { + 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, + engine_layout, + config, + status, + event_tx, + runtime_measurements, + ) + .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, + base_sequence: u8, + engine_layout: &Bzm2EngineLayout, + 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 | 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); + + 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}")) + })?; + + 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 }; + 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( + engine_layout.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, + 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, engine_layout, 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() + }; + + 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(); + 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, + engine_layout: &Bzm2EngineLayout, + config: &Bzm2ThreadConfig, +) -> Option<(Share, Difficulty, u16)> { + if !frame.nonce_valid() { + return None; + } + + 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; + 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, 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 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); +} + +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(all(test, unix))] +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)]; + let engine_layout = Bzm2EngineLayout::from_active_coordinates(engine_coords.clone()); + + dispatch_task_to_board( + &mut writer, + &task, + 1, + &engine_layout, + &mut engine_dispatches, + &config, + ) + .await + .unwrap(); + + 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]; + 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]); + } + + assert_eq!(bytes.len(), expected_total); + assert_eq!(engine_dispatches.len(), engine_coords.len()); + + 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!( + 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); + } + + #[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 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], + 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 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; + + 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, &engine_layout, &config) + .is_some() + ); + + 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 + .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!(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); + 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_unvalidated_trip_bit_does_not_shut_down() { + // A single noisy DTS/VS frame whose only meaningful content is a set + // thermal-trip bit, with the thermal sensor NOT enabled. This is line + // noise, not a credible over-temp, and must not take the thread + // permanently offline. + let config = Bzm2ThreadConfig::new("/dev/null".into(), 5_000_000); + let status = Arc::new(RwLock::new(HashThreadStatus::default())); + let (event_tx, _event_rx) = tokio_mpsc::channel(4); + + let mut parser = TdmFrameParser::new(protocol::DtsVsGeneration::Gen2); + let frame = match parser + .push(&[ + 0x00, + protocol::OPCODE_UART_DTS_VS, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0x10, + ]) + .into_iter() + .next() + { + Some(TdmFrame::DtsVs(frame)) => frame, + other => panic!("expected a DTS/VS frame, got {other:?}"), + }; + + // Premise: the trip bit decoded true, but the sensor is not enabled. + match frame { + TdmDtsVsFrame::Gen2(gen2) => { + assert!(gen2.thermal_trip_status); + assert!(!gen2.thermal_enabled); + } + other => panic!("expected a gen2 frame, got {other:?}"), + } + + let should_shutdown = handle_dts_vs_frame(&frame, &config, &status, &event_tx).await; + assert!( + !should_shutdown, + "an unvalidated trip bit from a disabled sensor must not shut the thread down" + ); + assert_eq!(status.read().unwrap().hardware_errors, 0); + } + + #[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" + ); + } + + #[tokio::test] + async fn diagnostic_read_times_out_and_actor_stays_responsive() { + 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 (mut host_reader, mut host_writer, _host_control) = host_side.split(); + + let config = Bzm2ThreadConfig::new("/dev/null".into(), 5_000_000); + let mut thread = Bzm2Thread::new("BZM2 test".into(), reader, writer, control, config); + let handle = thread.shutdown_handle(); + let mut event_rx = thread.take_event_receiver().unwrap(); + + // Drain the initial status update so we know the actor is up. + let initial = tokio::time::timeout(Duration::from_millis(250), event_rx.recv()) + .await + .unwrap() + .unwrap(); + assert!(matches!(initial, HashThreadEvent::StatusUpdate(_))); + + // Host reads the NOOP request, replies with a single (short) byte, then + // goes silent. Without a bounded read the actor's read_exact would block + // forever, wedging every other command including Shutdown. + let emulator = tokio::spawn(async move { + let mut request = [0u8; 4]; + host_reader.read_exact(&mut request).await.unwrap(); + host_writer.write_all(&[0x02]).await.unwrap(); + tokio::time::sleep(Duration::from_secs(5)).await; + drop(host_writer); + }); + + // QueryNoop must resolve to an error within the diagnostic timeout. + let result = tokio::time::timeout(Duration::from_secs(4), handle.noop(2)).await; + assert!(result.is_ok(), "QueryNoop hung past the diagnostic timeout"); + assert!( + result.unwrap().is_err(), + "QueryNoop should fail on a short, stalled response" + ); + + // The actor is still responsive: Shutdown is honored and the event + // stream closes. + handle.shutdown(); + let closed = tokio::time::timeout(Duration::from_secs(2), async { + while event_rx.recv().await.is_some() {} + }) + .await; + assert!( + closed.is_ok(), + "actor did not honor Shutdown after the diagnostic read timed out" + ); + + emulator.abort(); + } + + #[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 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], + 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, &engine_layout, &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)); + } + + #[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); + 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 + .unwrap() + .unwrap(); + if n == 0 { + break; + } + bytes.extend_from_slice(&buf[..n]); + } + + 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/asic/bzm2/uart.rs b/mujina-miner/src/asic/bzm2/uart.rs new file mode 100644 index 00000000..6994e5b9 --- /dev/null +++ b/mujina-miner/src/asic/bzm2/uart.rs @@ -0,0 +1,1140 @@ +use std::time::Duration; + +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::time::{Instant, timeout}; + +use crate::transport::{SerialReader, SerialWriter}; + +use super::protocol::{ + 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; +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_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; +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; +const DISCOVERED_ENGINE_END_NONCE: u32 = 0xffff_fffe; + +#[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 { + #[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, + }, + + #[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 }, + + #[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. +/// +/// 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 + } + + /// 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, + 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 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" { + Ok(()) + } else { + Err(Bzm2UartError::UnexpectedNoopPayload { asic, data }) + } + } + + 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_with_timeout(DEFAULT_ASIC_ID, probe_timeout) + .await + .is_err() + { + break; + } + self.assign_default_asic_id(next_id).await?; + // Bound the post-assignment verify with the same probe timeout: a + // device that passed the timed probe on 0xfa but then fails to echo + // on its new id (it died, an id collision garbled the reply, or the + // assignment half-took) must not wedge enumeration — and board init + // with it — on an unbounded read_exact. + self.verify_noop_bz2_with_timeout(next_id, probe_timeout) + .await?; + assigned.push(next_id); + } + 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?; + 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()) + } + + // Mirrors the write-job wire format field for field; a parameter struct + // would obscure the correspondence with the opcode layout. + #[allow(clippy::too_many_arguments)] + 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(()) + } + + /// 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 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 + && 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, + 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 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, + 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(all(test, unix))] +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() { + 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, + } + )); + } + + #[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); + } + + #[test] + 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)); + } + + #[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(); + } + + #[tokio::test] + async fn enumerate_chain_bounds_post_assignment_verify() { + 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); + + // First timed NOOP probe on the default id -> answer "BZ2" (ok). + let mut probe = [0u8; 4]; + file.read_exact(&mut probe).unwrap(); + assert_eq!(probe.to_vec(), encode_noop(DEFAULT_ASIC_ID)); + file.write_all(&[DEFAULT_ASIC_ID, OPCODE_UART_NOOP, b'B', b'Z', b'2']) + .unwrap(); + file.flush().unwrap(); + + // Accept the assign-id write... + let assign_len = encode_write_register( + DEFAULT_ASIC_ID, + NOTCH_REG, + LOCAL_REG_ASIC_ID, + &7u32.to_le_bytes(), + ) + .len(); + let mut assign = vec![0u8; assign_len]; + file.read_exact(&mut assign).unwrap(); + + // ...then go silent for the post-assignment verify probe. + let mut verify = [0u8; 4]; + let _ = file.read_exact(&mut verify); + std::thread::sleep(Duration::from_millis(300)); + }); + + let stream = SerialStream::new(&serial_path, 5_000_000).unwrap(); + let (reader, writer, _control) = stream.split(); + let mut uart = Bzm2UartController::new(reader, writer); + + // Without the post-assign timeout this call never returns; assert it + // completes with a bounded timeout error well inside the wall clock. + let result = tokio::time::timeout( + Duration::from_secs(2), + uart.enumerate_chain_with_timeout(4, 7, Duration::from_millis(100)), + ) + .await; + assert!( + result.is_ok(), + "enumerate_chain hung on the post-assign verify" + ); + assert!(matches!( + result.unwrap(), + Err(Bzm2UartError::NoopTimeout { asic: 7, .. }) + )); + + emulator.join().unwrap(); + } +} diff --git a/mujina-miner/src/asic/hash_thread.rs b/mujina-miner/src/asic/hash_thread.rs index bf4f2636..6be56026 100644 --- a/mujina-miner/src/asic/hash_thread.rs +++ b/mujina-miner/src/asic/hash_thread.rs @@ -76,6 +76,29 @@ pub struct HashThreadStatus { pub is_active: bool, } +/// Temperature reading reported by a hash thread. +#[derive(Debug, Clone, PartialEq)] +pub struct HashThreadTemperatureReading { + pub name: String, + pub temperature_c: Option, +} + +/// Voltage/current/power reading reported by a hash thread. +#[derive(Debug, Clone, PartialEq)] +pub struct HashThreadPowerReading { + pub name: String, + pub voltage_v: Option, + pub current_a: Option, + pub power_w: Option, +} + +/// Telemetry update reported by a hash thread. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct HashThreadTelemetryUpdate { + pub temperatures: Vec, + pub powers: Vec, +} + /// Events emitted by HashThreads back to the scheduler. /// /// When a thread shuts down (USB unplug, fault, user request, etc.), it closes @@ -106,8 +129,38 @@ pub enum HashThreadEvent { /// /// Emitted after `configure()` and whenever the expectation changes. ExpectedHashRate(HashRate), + + /// Additional telemetry update + TelemetryUpdate(HashThreadTelemetryUpdate), } +/// Error types for HashThread operations. +#[derive(Debug, thiserror::Error)] +pub enum HashThreadError { + #[error("Thread has been shut down")] + ThreadOffline, + + #[error("Channel closed: {0}")] + ChannelClosed(String), + + #[error("Work assignment failed: {0}")] + WorkAssignmentFailed(String), + + #[error("Preemption failed: {0}")] + PreemptionFailed(String), + + #[error("Telemetry query failed: {0}")] + TelemetryQueryFailed(String), + + #[error("Diagnostics failed: {0}")] + DiagnosticsFailed(String), + + #[error("Shutdown timeout")] + ShutdownTimeout, + + #[error("Chip initialization failed: {0}")] + InitializationFailed(String), +} // --------------------------------------------------------------------------- // Hardware abstraction traits for hash threads // --------------------------------------------------------------------------- diff --git a/mujina-miner/src/asic/mod.rs b/mujina-miner/src/asic/mod.rs index 6d8a347a..4cb84451 100644 --- a/mujina-miner/src/asic/mod.rs +++ b/mujina-miner/src/asic/mod.rs @@ -1,4 +1,5 @@ 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 44c30612..26e91ffa 100644 --- a/mujina-miner/src/backplane.rs +++ b/mujina-miner/src/backplane.rs @@ -139,10 +139,14 @@ impl Backplane { info, threads, telemetry_rx, + command_tx, shutdown, } = conn; - let registration = BoardRegistration { telemetry_rx }; + let registration = BoardRegistration { + telemetry_rx, + command_tx, + }; if let Err(e) = self.board_reg_tx.send(registration).await { error!( board = %info.model, @@ -290,6 +294,41 @@ impl Backplane { } } + Ok(()) + } + /// Attach a configured board directly without going through a synthetic transport. + pub async fn attach_configured_board( + &mut self, + device_type: &str, + device_id: String, + ) -> Result<()> { + let Some(descriptor) = self.virtual_registry.find(device_type) else { + error!(device_type = %device_type, "No configured board descriptor found"); + return Ok(()); + }; + + info!( + board = descriptor.name, + device_type = %device_type, + device_id = %device_id, + "Configured board attached." + ); + + let conn = match (descriptor.create_fn)().await { + Ok(conn) => conn, + Err(e) => { + error!( + board = descriptor.name, + device_type = %device_type, + error = %e, + "Failed to create configured board" + ); + return Ok(()); + } + }; + + self.start_board(device_id, conn).await; + Ok(()) } } diff --git a/mujina-miner/src/board/bitaxe.rs b/mujina-miner/src/board/bitaxe.rs index 2108e665..03b3769b 100644 --- a/mujina-miner/src/board/bitaxe.rs +++ b/mujina-miner/src/board/bitaxe.rs @@ -228,6 +228,7 @@ async fn create_from_usb(device: UsbDeviceInfo) -> Result { info, threads, telemetry_rx, + command_tx: None, shutdown: Some(shutdown), }) } @@ -419,6 +420,7 @@ impl Bitaxe { }, ], threads: Vec::new(), // TODO: populate from hash thread telemetry + ..Default::default() }); // Periodic log diff --git a/mujina-miner/src/board/cpu.rs b/mujina-miner/src/board/cpu.rs index 41e30a1f..850a9255 100644 --- a/mujina-miner/src/board/cpu.rs +++ b/mujina-miner/src/board/cpu.rs @@ -55,6 +55,7 @@ async fn create_cpu_board() -> Result { info, threads, telemetry_rx, + command_tx: None, shutdown: None, }) } diff --git a/mujina-miner/src/board/emberone00.rs b/mujina-miner/src/board/emberone00.rs index 60c8e56b..c17cb67c 100644 --- a/mujina-miner/src/board/emberone00.rs +++ b/mujina-miner/src/board/emberone00.rs @@ -171,6 +171,7 @@ async fn create_from_usb(device: UsbDeviceInfo) -> Result { info, threads: Vec::new(), telemetry_rx, + command_tx: None, shutdown: Some(shutdown), }) } diff --git a/mujina-miner/src/board/mod.rs b/mujina-miner/src/board/mod.rs index 3b9e161b..f5eec8e6 100644 --- a/mujina-miner/src/board/mod.rs +++ b/mujina-miner/src/board/mod.rs @@ -2,13 +2,15 @@ pub(crate) mod bitaxe; pub(crate) mod cpu; pub(crate) mod emberone00; pub mod pattern; +pub mod power; use anyhow::Result; use futures::future::BoxFuture; -use tokio::sync::watch; +use tokio::sync::{mpsc, watch}; use crate::{ - api_client::types::BoardTelemetry, asic::hash_thread::HashThread, transport::UsbDeviceInfo, + api::commands::BoardCommand, api_client::types::BoardTelemetry, asic::hash_thread::HashThread, + transport::UsbDeviceInfo, }; /// Returned by board factory functions with everything the backplane @@ -23,6 +25,10 @@ pub struct BackplaneConnector { /// Watch receiver for the board's telemetry stream. pub telemetry_rx: watch::Receiver, + /// Sender for board commands (diagnostics, fan control). `None` if + /// the board accepts no commands. + pub command_tx: Option>, + /// Shuts down the board when awaited. `None` if the board has /// no shutdown work to do. pub shutdown: Option>, diff --git a/mujina-miner/src/board/power.rs b/mujina-miner/src/board/power.rs new file mode 100644 index 00000000..1a2ead14 --- /dev/null +++ b/mujina-miner/src/board/power.rs @@ -0,0 +1,425 @@ +use std::time::Duration; + +use anyhow::Result; +use async_trait::async_trait; +use tokio::fs; +use tokio::time::sleep; + +use crate::{ + asic::hash_thread::{AsicEnable, VoltageRegulator}, + hw_trait::{ + gpio::{GpioPin, PinValue}, + i2c::I2c, + }, + peripheral::tps546::Tps546, +}; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct PowerRailTelemetry { + pub vin_volts: f32, + pub vout_volts: f32, + pub current_amps: f32, + pub temperature_c: f32, + pub power_watts: f32, +} + +#[async_trait] +pub trait PowerRail: Send + Sync { + async fn initialize(&mut self) -> Result<()>; + async fn set_voltage(&mut self, volts: f32) -> Result<()>; + async fn telemetry(&mut self) -> Result; +} + +pub struct Tps546PowerRail { + inner: Tps546, +} + +impl Tps546PowerRail { + pub fn new(inner: Tps546) -> Self { + Self { inner } + } + + pub fn into_inner(self) -> Tps546 { + self.inner + } +} + +#[async_trait] +impl PowerRail for Tps546PowerRail { + async fn initialize(&mut self) -> Result<()> { + self.inner.init().await + } + + async fn set_voltage(&mut self, volts: f32) -> Result<()> { + self.inner.set_vout(volts).await + } + + async fn telemetry(&mut self) -> Result { + Ok(PowerRailTelemetry { + vin_volts: self.inner.get_vin().await? as f32 / 1000.0, + vout_volts: self.inner.get_vout().await? as f32 / 1000.0, + current_amps: self.inner.get_iout().await? as f32 / 1000.0, + temperature_c: self.inner.get_temperature().await? as f32, + power_watts: self.inner.get_power().await? as f32 / 1000.0, + }) + } +} + +#[async_trait] +impl VoltageRegulator for Tps546PowerRail { + async fn set_voltage(&mut self, volts: f32) -> Result<()> { + PowerRail::set_voltage(self, volts).await + } +} + +pub struct GpioResetLine { + pin: PIN, + active_low: bool, +} + +impl GpioResetLine { + pub fn new(pin: PIN, active_low: bool) -> Self { + Self { pin, active_low } + } + + pub fn into_inner(self) -> PIN { + self.pin + } + + async fn drive(&mut self, asserted: bool) -> Result<()> + where + PIN: GpioPin, + { + let value = if asserted == self.active_low { + PinValue::Low + } else { + PinValue::High + }; + self.pin.write(value).await?; + Ok(()) + } + + pub async fn pulse(&mut self, assert_for: Duration, settle_for: Duration) -> Result<()> + where + PIN: GpioPin, + { + self.drive(true).await?; + sleep(assert_for).await; + self.drive(false).await?; + sleep(settle_for).await; + Ok(()) + } +} + +#[derive(Debug, Clone)] +pub struct FileGpioPin { + path: String, + high_value: String, + low_value: String, +} + +impl FileGpioPin { + pub fn new( + path: impl Into, + high_value: impl Into, + low_value: impl Into, + ) -> Self { + Self { + path: path.into(), + high_value: high_value.into(), + low_value: low_value.into(), + } + } +} + +#[async_trait] +impl GpioPin for FileGpioPin { + async fn set_mode( + &mut self, + _mode: crate::hw_trait::gpio::PinMode, + ) -> crate::hw_trait::Result<()> { + Ok(()) + } + + async fn write(&mut self, value: PinValue) -> crate::hw_trait::Result<()> { + let raw = match value { + PinValue::Low => &self.low_value, + PinValue::High => &self.high_value, + }; + fs::write(&self.path, raw).await?; + Ok(()) + } + + async fn read(&mut self) -> crate::hw_trait::Result { + let raw = fs::read_to_string(&self.path).await?; + if raw.trim() == self.high_value.trim() { + Ok(PinValue::High) + } else { + Ok(PinValue::Low) + } + } +} + +#[async_trait] +impl AsicEnable for GpioResetLine { + async fn enable(&mut self) -> Result<()> { + self.drive(false).await + } + + async fn disable(&mut self) -> Result<()> { + self.drive(true).await + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct VoltageStackStep { + pub rail_index: usize, + pub voltage: f32, + pub settle_for: Duration, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct VoltageStackBringupPlan { + pub assert_reset_before_power: bool, + pub pre_power_delay: Duration, + pub post_power_delay: Duration, + pub release_reset_delay: Duration, + pub steps: Vec, +} + +#[derive(Debug, Clone)] +pub struct FilePowerRail { + set_path: String, + write_scale: f32, + enable_path: Option, + enable_value: Option, +} + +impl FilePowerRail { + pub fn new(path: impl Into, write_scale: f32) -> Self { + Self { + set_path: path.into(), + write_scale, + enable_path: None, + enable_value: None, + } + } + + pub fn with_enable( + mut self, + enable_path: impl Into, + enable_value: impl Into, + ) -> Self { + self.enable_path = Some(enable_path.into()); + self.enable_value = Some(enable_value.into()); + self + } + + fn encode_voltage(&self, volts: f32) -> String { + if (self.write_scale - 1.0).abs() < f32::EPSILON { + format!("{volts:.6}") + } else { + format!("{}", (volts * self.write_scale).round() as i64) + } + } +} + +#[async_trait] +impl PowerRail for FilePowerRail { + async fn initialize(&mut self) -> Result<()> { + if let (Some(path), Some(value)) = (&self.enable_path, &self.enable_value) { + fs::write(path, value).await?; + } + Ok(()) + } + + async fn set_voltage(&mut self, volts: f32) -> Result<()> { + fs::write(&self.set_path, self.encode_voltage(volts)).await?; + Ok(()) + } + + async fn telemetry(&mut self) -> Result { + Ok(PowerRailTelemetry { + vin_volts: 0.0, + vout_volts: 0.0, + current_amps: 0.0, + temperature_c: 0.0, + power_watts: 0.0, + }) + } +} + +impl Default for VoltageStackBringupPlan { + fn default() -> Self { + Self { + assert_reset_before_power: true, + pre_power_delay: Duration::from_millis(10), + post_power_delay: Duration::from_millis(25), + release_reset_delay: Duration::from_millis(25), + steps: Vec::new(), + } + } +} + +impl VoltageStackBringupPlan { + pub async fn apply( + &self, + rails: &mut [R], + mut reset_line: Option<&mut GpioResetLine>, + ) -> Result<()> + where + R: PowerRail, + PIN: GpioPin, + { + if self.assert_reset_before_power { + if let Some(reset_line) = reset_line.as_deref_mut() { + reset_line.disable().await?; + } + sleep(self.pre_power_delay).await; + } + + for rail in rails.iter_mut() { + rail.initialize().await?; + } + + for step in &self.steps { + let rail = rails + .get_mut(step.rail_index) + .ok_or_else(|| anyhow::anyhow!("rail index {} out of range", step.rail_index))?; + rail.set_voltage(step.voltage).await?; + sleep(step.settle_for).await; + } + + sleep(self.post_power_delay).await; + + if let Some(reset_line) = reset_line { + reset_line.enable().await?; + sleep(self.release_reset_delay).await; + } + + Ok(()) + } + + pub async fn shutdown( + &self, + rails: &mut [R], + reset_line: Option<&mut GpioResetLine>, + ) -> Result<()> + where + R: PowerRail, + PIN: GpioPin, + { + if let Some(reset_line) = reset_line { + reset_line.disable().await?; + } + + for rail in rails.iter_mut().rev() { + rail.set_voltage(0.0).await?; + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hw_trait::{Result as HwResult, gpio::PinMode}; + + #[derive(Default)] + struct MockPin { + writes: Vec, + } + + #[async_trait] + impl GpioPin for MockPin { + async fn set_mode(&mut self, _mode: PinMode) -> HwResult<()> { + Ok(()) + } + + async fn write(&mut self, value: PinValue) -> HwResult<()> { + self.writes.push(value); + Ok(()) + } + + async fn read(&mut self) -> HwResult { + Ok(self.writes.last().copied().unwrap_or(PinValue::Low)) + } + } + + #[derive(Default)] + struct MockRail { + initialized: bool, + voltages: Vec, + } + + #[async_trait] + impl PowerRail for MockRail { + async fn initialize(&mut self) -> Result<()> { + self.initialized = true; + Ok(()) + } + + async fn set_voltage(&mut self, volts: f32) -> Result<()> { + self.voltages.push(volts); + Ok(()) + } + + async fn telemetry(&mut self) -> Result { + Ok(PowerRailTelemetry { + vin_volts: 12.0, + vout_volts: self.voltages.last().copied().unwrap_or_default(), + current_amps: 1.0, + temperature_c: 42.0, + power_watts: 12.0, + }) + } + } + + #[tokio::test] + async fn bringup_plan_sequences_rails_then_releases_reset() { + let mut rails = vec![MockRail::default(), MockRail::default()]; + let mut reset = GpioResetLine::new(MockPin::default(), true); + let plan = VoltageStackBringupPlan { + pre_power_delay: Duration::from_millis(0), + post_power_delay: Duration::from_millis(0), + release_reset_delay: Duration::from_millis(0), + steps: vec![ + VoltageStackStep { + rail_index: 0, + voltage: 0.82, + settle_for: Duration::from_millis(0), + }, + VoltageStackStep { + rail_index: 1, + voltage: 0.79, + settle_for: Duration::from_millis(0), + }, + ], + ..Default::default() + }; + + plan.apply(&mut rails, Some(&mut reset)).await.unwrap(); + + assert!(rails[0].initialized); + assert!(rails[1].initialized); + assert_eq!(rails[0].voltages, vec![0.82]); + assert_eq!(rails[1].voltages, vec![0.79]); + let pin = reset.into_inner(); + assert_eq!(pin.writes, vec![PinValue::Low, PinValue::High]); + } + + #[tokio::test] + async fn shutdown_plan_asserts_reset_then_powers_off_rails() { + let mut rails = vec![MockRail::default(), MockRail::default()]; + let mut reset = GpioResetLine::new(MockPin::default(), true); + let plan = VoltageStackBringupPlan::default(); + + plan.shutdown(&mut rails, Some(&mut reset)).await.unwrap(); + + assert_eq!(rails[0].voltages, vec![0.0]); + assert_eq!(rails[1].voltages, vec![0.0]); + let pin = reset.into_inner(); + assert_eq!(pin.writes, vec![PinValue::Low]); + } +} diff --git a/mujina-miner/src/scheduler.rs b/mujina-miner/src/scheduler.rs index e9f44bfa..6a9fa90f 100644 --- a/mujina-miner/src/scheduler.rs +++ b/mujina-miner/src/scheduler.rs @@ -634,6 +634,10 @@ impl Scheduler { ); } + HashThreadEvent::TelemetryUpdate(_) => { + trace!(thread = %thread_name, "Thread telemetry update"); + } + HashThreadEvent::ExpectedHashRate(rate) => { let Some(entry) = self.threads.get_mut(thread_id) else { return; diff --git a/mujina-miner/src/transport/serial.rs b/mujina-miner/src/transport/serial.rs index e04ad65a..44da0f6d 100644 --- a/mujina-miner/src/transport/serial.rs +++ b/mujina-miner/src/transport/serial.rs @@ -120,16 +120,19 @@ struct SerialInner { } /// Reader half of a split serial stream. +#[derive(Clone)] pub struct SerialReader { inner: Arc, } /// Writer half of a split serial stream. +#[derive(Clone)] pub struct SerialWriter { inner: Arc, } /// Control handle for a split serial stream. +#[derive(Clone)] pub struct SerialControl { inner: Arc, }