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
169 changes: 169 additions & 0 deletions asic-rs-core/src/data/firmware.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,85 @@
use std::path::Path;

use anyhow::Context;
#[cfg(feature = "python")]
use pyo3::prelude::*;
use semver::Version;
use tokio::io::AsyncReadExt;

/// Result of checking a miner for an available firmware update.
///
/// Read-only and obtained on demand (a firmware-update check usually hits the
/// vendor's release server, so it is not part of the regular telemetry poll).
#[cfg_attr(
feature = "python",
pyclass(name = "FirmwareStats", skip_from_py_object, module = "asic_rs")
)]
#[derive(Debug, Clone, Default)]
pub struct FirmwareStats {
/// The firmware version currently installed, if known.
pub current_version: Option<Version>,
/// The latest firmware version offered by the vendor, if any.
pub latest_version: Option<Version>,
/// Where to obtain the available update, if any: a local image or a remote URL.
pub firmware: Option<FirmwareUpdate>,
}

impl FirmwareStats {
/// Whether a newer firmware than the installed one is available.
///
/// Derived by comparing `current_version` to `latest_version`; returns
/// `false` when either version is unknown.
pub fn update_available(&self) -> bool {
match (&self.current_version, &self.latest_version) {
(Some(current), Some(latest)) => latest > current,
_ => false,
}
}
}

#[cfg(feature = "python")]
#[pymethods]
impl FirmwareStats {
#[getter(current_version)]
fn py_current_version(&self) -> Option<String> {
self.current_version.as_ref().map(ToString::to_string)
}

#[getter(latest_version)]
fn py_latest_version(&self) -> Option<String> {
self.latest_version.as_ref().map(ToString::to_string)
}

#[getter(update_available)]
fn py_update_available(&self) -> bool {
self.update_available()
}

#[getter(firmware)]
fn py_firmware(&self) -> Option<FirmwareUpdate> {
self.firmware.clone()
}

fn __repr__(&self) -> String {
format!(
"FirmwareStats(current_version={:?}, latest_version={:?}, update_available={})",
self.current_version.as_ref().map(ToString::to_string),
self.latest_version.as_ref().map(ToString::to_string),
self.update_available(),
)
}
}

