diff --git a/asic-rs-core/src/data/firmware.rs b/asic-rs-core/src/data/firmware.rs index 70d312cb..f5c9c644 100644 --- a/asic-rs-core/src/data/firmware.rs +++ b/asic-rs-core/src/data/firmware.rs @@ -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, + /// The latest firmware version offered by the vendor, if any. + pub latest_version: Option, + /// Where to obtain the available update, if any: a local image or a remote URL. + pub firmware: Option, +} + +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 { + self.current_version.as_ref().map(ToString::to_string) + } + + #[getter(latest_version)] + fn py_latest_version(&self) -> Option { + 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 { + 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, @@ -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 }, + 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 { + match self { + Self::Remote { url } => Some(url.clone()), + Self::Local { .. } => None, + } + } + + #[getter] + fn filename(&self) -> Option { + match self { + Self::Local { filename, .. } => Some(filename.clone()), + Self::Remote { .. } => None, + } + } + + #[getter] + fn data(&self) -> Option> { + 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 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 = + { ::TYPE_HINT }; + + fn into_pyobject(self, py: pyo3::Python<'py>) -> Result { + PyFirmwareUpdate::from(self) + .into_pyobject(py) + .map(pyo3::Bound::into_any) + } + } +} diff --git a/asic-rs-core/src/traits/miner.rs b/asic-rs-core/src/traits/miner.rs index 200aa177..3be14b1a 100644 --- a/asic-rs-core/src/traits/miner.rs +++ b/asic-rs-core/src/traits/miner.rs @@ -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}, @@ -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 { + anyhow::bail!("Checking for firmware updates is not supported on this platform"); + } + + fn supports_check_firmware_update(&self) -> bool { + false + } } // Config traits diff --git a/asic-rs-firmwares/braiins/src/backends/util.rs b/asic-rs-firmwares/braiins/src/backends/util.rs index 2b6ee616..a7c583e4 100644 --- a/asic-rs-firmwares/braiins/src/backends/util.rs +++ b/asic-rs-firmwares/braiins/src/backends/util.rs @@ -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 { + 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::>() + .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 { parse_tagged_tuning_target(value, "configured") } diff --git a/asic-rs-firmwares/braiins/src/backends/v26_04/mod.rs b/asic-rs-firmwares/braiins/src/backends/v26_04/mod.rs index b3eff350..b0e4e892 100644 --- a/asic-rs-firmwares/braiins/src/backends/v26_04/mod.rs +++ b/asic-rs-firmwares/braiins/src/backends/v26_04/mod.rs @@ -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, @@ -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, @@ -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 { + 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 { + 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 { diff --git a/asic-rs-firmwares/braiins/src/firmware.rs b/asic-rs-firmwares/braiins/src/firmware.rs index 0632bb3e..a43d4ade 100644 --- a/asic-rs-firmwares/braiins/src/firmware.rs +++ b/asic-rs-firmwares/braiins/src/firmware.rs @@ -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::>() - .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) } } diff --git a/python/pyasic_rs/asic_rs.pyi b/python/pyasic_rs/asic_rs.pyi index e9bfb588..655b0553 100644 --- a/python/pyasic_rs/asic_rs.pyi +++ b/python/pyasic_rs/asic_rs.pyi @@ -212,6 +212,27 @@ class FanMode: def __int__(self, /) -> int: ... def __repr__(self, /) -> str: ... +@final +class FirmwareStats: + @property + def current_version(self, /) -> str |None: ... + @property + def latest_version(self, /) -> str |None: ... + @property + def update_available(self, /) -> bool: ... + @property + def firmware(self, /) -> "FirmwareUpdate | None": ... + +class FirmwareUpdate: + @property + def variant(self, /) -> str: ... + @property + def url(self, /) -> str |None: ... + @property + def filename(self, /) -> str |None: ... + @property + def data(self, /) -> list[int] |None: ... + @final class HashAlgorithm: Blake2S256: Final[HashAlgorithm] @@ -339,6 +360,7 @@ class Miner: def factory_reset(self, /) -> Awaitable[bool |None]: ... @property def firmware(self, /) -> str: ... + def check_firmware_update(self, /) -> Awaitable[FirmwareStats |None]: ... def get_api_version(self, /) -> Awaitable[str |None]: ... def get_control_board_version(self, /) -> Awaitable[str |None]: ... def get_data(self, /, exclude: "list[DataField] | None" = None) -> Awaitable[MinerData]: ... @@ -409,6 +431,7 @@ class Miner: @property def supports_tuning_config(self, /) -> bool: ... @property + def supports_check_firmware_update(self, /) -> bool: ... def supports_upgrade_firmware(self, /) -> bool: ... def upgrade_firmware(self, /, path: StrOrBytesPath) -> Awaitable[bool]: ... diff --git a/src/python/miner.rs b/src/python/miner.rs index 15b40c75..c6897d64 100644 --- a/src/python/miner.rs +++ b/src/python/miner.rs @@ -11,7 +11,7 @@ use asic_rs_core::{ board::BoardData, device::{HashAlgorithm, MinerHardware}, fan::FanData, - firmware::FirmwareImage, + firmware::{FirmwareImage, FirmwareStats}, hashrate::HashRate, message::MinerMessage, miner::{MinerData, TuningTarget}, @@ -210,6 +210,11 @@ impl Miner { fn supports_upgrade_firmware(&self, py: Python<'_>) -> bool { self.with_miner(py, |miner| miner.supports_upgrade_firmware()) } + /// Whether this miner supports checking for an available firmware update. + #[getter] + fn supports_check_firmware_update(&self, py: Python<'_>) -> bool { + self.with_miner(py, |miner| miner.supports_check_firmware_update()) + } /// Whether this miner supports scaling configuration. #[getter] fn supports_scaling_config(&self, py: Python<'_>) -> bool { @@ -257,6 +262,18 @@ impl Miner { } }) } + /// Check for an available firmware update (on-demand; queries the vendor's + /// release server). Returns `None` if unsupported or the check fails. + pub fn check_firmware_update<'a>( + &self, + py: Python<'a>, + ) -> PyResult>> { + let inner = Arc::clone(&self.inner); + future_into_py(py, async move { + let inner = inner.read().await; + Ok(inner.check_firmware_update().await.ok()) + }) + } /// Await the miner MAC address, if exposed by the firmware. pub fn get_mac<'a>(&self, py: Python<'a>) -> PyResult>> { let inner = Arc::clone(&self.inner); diff --git a/src/python/mod.rs b/src/python/mod.rs index 20d69031..9b784c91 100644 --- a/src/python/mod.rs +++ b/src/python/mod.rs @@ -39,6 +39,7 @@ mod asic_rs { board::{BoardData, ChipData, MinerControlBoard}, device::{DeviceInfo, MinerHardware}, fan::FanData, + firmware::{FirmwareStats, PyFirmwareUpdate as FirmwareUpdate}, message::{MessageSeverity, MinerComponent, MinerMessage}, miner::{MinerData, PyTuningTarget as TuningTarget}, pool::{PoolData, PoolGroupData, PoolScheme, PoolURL},