From 596e83bf23c15e7f3ea1526f6adcbe667f60e2f5 Mon Sep 17 00:00:00 2001 From: jbride Date: Thu, 26 Feb 2026 18:52:21 -0700 Subject: [PATCH] REST API: allow for voltage change on board --- docs/voltage_change_api.md | 190 ++++++++++++++++++++++ mujina-miner/src/api/commands.rs | 9 ++ mujina-miner/src/api/registry.rs | 24 ++- mujina-miner/src/api/server.rs | 2 +- mujina-miner/src/api/v0.rs | 77 ++++++++- mujina-miner/src/api_client/types.rs | 19 +++ mujina-miner/src/board/bitaxe.rs | 229 ++++++++++++++++----------- mujina-miner/src/board/cpu.rs | 2 +- mujina-miner/src/board/emberone00.rs | 2 +- mujina-miner/src/board/mod.rs | 11 +- 10 files changed, 464 insertions(+), 101 deletions(-) create mode 100644 docs/voltage_change_api.md diff --git a/docs/voltage_change_api.md b/docs/voltage_change_api.md new file mode 100644 index 00000000..861f99d9 --- /dev/null +++ b/docs/voltage_change_api.md @@ -0,0 +1,190 @@ +# Voltage Change API + +Allows runtime adjustment of a board's core voltage via the REST API without +restarting the miner. + +## Endpoint + +``` +PATCH /api/v0/boards/{name} +Content-Type: application/json +``` + +### Path parameter + +| Parameter | Description | +|-----------|-------------| +| `name` | Board name as returned by `GET /api/v0/boards` (e.g. `bitaxe-71bfd369`) | + +### Request body + +```json +{ + "powers": [ + { "name": "core", "voltage_v": 1.134 } + ] +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `powers` | array, optional | List of power-domain patches to apply | +| `powers[].name` | string | Power domain name. Currently only `"core"` is supported on Bitaxe boards. | +| `powers[].voltage_v` | float, optional | Target output voltage in volts. Omit the field to leave the domain unchanged. | + +Multiple domains can be patched in a single request; they are applied in array +order and the handler aborts on the first error. + +### Response + +On success (`200 OK`) the current `BoardState` snapshot is returned. Because +the stats monitor reads back voltage every 5 seconds, the `powers[].voltage_v` +field in the response may still show the previous reading — issue a `GET` +after the next stats interval to confirm the new value. + +```json +{ + "name": "bitaxe-71bfd369", + "model": "Bitaxe Gamma", + "powers": [ + { "name": "input", "voltage_v": 5.003 }, + { "name": "core", "voltage_v": 1.131, "current_a": 8.2, "power_w": 9.3 } + ], + ... +} +``` + +### Error responses + +| Status | Cause | +|--------|-------| +| `404 Not Found` | No connected board with the given name | +| `422 Unprocessable Entity` | Unknown power domain name, or voltage outside the hardware-enforced range, or board type does not support voltage control | +| `500 Internal Server Error` | Command channel closed, or 5-second hardware timeout | + +## Voltage range (Bitaxe Gamma / BM1370) + +Voltage limits are enforced by the TPS546D24A driver and cannot be overridden +at runtime. Requests outside this window are rejected before any I2C write +occurs. + +| Parameter | Value | +|-----------|-------| +| Minimum (`vout_min`) | **1.0 V** | +| Maximum (`vout_max`) | **2.0 V** | +| Default at boot (`vout_command`) | **1.15 V** | + +The limits are defined in `mujina-miner/src/board/bitaxe.rs` +`init_power_controller()` as part of the `Tps546Config` struct. They are +not configurable at runtime or via a config file. + +## Board support + +| Board | Voltage control | +|-------|----------------| +| Bitaxe Gamma | Supported (`"core"` domain) | +| CPU Miner | Not supported → `422` | +| EmberOne | Not supported → `422` | + +## Example usage + +```bash +# Set core voltage to 1.134 V +curl -s -X PATCH http://127.0.0.1:7785/api/v0/boards/bitaxe-71bfd369 \ + -H 'Content-Type: application/json' \ + -d '{"powers":[{"name":"core","voltage_v":1.134}]}' | jq . + +# Confirm (wait up to 5 s for stats monitor to refresh) +curl -s http://127.0.0.1:7785/api/v0/boards/bitaxe-71bfd369 | jq .powers + +# Unknown domain → 422 +curl -s -o /dev/null -w '%{http_code}' -X PATCH \ + http://127.0.0.1:7785/api/v0/boards/bitaxe-71bfd369 \ + -H 'Content-Type: application/json' \ + -d '{"powers":[{"name":"input","voltage_v":5.0}]}' + +# Out-of-range voltage → 422 +curl -s -o /dev/null -w '%{http_code}' -X PATCH \ + http://127.0.0.1:7785/api/v0/boards/bitaxe-71bfd369 \ + -H 'Content-Type: application/json' \ + -d '{"powers":[{"name":"core","voltage_v":9.9}]}' +``` + +## Implementation overview + +The feature is wired through seven files: + +### `mujina-miner/src/api_client/types.rs` +Added `PowerPatch` and `BoardPatchRequest` — the serde/OpenAPI types that +define the request body shape. + +### `mujina-miner/src/api/commands.rs` +Added `BoardCommand::SetVoltage { domain, voltage_v, reply }` to the existing +board command enum. The `reply` field is a oneshot channel so the API handler +can await the hardware result synchronously. + +### `mujina-miner/src/board/mod.rs` +Added `cmd_tx: Option>` to `BoardRegistration`. +Boards that support runtime commands populate this field; boards that do not +(CPU miner, EmberOne) set it to `None`. + +### `mujina-miner/src/api/registry.rs` +Added two methods to `BoardRegistry`: +- `find_cmd_tx(name)` — returns the command sender for a named board, or + `None` if not found or unsupported. +- `contains(name)` — checks whether a board is connected, independent of + command support (used to distinguish 404 from 422). + +### `mujina-miner/src/api/v0.rs` +Added `patch_board` handler (`PATCH /boards/{name}`) and registered it +alongside the existing `get_board` route. The handler: +1. Locks the registry briefly to resolve the board's command sender. +2. Returns `404` if the board is absent, `422` if `cmd_tx` is `None`. +3. Sends one `SetVoltage` command per `powers` entry, awaiting each reply + with a 5-second timeout. +4. Maps `Err` replies from the board to `422`; timeout/channel errors to `500`. +5. Returns the current `BoardState` snapshot. + +### `mujina-miner/src/board/bitaxe.rs` +Three changes: + +1. **`BitaxeBoard` struct** — added `cmd_rx: Option>`. + +2. **`create_from_usb` factory** — creates the `mpsc::channel`, stores + `cmd_tx` in `BoardRegistration` and `cmd_rx` in the board struct. + +3. **`spawn_stats_monitor`** — takes ownership of `cmd_rx` alongside the + existing `state_tx`. The monitoring loop now uses `tokio::select!` to + interleave the 5-second telemetry tick with incoming commands: + + ```rust + tokio::select! { + _ = interval.tick() => { /* read sensors, publish BoardState */ } + Some(cmd) = cmd_rx.recv() => { handle_board_command(cmd, ®ulator).await; } + } + ``` + + **Why `tokio::select!` is necessary.** The previous loop called + `interval.tick().await` unconditionally, which suspends the task for up + to 5 seconds. While suspended the task cannot read `cmd_rx`; any + incoming command queues in the channel buffer and is ignored until the + next tick fires. In the worst case — a command arrives just after a tick + — the board sleeps for ~5 s, executes the I2C write, then replies, but + the API handler's own 5-second timeout has already expired and the caller + receives a `500` even though the hardware operation succeeded. + + `tokio::select!` eliminates that window by racing both futures + concurrently. A command that arrives mid-interval is processed + immediately (typically within a few hundred milliseconds of I2C + round-trip time) without disturbing the telemetry cadence — the interval + timer continues counting down and fires normally on schedule. + + `handle_board_command` validates the domain name (`"core"` only), then + calls `regulator.lock().await.set_vout(voltage_v)` and sends the result + back on the reply channel. The actual range validation (`1.0 V – 2.0 V`) + happens inside `Tps546::set_vout` in + `mujina-miner/src/peripheral/tps546.rs`. + +### `mujina-miner/src/api/server.rs` +One-line test fixture update: `BoardRegistration { state_rx, cmd_tx: None }` +to match the new struct shape. diff --git a/mujina-miner/src/api/commands.rs b/mujina-miner/src/api/commands.rs index 83994f61..460b4e72 100644 --- a/mujina-miner/src/api/commands.rs +++ b/mujina-miner/src/api/commands.rs @@ -25,4 +25,13 @@ pub enum BoardCommand { percent: Option, reply: oneshot::Sender>, }, + + /// Set the output voltage of a named power domain. + SetVoltage { + /// Power domain name (e.g. "core"). + domain: String, + /// Target voltage in volts. + voltage_v: f32, + reply: oneshot::Sender>, + }, } diff --git a/mujina-miner/src/api/registry.rs b/mujina-miner/src/api/registry.rs index d4704c52..52f72850 100644 --- a/mujina-miner/src/api/registry.rs +++ b/mujina-miner/src/api/registry.rs @@ -1,5 +1,8 @@ //! Dynamic board registration tracking. +use tokio::sync::mpsc; + +use crate::api::commands::BoardCommand; use crate::api_client::types::BoardTelemetry; use crate::board::BoardRegistration; @@ -35,6 +38,25 @@ impl BoardRegistry { .map(|reg| reg.telemetry_rx.borrow().clone()) .collect() } + + /// Return the command sender for a connected board by name, or `None` if + /// the board is not found or does not support runtime commands. + pub fn find_cmd_tx(&self, name: &str) -> Option> { + self.boards + .iter() + .find(|reg| { + reg.telemetry_rx.has_changed().is_ok() && reg.telemetry_rx.borrow().name == name + }) + .and_then(|reg| reg.cmd_tx.clone()) + } + + /// Return `true` if a connected board with the given name exists (regardless + /// of whether it supports commands). + pub fn contains(&self, name: &str) -> bool { + self.boards.iter().any(|reg| { + reg.telemetry_rx.has_changed().is_ok() && reg.telemetry_rx.borrow().name == name + }) + } } #[cfg(test)] @@ -53,7 +75,7 @@ mod tests { ..Default::default() }; let (tx, rx) = watch::channel(telemetry); - (tx, BoardRegistration { telemetry_rx: rx }) + (tx, BoardRegistration { telemetry_rx: rx, cmd_tx: None }) } #[test] diff --git a/mujina-miner/src/api/server.rs b/mujina-miner/src/api/server.rs index bb0237cb..b7b598c4 100644 --- a/mujina-miner/src/api/server.rs +++ b/mujina-miner/src/api/server.rs @@ -163,7 +163,7 @@ 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, cmd_tx: None }); board_senders.push(tx); } diff --git a/mujina-miner/src/api/v0.rs b/mujina-miner/src/api/v0.rs index 5778fe5d..b1010cff 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, + BoardPatchRequest, BoardTelemetry, MinerPatchRequest, MinerTelemetry, SourceTelemetry, }; /// Build the v0 API routes with OpenAPI metadata. @@ -25,7 +25,7 @@ pub fn routes() -> OpenApiRouter { .routes(routes!(health)) .routes(routes!(get_miner, patch_miner)) .routes(routes!(get_boards)) - .routes(routes!(get_board)) + .routes(routes!(get_board, patch_board)) .routes(routes!(get_sources)) .routes(routes!(get_source)) } @@ -139,6 +139,77 @@ async fn get_board( .ok_or(StatusCode::NOT_FOUND) } +/// Apply partial updates to a board's configuration. +#[utoipa::path( + patch, + path = "/boards/{name}", + tag = "boards", + params( + ("name" = String, Path, description = "Board name"), + ), + request_body = BoardPatchRequest, + responses( + (status = OK, description = "Updated board telemetry", body = BoardTelemetry), + (status = NOT_FOUND, description = "Board not found"), + (status = UNPROCESSABLE_ENTITY, description = "Unknown power domain or voltage out of range"), + (status = INTERNAL_SERVER_ERROR, description = "Command channel error or hardware fault"), + ), +)] +async fn patch_board( + State(state): State, + Path(name): Path, + Json(req): Json, +) -> Result, StatusCode> { + if let Some(powers) = req.powers { + // Resolve the board's command sender while holding the lock as briefly as possible. + let (board_exists, cmd_tx) = { + let registry = state + .board_registry + .lock() + .unwrap_or_else(|e| e.into_inner()); + (registry.contains(&name), registry.find_cmd_tx(&name)) + }; + + if !board_exists { + return Err(StatusCode::NOT_FOUND); + } + let cmd_tx = cmd_tx.ok_or(StatusCode::UNPROCESSABLE_ENTITY)?; + + for patch in powers { + let Some(voltage_v) = patch.voltage_v else { + continue; + }; + let (reply_tx, reply_rx) = oneshot::channel(); + cmd_tx + .send(BoardCommand::SetVoltage { + domain: patch.name, + voltage_v, + reply: reply_tx, + }) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let result = tokio::time::timeout(Duration::from_secs(5), reply_rx).await; + match result { + Ok(Ok(Ok(()))) => {} + Ok(Ok(Err(_))) => return Err(StatusCode::UNPROCESSABLE_ENTITY), + _ => return Err(StatusCode::INTERNAL_SERVER_ERROR), + } + } + } + + // Return the current board snapshot. + state + .board_registry + .lock() + .unwrap_or_else(|e| e.into_inner()) + .boards() + .into_iter() + .find(|b| b.name == name) + .map(Json) + .ok_or(StatusCode::NOT_FOUND) +} + /// Return all registered job sources. #[utoipa::path( get, diff --git a/mujina-miner/src/api_client/types.rs b/mujina-miner/src/api_client/types.rs index 7168d599..cd52bc24 100644 --- a/mujina-miner/src/api_client/types.rs +++ b/mujina-miner/src/api_client/types.rs @@ -88,6 +88,25 @@ pub struct SetFanTargetRequest { pub target_percent: Option, } +/// A single power-domain patch entry for `PATCH /api/v0/boards/{name}`. +#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] +pub struct PowerPatch { + /// Power domain name (e.g. "core"). + pub name: String, + /// Desired output voltage in volts. + #[serde(skip_serializing_if = "Option::is_none")] + pub voltage_v: Option, +} + +/// Writable fields for `PATCH /api/v0/boards/{name}`. +/// +/// All fields are optional; only those present in the request body are applied. +#[derive(Clone, Debug, Default, Deserialize, Serialize, ToSchema)] +pub struct BoardPatchRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub powers: Option>, +} + /// Job source telemetry. #[derive(Clone, Debug, Default, Deserialize, Serialize, ToSchema)] pub struct SourceTelemetry { diff --git a/mujina-miner/src/board/bitaxe.rs b/mujina-miner/src/board/bitaxe.rs index 89d1e432..25be0b25 100644 --- a/mujina-miner/src/board/bitaxe.rs +++ b/mujina-miner/src/board/bitaxe.rs @@ -16,6 +16,7 @@ use tokio_stream::StreamExt; use tokio_util::codec::{FramedRead, FramedWrite}; use crate::{ + api::commands::BoardCommand, api_client::types::{BoardTelemetry, Fan, PowerMeasurement, TemperatureSensor}, asic::{ ChipInfo, @@ -146,6 +147,9 @@ pub struct BitaxeBoard { /// Channel for publishing board telemetry to the API server. /// Taken by `spawn_stats_monitor` which publishes periodic snapshots. telemetry_tx: Option>, + /// Command receiver from the API layer. + /// Taken by `spawn_stats_monitor` so the monitoring loop can handle commands. + cmd_rx: Option>, } impl BitaxeBoard { @@ -177,6 +181,7 @@ impl BitaxeBoard { data_path: &str, serial_number: Option, telemetry_tx: watch::Sender, + cmd_rx: tokio::sync::mpsc::Receiver, ) -> Result { // Bitaxe runs original bitaxe-raw firmware (v0 response format) let control_channel = ControlChannel::new(control, ResponseFormat::V0); @@ -204,6 +209,7 @@ impl BitaxeBoard { stats_task_handle: None, serial_number, telemetry_tx: Some(telemetry_tx), + cmd_rx: Some(cmd_rx), }) } @@ -704,6 +710,12 @@ impl BitaxeBoard { .take() .expect("telemetry_tx must be present when spawning stats monitor"); + // Take the command receiver so this task handles API commands + let mut cmd_rx = self + .cmd_rx + .take() + .expect("cmd_rx must be present when spawning stats monitor"); + let handle = tokio::spawn(async move { const STATS_INTERVAL: Duration = Duration::from_secs(5); let mut interval = tokio::time::interval(STATS_INTERVAL); @@ -719,103 +731,109 @@ impl BitaxeBoard { interval.tick().await; loop { - interval.tick().await; - - // -- Read sensor values -- - - let asic_temp = fan_ctrl.get_external_temperature().await.ok(); - let fan_percent = fan_ctrl.get_fan_speed().await.ok().map(u8::from); - let fan_rpm = fan_ctrl.get_rpm().await.ok(); - - let (vin_mv, vout_mv, iout_ma, power_mw, vr_temp) = { - let mut reg = regulator.lock().await; - ( - reg.get_vin().await.ok(), - reg.get_vout().await.ok(), - reg.get_iout().await.ok(), - reg.get_power().await.ok(), - reg.get_temperature().await.ok(), - ) - }; - - if let Some(mv) = vout_mv { - let volts = mv as f32 / 1000.0; - if volts < 1.0 { - warn!("Core voltage low: {:.3}V", volts); - } - } + tokio::select! { + _ = interval.tick() => { + // -- Read sensor values -- + + let asic_temp = fan_ctrl.get_external_temperature().await.ok(); + let fan_percent = fan_ctrl.get_fan_speed().await.ok().map(u8::from); + let fan_rpm = fan_ctrl.get_rpm().await.ok(); + + let (vin_mv, vout_mv, iout_ma, power_mw, vr_temp) = { + let mut reg = regulator.lock().await; + ( + reg.get_vin().await.ok(), + reg.get_vout().await.ok(), + reg.get_iout().await.ok(), + reg.get_power().await.ok(), + reg.get_temperature().await.ok(), + ) + }; + + if let Some(mv) = vout_mv { + let volts = mv as f32 / 1000.0; + if volts < 1.0 { + warn!("Core voltage low: {:.3}V", volts); + } + } + + // Check power status -- critical faults will return error + { + let mut reg = regulator.lock().await; + if let Err(e) = reg.check_status().await { + error!("CRITICAL: Power controller fault detected: {}", e); - // Check power status -- critical faults will return error - { - let mut reg = regulator.lock().await; - if let Err(e) = reg.check_status().await { - error!("CRITICAL: Power controller fault detected: {}", e); + warn!("Attempting to clear power controller faults..."); + if let Err(clear_err) = reg.clear_faults().await { + error!("Failed to clear faults: {}", clear_err); + } - warn!("Attempting to clear power controller faults..."); - if let Err(clear_err) = reg.clear_faults().await { - error!("Failed to clear faults: {}", clear_err); + continue; + } } - continue; + // -- Publish BoardTelemetry -- + + let _ = telemetry_tx.send(BoardTelemetry { + name: board_name.clone(), + model: board_model.clone(), + serial: board_serial.clone(), + fans: vec![Fan { + name: "fan".into(), + rpm: fan_rpm, + percent: fan_percent, + target_percent: None, + }], + temperatures: vec![ + TemperatureSensor { + name: "asic".into(), + temperature_c: asic_temp, + }, + TemperatureSensor { + name: "vr".into(), + temperature_c: vr_temp.map(|t| t as f32), + }, + ], + powers: vec![ + PowerMeasurement { + name: "input".into(), + voltage_v: vin_mv.map(|mv| mv as f32 / 1000.0), + current_a: None, + power_w: None, + }, + PowerMeasurement { + name: "core".into(), + voltage_v: vout_mv.map(|mv| mv as f32 / 1000.0), + current_a: iout_ma.map(|ma| ma as f32 / 1000.0), + power_w: power_mw.map(|mw| mw as f32 / 1000.0), + }, + ], + threads: Vec::new(), + }); + + // -- Log summary (throttled) -- + + if last_log.elapsed() >= LOG_INTERVAL { + last_log = tokio::time::Instant::now(); + info!( + board = %board_model, + serial = ?board_serial, + asic_temp_c = ?asic_temp, + fan_percent = ?fan_percent, + fan_rpm = ?fan_rpm, + vr_temp_c = ?vr_temp, + power_w = ?power_mw.map(|mw| mw as f32 / 1000.0), + current_a = ?iout_ma.map(|ma| ma as f32 / 1000.0), + vin_v = ?vin_mv.map(|mv| mv as f32 / 1000.0), + vout_v = ?vout_mv.map(|mv| mv as f32 / 1000.0), + "Board status." + ); + } } - } - // -- Publish BoardTelemetry -- - - let _ = telemetry_tx.send(BoardTelemetry { - name: board_name.clone(), - model: board_model.clone(), - serial: board_serial.clone(), - fans: vec![Fan { - name: "fan".into(), - rpm: fan_rpm, - percent: fan_percent, - target_percent: None, - }], - temperatures: vec![ - TemperatureSensor { - name: "asic".into(), - temperature_c: asic_temp, - }, - TemperatureSensor { - name: "vr".into(), - temperature_c: vr_temp.map(|t| t as f32), - }, - ], - powers: vec![ - PowerMeasurement { - name: "input".into(), - voltage_v: vin_mv.map(|mv| mv as f32 / 1000.0), - current_a: None, - power_w: None, - }, - PowerMeasurement { - name: "core".into(), - voltage_v: vout_mv.map(|mv| mv as f32 / 1000.0), - current_a: iout_ma.map(|ma| ma as f32 / 1000.0), - power_w: power_mw.map(|mw| mw as f32 / 1000.0), - }, - ], - threads: Vec::new(), - }); - - // -- Log summary (throttled) -- - - if last_log.elapsed() >= LOG_INTERVAL { - last_log = tokio::time::Instant::now(); - info!( - board = %board_model, - serial = ?board_serial, - asic_temp_c = ?asic_temp, - fan_percent = ?fan_percent, - fan_rpm = ?fan_rpm, - vr_temp_c = ?vr_temp, - power_w = ?power_mw.map(|mw| mw as f32 / 1000.0), - current_a = ?iout_ma.map(|ma| ma as f32 / 1000.0), - vin_v = ?vin_mv.map(|mv| mv as f32 / 1000.0), - vout_v = ?vout_mv.map(|mv| mv as f32 / 1000.0), - "Board status." - ); + Some(cmd) = cmd_rx.recv() => { + handle_board_command(cmd, ®ulator).await; + } } } }); @@ -824,6 +842,26 @@ impl BitaxeBoard { } } +/// Handle a single board command received from the API layer. +async fn handle_board_command( + cmd: BoardCommand, + regulator: &Arc>>, +) { + match cmd { + BoardCommand::SetVoltage { domain, voltage_v, reply } => { + if domain != "core" { + let _ = reply.send(Err(anyhow::anyhow!("unknown power domain: {}", domain))); + return; + } + let result = regulator.lock().await.set_vout(voltage_v).await; + let _ = reply.send(result); + } + BoardCommand::SetFanTarget { reply, .. } => { + let _ = reply.send(Err(anyhow::anyhow!("fan target control not yet implemented"))); + } + } +} + #[async_trait] impl Board for BitaxeBoard { fn board_info(&self) -> BoardInfo { @@ -961,12 +999,16 @@ async fn create_from_usb( }; let (telemetry_tx, telemetry_rx) = watch::channel(initial_state); + // Create command channel for API-to-board control + let (cmd_tx, cmd_rx) = tokio::sync::mpsc::channel::(16); + // Create the board with the control port and data port path let mut board = BitaxeBoard::new( control_port, &serial_ports[1], device.serial_number.clone(), telemetry_tx, + cmd_rx, ) .context("failed to create board")?; @@ -981,7 +1023,10 @@ async fn create_from_usb( board.chip_count() ); - let registration = super::BoardRegistration { telemetry_rx }; + let registration = super::BoardRegistration { + telemetry_rx, + cmd_tx: Some(cmd_tx), + }; Ok((Box::new(board), registration)) } diff --git a/mujina-miner/src/board/cpu.rs b/mujina-miner/src/board/cpu.rs index 28e1a27a..126e55f3 100644 --- a/mujina-miner/src/board/cpu.rs +++ b/mujina-miner/src/board/cpu.rs @@ -95,7 +95,7 @@ async fn create_cpu_board() -> Result<(Box, super::BoardRegist let (telemetry_tx, telemetry_rx) = watch::channel(initial_state); let board = CpuBoard::new(config, telemetry_tx); - let registration = super::BoardRegistration { telemetry_rx }; + let registration = super::BoardRegistration { telemetry_rx, cmd_tx: None }; Ok((Box::new(board), registration)) } diff --git a/mujina-miner/src/board/emberone00.rs b/mujina-miner/src/board/emberone00.rs index 1ae9c69e..c17f8918 100644 --- a/mujina-miner/src/board/emberone00.rs +++ b/mujina-miner/src/board/emberone00.rs @@ -95,7 +95,7 @@ async fn create_from_usb( telemetry_tx, }; - let registration = super::BoardRegistration { telemetry_rx }; + let registration = super::BoardRegistration { telemetry_rx, cmd_tx: None }; Ok((Box::new(board), registration)) } diff --git a/mujina-miner/src/board/mod.rs b/mujina-miner/src/board/mod.rs index b84e27ad..729b8a07 100644 --- a/mujina-miner/src/board/mod.rs +++ b/mujina-miner/src/board/mod.rs @@ -6,10 +6,13 @@ pub mod pattern; use anyhow::Result; use async_trait::async_trait; use std::{future::Future, pin::Pin}; -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, }; /// Represents a mining board containing one or more ASIC chips. @@ -58,6 +61,10 @@ pub struct BoardInfo { pub struct BoardRegistration { /// Watch receiver for the board's telemetry. pub telemetry_rx: watch::Receiver, + /// Sender for dispatching commands to this board. + /// + /// `None` for boards that do not support runtime commands (e.g. CPU miner). + pub cmd_tx: Option>, } /// Helper type for async board factory functions