/// Where an available firmware update can be obtained: either a local image
/// (bytes already in hand) or a remote URL to download from.
#[derive(Debug, Clone)]
pub enum FirmwareUpdate {
/// A firmware image available locally (e.g. already downloaded).
Local(FirmwareImage),
/// A URL the firmware can be downloaded from.
Remote(String),
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FirmwareImage {
pub filename: String,
Expand Down Expand Up @@ -53,3 +130,95 @@ impl FirmwareImage {
.context("Firmware path must include a valid UTF-8 filename")
}
}

#[cfg(feature = "python")]
pub use python_firmware_update::PyFirmwareUpdate;

#[cfg(feature = "python")]
mod python_firmware_update {
use pyo3::prelude::*;

use super::FirmwareUpdate;

/// Python view of [`FirmwareUpdate`]: the source of an available update.
#[pyclass(name = "FirmwareUpdate", skip_from_py_object, module = "asic_rs")]
#[derive(Debug, Clone)]
pub enum PyFirmwareUpdate {
Local { filename: String, data: Vec<u8> },
Remote { url: String },
}

#[pymethods]
impl PyFirmwareUpdate {
#[getter]
fn variant(&self) -> &'static str {
match self {
Self::Local { .. } => "local",
Self::Remote { .. } => "remote",
}
}

#[getter]
fn url(&self) -> Option<String> {
match self {
Self::Remote { url } => Some(url.clone()),
Self::Local { .. } => None,
}
}

#[getter]
fn filename(&self) -> Option<String> {
match self {
Self::Local { filename, .. } => Some(filename.clone()),
Self::Remote { .. } => None,
}
}

#[getter]
fn data(&self) -> Option<Vec<u8>> {
match self {
Self::Local { data, .. } => Some(data.clone()),
Self::Remote { .. } => None,
}
}

fn __repr__(&self) -> String {
match self {
Self::Local { filename, data } => {
format!(
"FirmwareUpdate.Local(filename={filename:?}, {} bytes)",
data.len()
)
}
Self::Remote { url } => format!("FirmwareUpdate.Remote(url={url:?})"),
}
}
}

impl From<FirmwareUpdate> for PyFirmwareUpdate {
fn from(value: FirmwareUpdate) -> Self {
match value {
FirmwareUpdate::Local(image) => Self::Local {
filename: image.filename,
data: image.bytes,
},
FirmwareUpdate::Remote(url) => Self::Remote { url },
}
}
}

impl<'py> pyo3::IntoPyObject<'py> for FirmwareUpdate {
type Target = pyo3::PyAny;
type Output = pyo3::Bound<'py, pyo3::PyAny>;
type Error = pyo3::PyErr;

const OUTPUT_TYPE: pyo3::inspect::PyStaticExpr =
{ <PyFirmwareUpdate as pyo3::PyTypeInfo>::TYPE_HINT };

fn into_pyobject(self, py: pyo3::Python<'py>) -> Result<Self::Output, Self::Error> {
PyFirmwareUpdate::from(self)
.into_pyobject(py)
.map(pyo3::Bound::into_any)
}
}
}
15 changes: 14 additions & 1 deletion asic-rs-core/src/traits/miner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ use crate::{
command::MinerCommand,
device::DeviceInfo,
fan::FanData,
firmware::FirmwareImage,
firmware::{FirmwareImage, FirmwareStats},
hashrate::{HashRate, HashRateUnit},
message::MinerMessage,
miner::{MinerData, TuningTarget},
Expand Down Expand Up @@ -808,6 +808,19 @@ pub trait UpgradeFirmware {
fn supports_upgrade_firmware(&self) -> bool {
false
}

/// Check whether a newer firmware is available for this miner.
///
/// This is an on-demand call (it typically queries the vendor's release
/// server), not part of the regular telemetry poll. Defaults to
/// unsupported.
async fn check_firmware_update(&self) -> anyhow::Result<FirmwareStats> {
anyhow::bail!("Checking for firmware updates is not supported on this platform");
}

fn supports_check_firmware_update(&self) -> bool {
false
}
}

// Config traits
Expand Down
25 changes: 25 additions & 0 deletions asic-rs-firmwares/braiins/src/backends/util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,31 @@ use asic_rs_core::data::miner::TuningTarget;
use measurements::Power;
use serde_json::Value;

/// Parse a BOS version string (e.g. `bos.info.version.full`) into semver.
///
/// BOS versions are CalVer-ish (`26.04`, `2026-04-1`): take the last dotted
/// segment, strip leading zeros per component, and pad to `major.minor.patch`.
pub(crate) fn parse_bos_version(full: &str) -> Option<semver::Version> {
let version_str = full.split('-').rev().find(|s| s.contains('.'))?;
let normalized = version_str
.split('.')
.map(|part| part.trim_start_matches('0').to_string())
.map(|part| {
if part.is_empty() {
"0".to_string()
} else {
part
}
})
.collect::<Vec<_>>()
.join(".");
let padded = match version_str.split('.').count() {
2 => format!("{normalized}.0"),
_ => normalized,
};
semver::Version::parse(&padded).ok()
}

pub(crate) fn parse_configured_tuning_target(value: &Value) -> Option<TuningTarget> {
parse_tagged_tuning_target(value, "configured")
}
Expand Down
46 changes: 45 additions & 1 deletion asic-rs-firmwares/braiins/src/backends/v26_04/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ use asic_rs_core::{
command::MinerCommand,
device::{DeviceInfo, HashAlgorithm},
fan::FanData,
firmware::{FirmwareStats, FirmwareUpdate},
hashrate::{HashRate, HashRateUnit},
message::{MessageSeverity, MinerMessage},
miner::TuningTarget,
Expand All @@ -33,7 +34,7 @@ use web::BraiinsWebAPI;

use crate::{
backends::{
util::{parse_configured_tuning_target, parse_scaled_tuning_target},
util::{parse_bos_version, parse_configured_tuning_target, parse_scaled_tuning_target},
v21_09::graphql::BraiinsGraphQLAPI,
},
firmware::BraiinsFirmware,
Expand Down Expand Up @@ -831,6 +832,49 @@ impl UpgradeFirmware for BraiinsV2604 {
fn supports_upgrade_firmware(&self) -> bool {
false
}

fn supports_check_firmware_update(&self) -> bool {
true
}

/// Reads the installed version and asks BOS to check the vendor's release
/// server (`bos.checkForUpgrade`) via the authenticated GraphQL client.
async fn check_firmware_update(&self) -> anyhow::Result<FirmwareStats> {
const GQL_CHECK_UPGRADE: MinerCommand = MinerCommand::GraphQL {
command: r#"{
bos {
info { version { full } }
checkForUpgrade {
__typename
... on UpgradeDetail {
latestRelease {
version
url
}
}
}
}
}"#,
};
let data = self.graphql.get_api_result(&GQL_CHECK_UPGRADE).await?;

let s = |p: &str| -> Option<String> {
data.pointer(p).and_then(|v| v.as_str()).map(String::from)
};
let current_version = s("/bos/info/version/full")
.as_deref()
.and_then(parse_bos_version);
let latest_version = s("/bos/checkForUpgrade/latestRelease/version")
.as_deref()
.and_then(parse_bos_version);
let firmware = s("/bos/checkForUpgrade/latestRelease/url").map(FirmwareUpdate::Remote);

Ok(FirmwareStats {
current_version,
latest_version,
firmware,
})
}
}

impl HasDefaultAuth for BraiinsV2604 {
Expand Down
22 changes: 1 addition & 21 deletions asic-rs-firmwares/braiins/src/firmware.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,27 +116,7 @@ impl MinerFirmware for BraiinsFirmware {

let full = response["data"]["bos"]["info"]["version"]["full"].as_str()?;

let version_str = full.split('-').rev().find(|s| s.contains('.'))?;

let normalized = version_str
.split('.')
.map(|part| part.trim_start_matches('0').to_string())
.map(|part| {
if part.is_empty() {
"0".to_string()
} else {
part
}
})
.collect::<Vec<_>>()
.join(".");

// pad if needed, semver requires major.minor.patch
let padded = match version_str.split('.').count() {
2 => format!("{}.0", normalized),
_ => normalized.to_string(),
};
semver::Version::parse(&padded).ok()
crate::backends::util::parse_bos_version(full)
}
}

Expand Down
23 changes: 23 additions & 0 deletions python/pyasic_rs/asic_rs.pyi

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading