Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
190 changes: 190 additions & 0 deletions docs/voltage_change_api.md
Original file line number Diff line number Diff line change
@@ -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<mpsc::Sender<BoardCommand>>` 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<mpsc::Receiver<BoardCommand>>`.

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, &regulator).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.
9 changes: 9 additions & 0 deletions mujina-miner/src/api/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,13 @@ pub enum BoardCommand {
percent: Option<u8>,
reply: oneshot::Sender<Result<()>>,
},

/// 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<Result<()>>,
},
}
24 changes: 23 additions & 1 deletion mujina-miner/src/api/registry.rs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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<mpsc::Sender<BoardCommand>> {
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)]
Expand All @@ -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]
Expand Down
2 changes: 1 addition & 1 deletion mujina-miner/src/api/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
77 changes: 74 additions & 3 deletions mujina-miner/src/api/v0.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ use std::time::Duration;
use tokio::sync::oneshot;
use utoipa_axum::{router::OpenApiRouter, routes};

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

/// Build the v0 API routes with OpenAPI metadata.
Expand All @@ -25,7 +25,7 @@ pub fn routes() -> OpenApiRouter<SharedState> {
.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))
}
Expand Down Expand Up @@ -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<SharedState>,
Path(name): Path<String>,
Json(req): Json<BoardPatchRequest>,
) -> Result<Json<BoardTelemetry>, 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,
Expand Down
19 changes: 19 additions & 0 deletions mujina-miner/src/api_client/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,25 @@ pub struct SetFanTargetRequest {
pub target_percent: Option<u8>,
}

/// 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<f32>,
}

/// 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<Vec<PowerPatch>>,
}

/// Job source telemetry.
#[derive(Clone, Debug, Default, Deserialize, Serialize, ToSchema)]
pub struct SourceTelemetry {
Expand Down
Loading
Loading