diff --git a/asic-rs-core/src/traits/miner.rs b/asic-rs-core/src/traits/miner.rs index 70a81d4b..c619e981 100644 --- a/asic-rs-core/src/traits/miner.rs +++ b/asic-rs-core/src/traits/miner.rs @@ -87,6 +87,7 @@ pub trait HasMinerControl: + SetHashboardsEnabled + SetTuningPercent + Restart + + RestartMining + Resume + Pause + ChangePassword @@ -101,6 +102,7 @@ impl< + SetHashboardsEnabled + SetTuningPercent + Restart + + RestartMining + Resume + Pause + ChangePassword @@ -1056,6 +1058,23 @@ pub trait Restart { fn supports_restart(&self) -> bool; } +/// Restart the mining program without rebooting the operating system. +/// +/// This is distinct from [`Restart`], which reboots the whole device. A +/// successful request only confirms that the firmware accepted the restart; +/// hashing stops while the mining program comes back up. +#[async_trait] +pub trait RestartMining { + async fn restart_mining(&self) -> anyhow::Result { + anyhow::bail!("Restarting the mining program is not supported on this platform"); + } + /// Defaults to `false`; backends that can restart the mining program + /// without a reboot override this. + fn supports_restart_mining(&self) -> bool { + false + } +} + #[async_trait] pub trait Pause { #[allow(unused_variables)] diff --git a/asic-rs-firmwares/antminer/src/backends/v2018/mod.rs b/asic-rs-firmwares/antminer/src/backends/v2018/mod.rs index 021337dc..f5683d87 100644 --- a/asic-rs-firmwares/antminer/src/backends/v2018/mod.rs +++ b/asic-rs-firmwares/antminer/src/backends/v2018/mod.rs @@ -1225,6 +1225,8 @@ impl FactoryReset for AntMinerV2018 { } } +impl RestartMining for AntMinerV2018 {} + impl RestoreStockOs for AntMinerV2018 {} #[async_trait] diff --git a/asic-rs-firmwares/antminer/src/backends/v2020/mod.rs b/asic-rs-firmwares/antminer/src/backends/v2020/mod.rs index 2f5ef18f..1c3c5eb0 100644 --- a/asic-rs-firmwares/antminer/src/backends/v2020/mod.rs +++ b/asic-rs-firmwares/antminer/src/backends/v2020/mod.rs @@ -1258,6 +1258,8 @@ impl FactoryReset for AntMinerV2020 { } } +impl RestartMining for AntMinerV2020 {} + impl RestoreStockOs for AntMinerV2020 {} #[async_trait] diff --git a/asic-rs-firmwares/antminer/src/backends/v2023_07/mod.rs b/asic-rs-firmwares/antminer/src/backends/v2023_07/mod.rs index ec39024b..93f23c60 100644 --- a/asic-rs-firmwares/antminer/src/backends/v2023_07/mod.rs +++ b/asic-rs-firmwares/antminer/src/backends/v2023_07/mod.rs @@ -1372,6 +1372,8 @@ impl FactoryReset for AntMinerV202307 { } } +impl RestartMining for AntMinerV202307 {} + impl RestoreStockOs for AntMinerV202307 {} #[async_trait] diff --git a/asic-rs-firmwares/auradine/src/backends/v1/mod.rs b/asic-rs-firmwares/auradine/src/backends/v1/mod.rs index c2ec9df2..f7397814 100644 --- a/asic-rs-firmwares/auradine/src/backends/v1/mod.rs +++ b/asic-rs-firmwares/auradine/src/backends/v1/mod.rs @@ -1396,6 +1396,22 @@ impl FactoryReset for AuradineV1 { } } +#[async_trait] +impl RestartMining for AuradineV1 { + async fn restart_mining(&self) -> anyhow::Result { + // `restart` with no parameter reboots the miner; the `gcminer` + // parameter restarts only the mining program. + self.web + .send_command("restart", true, Some(json!("gcminer")), Method::POST) + .await?; + Ok(true) + } + + fn supports_restart_mining(&self) -> bool { + true + } +} + impl RestoreStockOs for AuradineV1 {} #[async_trait] @@ -1471,6 +1487,121 @@ mod tests { }, }; + struct MockJsonSequenceServer { + port: u16, + task: tokio::task::JoinHandle>>, + } + + /// Answer consecutive POST requests with the given JSON bodies and return + /// the JSON bodies that were received. + async fn mock_json_sequence_server( + responses: Vec, + ) -> anyhow::Result { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let port = listener.local_addr()?.port(); + let task = tokio::spawn(async move { + let mut requests = Vec::with_capacity(responses.len()); + for response in responses { + let (mut socket, _) = + tokio::time::timeout(Duration::from_secs(2), listener.accept()) + .await + .context("timed out waiting for HTTP request")??; + + let mut request = Vec::new(); + let mut chunk = [0_u8; 1024]; + let body = loop { + let text = String::from_utf8_lossy(&request).into_owned(); + if let Some((headers, body)) = text.split_once("\r\n\r\n") { + let content_length = headers + .lines() + .filter_map(|line| line.split_once(':')) + .find_map(|(name, value)| { + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().ok()) + .flatten() + }) + .unwrap_or_default(); + if body.len() >= content_length { + break body.to_string(); + } + } + let bytes_read = socket.read(&mut chunk).await?; + if bytes_read == 0 { + anyhow::bail!("connection closed before the request was complete"); + } + request.extend_from_slice(&chunk[..bytes_read]); + }; + requests.push(serde_json::from_str(&body)?); + + let body = response.to_string(); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + socket.write_all(response.as_bytes()).await?; + } + Ok(requests) + }); + + Ok(MockJsonSequenceServer { port, task }) + } + + fn token_response() -> Value { + json!({ + "STATUS": [{ "STATUS": "S", "Msg": "Token" }], + "Token": [{ "Token": "test-token" }] + }) + } + + fn miner_with_mock_web(port: u16) -> AuradineV1 { + let ip = IpAddr::from([127, 0, 0, 1]); + let mut miner = AuradineV1::new(ip, AuradineModel::AT1500); + miner.web = AuradineWebAPI::new_with_port(ip, port, AuradineV1::default_auth()); + miner + } + + #[tokio::test] + async fn restart_mining_restarts_gcminer_instead_of_rebooting() -> anyhow::Result<()> { + let MockJsonSequenceServer { port, task } = mock_json_sequence_server(vec![ + token_response(), + json!({ "STATUS": [{ "STATUS": "S", "Msg": "Restarting gcminer" }] }), + ]) + .await?; + let miner = miner_with_mock_web(port); + + assert!(miner.supports_restart_mining()); + assert!(miner.restart_mining().await?); + + let requests = task.await??; + assert_eq!(requests[0]["command"], "token"); + assert_eq!( + requests[1], + json!({ "command": "restart", "parameter": "gcminer" }) + ); + + Ok(()) + } + + #[tokio::test] + async fn restart_mining_surfaces_firmware_rejection() -> anyhow::Result<()> { + let MockJsonSequenceServer { port, task } = mock_json_sequence_server(vec![ + token_response(), + json!({ "STATUS": [{ "STATUS": "E", "Msg": "gcminer restart refused" }] }), + ]) + .await?; + let miner = miner_with_mock_web(port); + + let error = miner.restart_mining().await.unwrap_err(); + + task.await??; + assert!(error.to_string().contains("gcminer restart refused")); + + Ok(()) + } + #[tokio::test] async fn parse_data_test_auradine_at1500() -> anyhow::Result<()> { let miner = AuradineV1::new(IpAddr::from([127, 0, 0, 1]), AuradineModel::AT1500); diff --git a/asic-rs-firmwares/auradine/src/backends/v1/web.rs b/asic-rs-firmwares/auradine/src/backends/v1/web.rs index bd010350..ee60e029 100644 --- a/asic-rs-firmwares/auradine/src/backends/v1/web.rs +++ b/asic-rs-firmwares/auradine/src/backends/v1/web.rs @@ -37,6 +37,13 @@ impl AuradineWebAPI { } } + #[cfg(test)] + pub(super) fn new_with_port(ip: IpAddr, port: u16, auth: MinerAuth) -> Self { + let mut api = Self::new(ip, auth); + api.port = port; + api + } + pub fn set_auth(&mut self, auth: MinerAuth) { self.auth = auth; *self.token.get_mut() = None; diff --git a/asic-rs-firmwares/avalonminer/src/backends/avalon_a/mod.rs b/asic-rs-firmwares/avalonminer/src/backends/avalon_a/mod.rs index 49f8fe44..ddef4c48 100644 --- a/asic-rs-firmwares/avalonminer/src/backends/avalon_a/mod.rs +++ b/asic-rs-firmwares/avalonminer/src/backends/avalon_a/mod.rs @@ -185,6 +185,7 @@ impl FactoryReset for AvalonAMiner { } } +impl RestartMining for AvalonAMiner {} impl RestoreStockOs for AvalonAMiner {} #[async_trait] diff --git a/asic-rs-firmwares/avalonminer/src/backends/avalon_q/mod.rs b/asic-rs-firmwares/avalonminer/src/backends/avalon_q/mod.rs index 5dad5d28..51d3e7db 100644 --- a/asic-rs-firmwares/avalonminer/src/backends/avalon_q/mod.rs +++ b/asic-rs-firmwares/avalonminer/src/backends/avalon_q/mod.rs @@ -180,6 +180,7 @@ impl FactoryReset for AvalonQMiner { } } +impl RestartMining for AvalonQMiner {} impl RestoreStockOs for AvalonQMiner {} #[async_trait] diff --git a/asic-rs-firmwares/bitaxe/src/backends/v2_0_0/mod.rs b/asic-rs-firmwares/bitaxe/src/backends/v2_0_0/mod.rs index b42ecd22..580ad0d8 100644 --- a/asic-rs-firmwares/bitaxe/src/backends/v2_0_0/mod.rs +++ b/asic-rs-firmwares/bitaxe/src/backends/v2_0_0/mod.rs @@ -617,6 +617,7 @@ impl FactoryReset for Bitaxe200 { } } +impl RestartMining for Bitaxe200 {} impl RestoreStockOs for Bitaxe200 {} #[async_trait] diff --git a/asic-rs-firmwares/bitaxe/src/backends/v2_14_0/mod.rs b/asic-rs-firmwares/bitaxe/src/backends/v2_14_0/mod.rs index 5c4436f7..d09af7ea 100644 --- a/asic-rs-firmwares/bitaxe/src/backends/v2_14_0/mod.rs +++ b/asic-rs-firmwares/bitaxe/src/backends/v2_14_0/mod.rs @@ -621,6 +621,7 @@ impl FactoryReset for Bitaxe2140 { } } +impl RestartMining for Bitaxe2140 {} impl RestoreStockOs for Bitaxe2140 {} #[async_trait] diff --git a/asic-rs-firmwares/bitaxe/src/backends/v2_9_0/mod.rs b/asic-rs-firmwares/bitaxe/src/backends/v2_9_0/mod.rs index a6b2576f..d2d64225 100644 --- a/asic-rs-firmwares/bitaxe/src/backends/v2_9_0/mod.rs +++ b/asic-rs-firmwares/bitaxe/src/backends/v2_9_0/mod.rs @@ -611,6 +611,7 @@ impl FactoryReset for Bitaxe290 { } } +impl RestartMining for Bitaxe290 {} impl RestoreStockOs for Bitaxe290 {} #[async_trait] diff --git a/asic-rs-firmwares/braiins/src/backends/v21_09/graphql.rs b/asic-rs-firmwares/braiins/src/backends/v21_09/graphql.rs index 2da9c4cd..0efdc223 100644 --- a/asic-rs-firmwares/braiins/src/backends/v21_09/graphql.rs +++ b/asic-rs-firmwares/braiins/src/backends/v21_09/graphql.rs @@ -30,6 +30,13 @@ impl BraiinsGraphQLAPI { } } + #[cfg(test)] + pub(crate) fn new_with_port(ip: IpAddr, port: u16, auth: MinerAuth) -> Self { + let mut api = Self::new(ip, auth); + api.port = port; + api + } + pub fn set_auth(&mut self, auth: MinerAuth) { self.auth = auth; *self.session_id.get_mut() = None; @@ -208,6 +215,32 @@ impl BraiinsGraphQLAPI { && result.pointer("/bos/factoryReset").is_some()) } + /// Restart BOSminer without rebooting the operating system. + pub async fn restart_bosminer(&self) -> anyhow::Result { + let mutation = r#"mutation { + bosminer { + restart { + ... on BosminerError { + message + } + } + } + }"#; + + let result = self.send_graphql_command(mutation, true, None).await?; + + // There is only a message field if there is an error + if let Some(message) = result.pointer("/bosminer/restart/message") { + let message = message + .as_str() + .map(str::to_string) + .unwrap_or_else(|| message.to_string()); + anyhow::bail!("BOSminer restart request failed: {message}"); + } + + Ok(true) + } + pub async fn read_logs(&self) -> anyhow::Result { let log_query = r#"query ($target: LogTarget!) { bos { @@ -334,3 +367,101 @@ impl APIClient for BraiinsGraphQLAPI { } } } + +#[cfg(test)] +mod tests { + use asic_rs_makes_antminer::models::AntMinerModel; + + use super::*; + use crate::{ + backends::{v21_09::BraiinsV2109, v25_03::BraiinsV2503, v25_05::BraiinsV2505}, + test::http::{MockResponse, MockSequenceServer, mock_sequence_server, request_body}, + }; + + const LOGIN_OK: MockResponse = MockResponse { + status: "200 OK", + headers: &["Set-Cookie: session_id=test-session; Path=/"], + body: r#"{"data":{"auth":{"login":{"void":null}}}}"#, + }; + + fn localhost() -> IpAddr { + IpAddr::from([127, 0, 0, 1]) + } + + fn mock_graphql(port: u16) -> BraiinsGraphQLAPI { + BraiinsGraphQLAPI::new_with_port(localhost(), port, MinerAuth::new("root", "root")) + } + + #[tokio::test] + async fn restart_mining_sends_authenticated_bosminer_restart() -> anyhow::Result<()> { + let MockSequenceServer { port, task } = mock_sequence_server(vec![ + LOGIN_OK, + MockResponse::ok(r#"{"data":{"bosminer":{"restart":{}}}}"#), + ]) + .await?; + let mut miner = BraiinsV2109::new(localhost(), AntMinerModel::S9); + miner.graphql = mock_graphql(port); + + assert!(miner.supports_restart_mining()); + assert!(miner.restart_mining().await?); + + let requests = task.await??; + assert!(requests[1].starts_with("POST /graphql HTTP/1.1\r\n")); + assert!( + requests[1] + .lines() + .any(|line| line.eq_ignore_ascii_case("cookie: session_id=test-session")) + ); + let query = request_body(&requests[1])?["query"] + .as_str() + .unwrap_or_default() + .split_whitespace() + .collect::>() + .join(" "); + assert!(query.starts_with("mutation { bosminer { restart {")); + assert!(!query.contains("reboot")); + + Ok(()) + } + + #[tokio::test] + async fn restart_mining_surfaces_bosminer_error_message() -> anyhow::Result<()> { + let MockSequenceServer { port, task } = mock_sequence_server(vec![ + LOGIN_OK, + MockResponse::ok( + r#"{"data":{"bosminer":{"restart":{"message":"bosminer is not running"}}}}"#, + ), + ]) + .await?; + + let error = mock_graphql(port).restart_bosminer().await.unwrap_err(); + + task.await??; + assert!(error.to_string().contains("bosminer is not running")); + + Ok(()) + } + + #[tokio::test] + async fn restart_mining_surfaces_graphql_errors() -> anyhow::Result<()> { + let MockSequenceServer { port, task } = mock_sequence_server(vec![ + LOGIN_OK, + MockResponse::ok(r#"{"data":null,"errors":[{"message":"Unauthorized"}]}"#), + ]) + .await?; + + let error = mock_graphql(port).restart_bosminer().await.unwrap_err(); + + task.await??; + assert!(error.to_string().contains("Unauthorized")); + + Ok(()) + } + + #[test] + fn graphql_backends_support_restart_mining() { + assert!(BraiinsV2109::new(localhost(), AntMinerModel::S9).supports_restart_mining()); + assert!(BraiinsV2503::new(localhost(), AntMinerModel::S19XP).supports_restart_mining()); + assert!(BraiinsV2505::new(localhost(), AntMinerModel::S19XP).supports_restart_mining()); + } +} diff --git a/asic-rs-firmwares/braiins/src/backends/v21_09/mod.rs b/asic-rs-firmwares/braiins/src/backends/v21_09/mod.rs index 7c8c6f2c..f520e8ff 100644 --- a/asic-rs-firmwares/braiins/src/backends/v21_09/mod.rs +++ b/asic-rs-firmwares/braiins/src/backends/v21_09/mod.rs @@ -903,6 +903,17 @@ impl FactoryReset for BraiinsV2109 { } } +#[async_trait] +impl RestartMining for BraiinsV2109 { + async fn restart_mining(&self) -> anyhow::Result { + self.graphql.restart_bosminer().await + } + + fn supports_restart_mining(&self) -> bool { + true + } +} + impl RestoreStockOs for BraiinsV2109 {} #[async_trait] diff --git a/asic-rs-firmwares/braiins/src/backends/v25_03/mod.rs b/asic-rs-firmwares/braiins/src/backends/v25_03/mod.rs index d4feec63..40bbd54e 100644 --- a/asic-rs-firmwares/braiins/src/backends/v25_03/mod.rs +++ b/asic-rs-firmwares/braiins/src/backends/v25_03/mod.rs @@ -974,6 +974,17 @@ impl FactoryReset for BraiinsV2503 { } } +#[async_trait] +impl RestartMining for BraiinsV2503 { + async fn restart_mining(&self) -> anyhow::Result { + self.graphql.restart_bosminer().await + } + + fn supports_restart_mining(&self) -> bool { + true + } +} + impl RestoreStockOs for BraiinsV2503 {} #[async_trait] diff --git a/asic-rs-firmwares/braiins/src/backends/v25_05/mod.rs b/asic-rs-firmwares/braiins/src/backends/v25_05/mod.rs index 048e4c89..c3273e10 100644 --- a/asic-rs-firmwares/braiins/src/backends/v25_05/mod.rs +++ b/asic-rs-firmwares/braiins/src/backends/v25_05/mod.rs @@ -980,6 +980,17 @@ impl FactoryReset for BraiinsV2505 { } } +#[async_trait] +impl RestartMining for BraiinsV2505 { + async fn restart_mining(&self) -> anyhow::Result { + self.graphql.restart_bosminer().await + } + + fn supports_restart_mining(&self) -> bool { + true + } +} + impl RestoreStockOs for BraiinsV2505 {} #[async_trait] diff --git a/asic-rs-firmwares/braiins/src/backends/v25_07/mod.rs b/asic-rs-firmwares/braiins/src/backends/v25_07/mod.rs index ebd1450f..58e35701 100644 --- a/asic-rs-firmwares/braiins/src/backends/v25_07/mod.rs +++ b/asic-rs-firmwares/braiins/src/backends/v25_07/mod.rs @@ -988,6 +988,24 @@ impl FactoryReset for BraiinsV2507 { } } +#[async_trait] +impl RestartMining for BraiinsV2507 { + async fn restart_mining(&self) -> anyhow::Result { + // `actions/restart` restarts BOSminer only; `actions/reboot` reboots the OS. + self.web + .send_command("actions/restart", true, None, Method::PUT) + .await?; + Ok(true) + } + + fn supports_restart_mining(&self) -> bool { + true + } +} + +#[cfg(test)] +mod restart_mining_tests; + impl RestoreStockOs for BraiinsV2507 {} #[async_trait] diff --git a/asic-rs-firmwares/braiins/src/backends/v25_07/restart_mining_tests.rs b/asic-rs-firmwares/braiins/src/backends/v25_07/restart_mining_tests.rs new file mode 100644 index 00000000..3d8e322c --- /dev/null +++ b/asic-rs-firmwares/braiins/src/backends/v25_07/restart_mining_tests.rs @@ -0,0 +1,59 @@ +use std::net::IpAddr; + +use asic_rs_core::traits::miner::{MinerAuth, RestartMining}; +use asic_rs_makes_antminer::models::AntMinerModel; + +use super::{BraiinsV2507, web::BraiinsWebAPI}; +use crate::test::http::{MockResponse, MockSequenceServer, mock_sequence_server}; + +fn miner_with_mock_web(port: u16) -> BraiinsV2507 { + let ip = IpAddr::from([127, 0, 0, 1]); + let mut miner = BraiinsV2507::new(ip, AntMinerModel::S19XP); + miner.web = BraiinsWebAPI::new_with_port(ip, port, MinerAuth::new("root", "root")); + miner +} + +#[tokio::test] +async fn restart_mining_puts_restart_action_not_reboot() -> anyhow::Result<()> { + let MockSequenceServer { port, task } = mock_sequence_server(vec![ + MockResponse::ok(r#"{"token":"test-token"}"#), + MockResponse::ok("true"), + ]) + .await?; + let miner = miner_with_mock_web(port); + + assert!(miner.supports_restart_mining()); + assert!(miner.restart_mining().await?); + + let requests = task.await??; + assert!(requests[0].starts_with("POST /api/v1/auth/login HTTP/1.1\r\n")); + assert!(requests[1].starts_with("PUT /api/v1/actions/restart HTTP/1.1\r\n")); + assert!( + requests[1] + .lines() + .any(|line| line.eq_ignore_ascii_case("authorization: test-token")) + ); + + Ok(()) +} + +#[tokio::test] +async fn restart_mining_surfaces_firmware_rejection() -> anyhow::Result<()> { + let MockSequenceServer { port, task } = mock_sequence_server(vec![ + MockResponse::ok(r#"{"token":"test-token"}"#), + MockResponse { + status: "500 Internal Server Error", + headers: &[], + body: "bosminer is not running", + }, + ]) + .await?; + let miner = miner_with_mock_web(port); + + let error = miner.restart_mining().await.unwrap_err(); + + task.await??; + assert!(error.to_string().contains("bosminer is not running")); + + Ok(()) +} 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 4fed01af..e68a562f 100644 --- a/asic-rs-firmwares/braiins/src/backends/v26_04/mod.rs +++ b/asic-rs-firmwares/braiins/src/backends/v26_04/mod.rs @@ -1002,6 +1002,24 @@ impl FactoryReset for BraiinsV2604 { } } +#[async_trait] +impl RestartMining for BraiinsV2604 { + async fn restart_mining(&self) -> anyhow::Result { + // `actions/restart` restarts BOSminer only; `actions/reboot` reboots the OS. + self.web + .send_command("actions/restart", true, None, Method::PUT) + .await?; + Ok(true) + } + + fn supports_restart_mining(&self) -> bool { + true + } +} + +#[cfg(test)] +mod restart_mining_tests; + #[async_trait] impl RestoreStockOs for BraiinsV2604 { async fn restore_stock_os(&self) -> anyhow::Result { diff --git a/asic-rs-firmwares/braiins/src/backends/v26_04/restart_mining_tests.rs b/asic-rs-firmwares/braiins/src/backends/v26_04/restart_mining_tests.rs new file mode 100644 index 00000000..90233658 --- /dev/null +++ b/asic-rs-firmwares/braiins/src/backends/v26_04/restart_mining_tests.rs @@ -0,0 +1,52 @@ +use std::net::IpAddr; + +use asic_rs_core::traits::miner::{MinerAuth, RestartMining}; +use asic_rs_makes_antminer::models::AntMinerModel; + +use super::{BraiinsV2604, web::BraiinsWebAPI}; +use crate::test::http::{MockResponse, MockSequenceServer, mock_sequence_server}; + +fn miner_with_mock_web(port: u16) -> BraiinsV2604 { + let ip = IpAddr::from([127, 0, 0, 1]); + let mut miner = BraiinsV2604::new(ip, AntMinerModel::S21Pro); + miner.web = BraiinsWebAPI::new_with_port(ip, port, MinerAuth::from_token("test-token")); + miner +} + +#[tokio::test] +async fn restart_mining_puts_restart_action_not_reboot() -> anyhow::Result<()> { + let MockSequenceServer { port, task } = + mock_sequence_server(vec![MockResponse::ok("true")]).await?; + let miner = miner_with_mock_web(port); + + assert!(miner.supports_restart_mining()); + assert!(miner.restart_mining().await?); + + let requests = task.await??; + assert!(requests[0].starts_with("PUT /api/v1/actions/restart HTTP/1.1\r\n")); + assert!( + requests[0] + .lines() + .any(|line| line.eq_ignore_ascii_case("authorization: test-token")) + ); + + Ok(()) +} + +#[tokio::test] +async fn restart_mining_surfaces_firmware_rejection() -> anyhow::Result<()> { + let MockSequenceServer { port, task } = mock_sequence_server(vec![MockResponse { + status: "500 Internal Server Error", + headers: &[], + body: "bosminer is not running", + }]) + .await?; + let miner = miner_with_mock_web(port); + + let error = miner.restart_mining().await.unwrap_err(); + + task.await??; + assert!(error.to_string().contains("bosminer is not running")); + + Ok(()) +} diff --git a/asic-rs-firmwares/braiins/src/test/http.rs b/asic-rs-firmwares/braiins/src/test/http.rs new file mode 100644 index 00000000..13cda52f --- /dev/null +++ b/asic-rs-firmwares/braiins/src/test/http.rs @@ -0,0 +1,104 @@ +use anyhow::Context; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::{TcpListener, TcpStream}, +}; + +/// One canned HTTP response: status line, extra headers and body. +pub(crate) struct MockResponse { + pub(crate) status: &'static str, + pub(crate) headers: &'static [&'static str], + pub(crate) body: &'static str, +} + +impl MockResponse { + pub(crate) fn ok(body: &'static str) -> Self { + Self { + status: "200 OK", + headers: &[], + body, + } + } +} + +pub(crate) struct MockSequenceServer { + pub(crate) port: u16, + pub(crate) task: tokio::task::JoinHandle>>, +} + +async fn read_http_request(socket: &mut TcpStream) -> anyhow::Result { + let mut request = Vec::new(); + let mut chunk = [0_u8; 1024]; + + loop { + if let Some(header_end) = request + .windows(4) + .position(|window| window == b"\r\n\r\n") + .map(|position| position + 4) + { + let headers = std::str::from_utf8(&request[..header_end])?; + let content_length = headers + .lines() + .filter_map(|line| line.split_once(':')) + .find_map(|(name, value)| { + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().ok()) + .flatten() + }) + .unwrap_or_default(); + if request.len() >= header_end + content_length { + break; + } + } + + let bytes_read = socket.read(&mut chunk).await?; + if bytes_read == 0 { + break; + } + request.extend_from_slice(&chunk[..bytes_read]); + } + + Ok(String::from_utf8(request)?) +} + +/// Serve the given responses to consecutive connections and return the +/// requests that were received. +pub(crate) async fn mock_sequence_server( + responses: Vec, +) -> anyhow::Result { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let port = listener.local_addr()?.port(); + let task = tokio::spawn(async move { + let mut requests = Vec::with_capacity(responses.len()); + for response in responses { + let (mut socket, _) = + tokio::time::timeout(std::time::Duration::from_secs(2), listener.accept()) + .await + .context("timed out waiting for HTTP request")??; + requests.push(read_http_request(&mut socket).await?); + + let mut head = format!( + "HTTP/1.1 {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n", + response.status, + response.body.len() + ); + for header in response.headers { + head.push_str(header); + head.push_str("\r\n"); + } + socket + .write_all(format!("{head}\r\n{}", response.body).as_bytes()) + .await?; + } + Ok(requests) + }); + + Ok(MockSequenceServer { port, task }) +} + +pub(crate) fn request_body(request: &str) -> anyhow::Result { + let (_, body) = request + .split_once("\r\n\r\n") + .context("HTTP request did not contain a body separator")?; + Ok(serde_json::from_str(body)?) +} diff --git a/asic-rs-firmwares/braiins/src/test/mod.rs b/asic-rs-firmwares/braiins/src/test/mod.rs index 1ebac8d4..7abdfc86 100644 --- a/asic-rs-firmwares/braiins/src/test/mod.rs +++ b/asic-rs-firmwares/braiins/src/test/mod.rs @@ -1 +1,3 @@ +#[cfg(test)] +pub(crate) mod http; pub(crate) mod json; diff --git a/asic-rs-firmwares/elphapex/src/backends/v1/mod.rs b/asic-rs-firmwares/elphapex/src/backends/v1/mod.rs index a62db91f..58843c7b 100644 --- a/asic-rs-firmwares/elphapex/src/backends/v1/mod.rs +++ b/asic-rs-firmwares/elphapex/src/backends/v1/mod.rs @@ -908,6 +908,7 @@ impl FactoryReset for ElphapexV1 { } } +impl RestartMining for ElphapexV1 {} impl RestoreStockOs for ElphapexV1 {} #[async_trait] diff --git a/asic-rs-firmwares/epic/src/backends/v1/mod.rs b/asic-rs-firmwares/epic/src/backends/v1/mod.rs index 0b73d9f3..6392bb2a 100644 --- a/asic-rs-firmwares/epic/src/backends/v1/mod.rs +++ b/asic-rs-firmwares/epic/src/backends/v1/mod.rs @@ -1986,6 +1986,18 @@ impl FactoryReset for PowerPlayV1 { } } +#[async_trait] +impl RestartMining for PowerPlayV1 { + async fn restart_mining(&self) -> anyhow::Result { + self.web.soft_reboot().await?; + Ok(true) + } + + fn supports_restart_mining(&self) -> bool { + true + } +} + #[async_trait] impl RestoreStockOs for PowerPlayV1 { async fn restore_stock_os(&self) -> anyhow::Result { @@ -2266,6 +2278,43 @@ mod tests { Ok(()) } + #[tokio::test] + async fn restart_mining_posts_authenticated_soft_reboot() -> anyhow::Result<()> { + let MockJsonServer { port, task } = + mock_json_server(json!({ "result": true, "error": null })).await?; + let mut miner = + PowerPlayV1::new_with_port(IpAddr::from([127, 0, 0, 1]), AntMinerModel::S19XP, port); + miner.set_auth(MinerAuth::new("", "test-password")); + + assert!(miner.supports_restart_mining()); + assert!(miner.restart_mining().await?); + + let request = task.await??; + assert!(request.starts_with("POST /softreboot HTTP/1.1\r\n")); + assert_eq!( + json_request_body(&request)?, + json!({ "param": null, "password": "test-password" }) + ); + + Ok(()) + } + + #[tokio::test] + async fn restart_mining_surfaces_soft_reboot_errors() -> anyhow::Result<()> { + let MockJsonServer { port, task } = + mock_json_server(json!({ "result": false, "error": "Wrong password" })).await?; + let miner = + PowerPlayV1::new_with_port(IpAddr::from([127, 0, 0, 1]), AntMinerModel::S19XP, port); + + let error = miner.restart_mining().await.unwrap_err(); + + let request = task.await??; + assert!(request.starts_with("POST /softreboot HTTP/1.1\r\n")); + assert!(error.to_string().contains("Wrong password")); + + Ok(()) + } + #[tokio::test] async fn restore_stock_os_refuses_unadvertised_endpoint() { let miner = diff --git a/asic-rs-firmwares/epic/src/backends/v1/web.rs b/asic-rs-firmwares/epic/src/backends/v1/web.rs index 87ea1978..196cc353 100644 --- a/asic-rs-firmwares/epic/src/backends/v1/web.rs +++ b/asic-rs-firmwares/epic/src/backends/v1/web.rs @@ -211,6 +211,34 @@ impl PowerPlayWebAPI { } } + /// Restart the mining program without a full hardware power cycle. + /// + /// UMC calls this a soft reboot: the operating system keeps running. A + /// successful response only confirms that the request was accepted. + pub async fn soft_reboot(&self) -> anyhow::Result<()> { + let response = self + .send_command( + "softreboot", + true, + Some(json!({ "param": null })), + Method::POST, + ) + .await?; + + match response.get("result").and_then(Value::as_bool) { + Some(true) => Ok(()), + Some(false) => { + let error = match response.get("error") { + Some(Value::String(error)) => error.clone(), + Some(error) if !error.is_null() => error.to_string(), + _ => "unknown error".to_string(), + }; + bail!("UMC soft reboot request failed: {error}") + } + None => bail!("Invalid UMC soft reboot response: missing boolean result"), + } + } + pub async fn change_password(&self, password: &str) -> anyhow::Result { self.send_command( "password", diff --git a/asic-rs-firmwares/forgeaxe/src/backends/v1_1_0/mod.rs b/asic-rs-firmwares/forgeaxe/src/backends/v1_1_0/mod.rs index 3bd54c20..bee551b1 100644 --- a/asic-rs-firmwares/forgeaxe/src/backends/v1_1_0/mod.rs +++ b/asic-rs-firmwares/forgeaxe/src/backends/v1_1_0/mod.rs @@ -618,6 +618,8 @@ impl FactoryReset for Forgeaxe110 { } } +impl RestartMining for Forgeaxe110 {} + impl RestoreStockOs for Forgeaxe110 {} #[async_trait] diff --git a/asic-rs-firmwares/futurebit/src/backends/v2/mod.rs b/asic-rs-firmwares/futurebit/src/backends/v2/mod.rs index c119768b..c15ad997 100644 --- a/asic-rs-firmwares/futurebit/src/backends/v2/mod.rs +++ b/asic-rs-firmwares/futurebit/src/backends/v2/mod.rs @@ -811,6 +811,7 @@ impl FactoryReset for ApolloV2 { } } +impl RestartMining for ApolloV2 {} impl RestoreStockOs for ApolloV2 {} #[async_trait] diff --git a/asic-rs-firmwares/goldshell/src/backends/v1/mod.rs b/asic-rs-firmwares/goldshell/src/backends/v1/mod.rs index fa6d5131..3b70adf1 100644 --- a/asic-rs-firmwares/goldshell/src/backends/v1/mod.rs +++ b/asic-rs-firmwares/goldshell/src/backends/v1/mod.rs @@ -495,6 +495,7 @@ impl UpgradeFirmware for GoldshellV1 { false } } +impl RestartMining for GoldshellV1 {} impl RestoreStockOs for GoldshellV1 {} impl SetHashboardsEnabled for GoldshellV1 {} impl SetTuningPercent for GoldshellV1 {} diff --git a/asic-rs-firmwares/luxminer/src/backends/v1/mod.rs b/asic-rs-firmwares/luxminer/src/backends/v1/mod.rs index c011629d..0e80cf82 100644 --- a/asic-rs-firmwares/luxminer/src/backends/v1/mod.rs +++ b/asic-rs-firmwares/luxminer/src/backends/v1/mod.rs @@ -1203,6 +1203,21 @@ impl FactoryReset for LuxMinerV1 { } } +#[async_trait] +impl RestartMining for LuxMinerV1 { + async fn restart_mining(&self) -> anyhow::Result { + self.rpc.reset_miner().await?; + Ok(true) + } + + fn supports_restart_mining(&self) -> bool { + true + } +} + +#[cfg(test)] +mod restart_mining_tests; + #[async_trait] impl RestoreStockOs for LuxMinerV1 { async fn restore_stock_os(&self) -> anyhow::Result { diff --git a/asic-rs-firmwares/luxminer/src/backends/v1/restart_mining_tests.rs b/asic-rs-firmwares/luxminer/src/backends/v1/restart_mining_tests.rs new file mode 100644 index 00000000..6bcc6334 --- /dev/null +++ b/asic-rs-firmwares/luxminer/src/backends/v1/restart_mining_tests.rs @@ -0,0 +1,104 @@ +use std::net::IpAddr; + +use asic_rs_core::traits::miner::RestartMining; +use asic_rs_makes_antminer::models::AntMinerModel; +use serde_json::{Value, json}; +use tokio::{ + io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, + net::TcpListener, +}; + +use super::{LuxMinerV1, rpc::LUXMinerRPCAPI}; + +struct MockRpcServer { + port: u16, + task: tokio::task::JoinHandle>>, +} + +/// Answer `session`, then `resetminer` with the given status and message, +/// and return the requests that were received. +async fn mock_reset_server( + status: &'static str, + message: &'static str, +) -> anyhow::Result { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let port = listener.local_addr()?.port(); + + let task = tokio::spawn(async move { + let mut requests = Vec::new(); + for request_idx in 0..2 { + let (socket, _) = + tokio::time::timeout(std::time::Duration::from_secs(2), listener.accept()) + .await + .map_err(|_| { + anyhow::anyhow!("timed out waiting for request {}", request_idx + 1) + })??; + let (reader, mut writer) = socket.into_split(); + let mut reader = BufReader::new(reader); + let mut line = String::new(); + reader.read_line(&mut line).await?; + + let request: Value = serde_json::from_str(line.trim_end())?; + let response = match request.get("command").and_then(Value::as_str) { + Some("session") => json!({ + "SESSION": [{ "SessionID": "test-session-id" }], + "STATUS": [{ "STATUS": "S", "Msg": "Session found" }] + }), + Some("resetminer") => json!({ + "STATUS": [{ "STATUS": status, "Msg": message }] + }), + other => anyhow::bail!("unexpected command: {other:?}"), + }; + requests.push(request); + + writer.write_all(format!("{response}\n").as_bytes()).await?; + } + + Ok(requests) + }); + + Ok(MockRpcServer { port, task }) +} + +fn miner_with_mock_rpc(port: u16) -> LuxMinerV1 { + let mut miner = LuxMinerV1::new(IpAddr::from([127, 0, 0, 1]), AntMinerModel::S19KPro); + miner.rpc = LUXMinerRPCAPI::new_with_port(miner.ip, port); + miner +} + +#[tokio::test] +async fn restart_mining_sends_authenticated_resetminer_command() -> anyhow::Result<()> { + let MockRpcServer { port, task } = mock_reset_server("S", "Miner reset").await?; + let miner = miner_with_mock_rpc(port); + + assert!(miner.supports_restart_mining()); + assert!(miner.restart_mining().await?); + + assert_eq!( + task.await??, + vec![ + json!({ "command": "session" }), + json!({ + "command": "resetminer", + "parameter": "test-session-id" + }) + ] + ); + + Ok(()) +} + +#[tokio::test] +async fn restart_mining_returns_rpc_rejection() -> anyhow::Result<()> { + let MockRpcServer { port, task } = mock_reset_server("E", "Reset denied").await?; + + let error = miner_with_mock_rpc(port) + .restart_mining() + .await + .unwrap_err(); + + task.await??; + assert!(error.to_string().contains("Reset denied")); + + Ok(()) +} diff --git a/asic-rs-firmwares/marathon/src/backends/v1/mod.rs b/asic-rs-firmwares/marathon/src/backends/v1/mod.rs index 83b2e7c2..4ad0be41 100644 --- a/asic-rs-firmwares/marathon/src/backends/v1/mod.rs +++ b/asic-rs-firmwares/marathon/src/backends/v1/mod.rs @@ -1147,6 +1147,7 @@ impl FactoryReset for MaraV1 { } } +impl RestartMining for MaraV1 {} impl RestoreStockOs for MaraV1 {} #[async_trait] diff --git a/asic-rs-firmwares/nerdaxe/src/backends/v1/mod.rs b/asic-rs-firmwares/nerdaxe/src/backends/v1/mod.rs index 22929007..1e61cadb 100644 --- a/asic-rs-firmwares/nerdaxe/src/backends/v1/mod.rs +++ b/asic-rs-firmwares/nerdaxe/src/backends/v1/mod.rs @@ -582,6 +582,7 @@ impl FactoryReset for NerdAxeV1 { } } +impl RestartMining for NerdAxeV1 {} impl RestoreStockOs for NerdAxeV1 {} #[async_trait] diff --git a/asic-rs-firmwares/proto/src/backends/v1/mod.rs b/asic-rs-firmwares/proto/src/backends/v1/mod.rs index 46ebd71e..05b380bb 100644 --- a/asic-rs-firmwares/proto/src/backends/v1/mod.rs +++ b/asic-rs-firmwares/proto/src/backends/v1/mod.rs @@ -868,6 +868,7 @@ impl FactoryReset for ProtoV1 { } } +impl RestartMining for ProtoV1 {} impl RestoreStockOs for ProtoV1 {} #[async_trait] diff --git a/asic-rs-firmwares/sealminer/src/backends/v2025/mod.rs b/asic-rs-firmwares/sealminer/src/backends/v2025/mod.rs index 3e1078e9..83111534 100644 --- a/asic-rs-firmwares/sealminer/src/backends/v2025/mod.rs +++ b/asic-rs-firmwares/sealminer/src/backends/v2025/mod.rs @@ -884,6 +884,7 @@ impl FactoryReset for SealMinerV2025 { } } +impl RestartMining for SealMinerV2025 {} impl RestoreStockOs for SealMinerV2025 {} #[async_trait] diff --git a/asic-rs-firmwares/vnish/src/backends/v1_2_0/mod.rs b/asic-rs-firmwares/vnish/src/backends/v1_2_0/mod.rs index db2ef411..7b0df266 100644 --- a/asic-rs-firmwares/vnish/src/backends/v1_2_0/mod.rs +++ b/asic-rs-firmwares/vnish/src/backends/v1_2_0/mod.rs @@ -1199,6 +1199,18 @@ impl FactoryReset for VnishV120 { } } +#[async_trait] +impl RestartMining for VnishV120 { + async fn restart_mining(&self) -> anyhow::Result { + self.web.restart_mining().await?; + Ok(true) + } + + fn supports_restart_mining(&self) -> bool { + true + } +} + #[async_trait] impl RestoreStockOs for VnishV120 { async fn restore_stock_os(&self) -> anyhow::Result { @@ -1383,6 +1395,43 @@ mod tests { Ok(()) } + #[tokio::test] + async fn restart_mining_accepts_bodyless_mining_restart_response() -> anyhow::Result<()> { + let MockHttpServer { port, task } = mock_http_server(200, "OK", "").await?; + let miner = miner_with_mock_web(port); + + assert!(miner.supports_restart_mining()); + assert!(miner.restart_mining().await?); + + let request = task.await??; + assert!(request.starts_with("POST /api/v1/mining/restart HTTP/1.1\r\n")); + assert!( + request + .lines() + .any(|line| { line.eq_ignore_ascii_case("authorization: Bearer test-token") }) + ); + + Ok(()) + } + + #[tokio::test] + async fn restart_mining_surfaces_firmware_rejection() -> anyhow::Result<()> { + for (status, reason, body) in [ + (500, "Internal Server Error", r#"{"err":"miner is busy"}"#), + (200, "OK", r#"{"success":false,"err":"miner is busy"}"#), + ] { + let MockHttpServer { port, task } = mock_http_server(status, reason, body).await?; + let miner = miner_with_mock_web(port); + + let error = miner.restart_mining().await.unwrap_err(); + + task.await??; + assert!(error.to_string().contains("miner is busy")); + } + + Ok(()) + } + #[tokio::test] async fn restore_stock_os_surfaces_incompatible_model() -> anyhow::Result<()> { let MockHttpServer { port, task } = diff --git a/asic-rs-firmwares/vnish/src/backends/v1_2_0/web.rs b/asic-rs-firmwares/vnish/src/backends/v1_2_0/web.rs index 07591812..663db805 100644 --- a/asic-rs-firmwares/vnish/src/backends/v1_2_0/web.rs +++ b/asic-rs-firmwares/vnish/src/backends/v1_2_0/web.rs @@ -264,6 +264,43 @@ impl VnishWebAPI { .await } + /// Restart the mining program without rebooting the miner. + /// + /// The request is sent directly rather than through `send_command` so an + /// empty success body is accepted and a rejection keeps the firmware's text. + pub async fn restart_mining(&self) -> anyhow::Result<()> { + self.ensure_authenticated().await?; + + let url = format!("http://{}:{}/api/v1/mining/restart", self.ip, self.port); + let mut response = self.execute_request(&url, &Method::POST, None).await?; + + if response.status().as_u16() == 401 { + *self.bearer_token.write().await = None; + self.ensure_authenticated().await?; + response = self.execute_request(&url, &Method::POST, None).await?; + } + + let status = response.status(); + let body = response + .text() + .await + .map_err(|e| VnishError::ParseError(e.to_string()))?; + + if !status.is_success() { + anyhow::bail!("VNish mining restart request failed with status code {status}: {body}"); + } + + // The documented 200 response has no body; older builds answer with a + // JSON object carrying a `success` flag. + if let Ok(json) = serde_json::from_str::(&body) + && json.get("success").and_then(Value::as_bool) == Some(false) + { + anyhow::bail!("VNish mining restart request failed: {body}"); + } + + Ok(()) + } + pub async fn change_password(&self, password: &str) -> anyhow::Result { let settings = json!({ "password": { diff --git a/asic-rs-firmwares/vnish/src/backends/v1_3_0/mod.rs b/asic-rs-firmwares/vnish/src/backends/v1_3_0/mod.rs index 8a38d456..2f593205 100644 --- a/asic-rs-firmwares/vnish/src/backends/v1_3_0/mod.rs +++ b/asic-rs-firmwares/vnish/src/backends/v1_3_0/mod.rs @@ -1166,6 +1166,18 @@ impl FactoryReset for VnishV130 { } } +#[async_trait] +impl RestartMining for VnishV130 { + async fn restart_mining(&self) -> anyhow::Result { + self.web.restart_mining().await?; + Ok(true) + } + + fn supports_restart_mining(&self) -> bool { + true + } +} + #[async_trait] impl RestoreStockOs for VnishV130 { async fn restore_stock_os(&self) -> anyhow::Result { @@ -1412,6 +1424,43 @@ mod tests { Ok(()) } + #[tokio::test] + async fn restart_mining_accepts_bodyless_mining_restart_response() -> anyhow::Result<()> { + let MockHttpServer { port, task } = mock_http_server(200, "OK", "").await?; + let miner = miner_with_mock_web(port); + + assert!(miner.supports_restart_mining()); + assert!(miner.restart_mining().await?); + + let request = task.await??; + assert!(request.starts_with("POST /api/v1/mining/restart HTTP/1.1\r\n")); + assert!( + request + .lines() + .any(|line| { line.eq_ignore_ascii_case("authorization: Bearer test-token") }) + ); + + Ok(()) + } + + #[tokio::test] + async fn restart_mining_surfaces_firmware_rejection() -> anyhow::Result<()> { + for (status, reason, body) in [ + (500, "Internal Server Error", r#"{"err":"miner is busy"}"#), + (200, "OK", r#"{"success":false,"err":"miner is busy"}"#), + ] { + let MockHttpServer { port, task } = mock_http_server(status, reason, body).await?; + let miner = miner_with_mock_web(port); + + let error = miner.restart_mining().await.unwrap_err(); + + task.await??; + assert!(error.to_string().contains("miner is busy")); + } + + Ok(()) + } + #[tokio::test] async fn restore_stock_os_surfaces_incompatible_model() -> anyhow::Result<()> { let MockHttpServer { port, task } = diff --git a/asic-rs-firmwares/vnish/src/backends/v1_3_0/web.rs b/asic-rs-firmwares/vnish/src/backends/v1_3_0/web.rs index 72e808ca..882a75f0 100644 --- a/asic-rs-firmwares/vnish/src/backends/v1_3_0/web.rs +++ b/asic-rs-firmwares/vnish/src/backends/v1_3_0/web.rs @@ -279,6 +279,43 @@ impl VnishWebAPI { .await } + /// Restart the mining program without rebooting the miner. + /// + /// The request is sent directly rather than through `send_command` so an + /// empty success body is accepted and a rejection keeps the firmware's text. + pub async fn restart_mining(&self) -> anyhow::Result<()> { + self.ensure_authenticated().await?; + + let url = format!("http://{}:{}/api/v1/mining/restart", self.ip, self.port); + let mut response = self.execute_request(&url, &Method::POST, None).await?; + + if response.status().as_u16() == 401 { + *self.bearer_token.write().await = None; + self.ensure_authenticated().await?; + response = self.execute_request(&url, &Method::POST, None).await?; + } + + let status = response.status(); + let body = response + .text() + .await + .map_err(|e| VnishError::ParseError(e.to_string()))?; + + if !status.is_success() { + anyhow::bail!("VNish mining restart request failed with status code {status}: {body}"); + } + + // The documented 200 response has no body; older builds answer with a + // JSON object carrying a `success` flag. + if let Ok(json) = serde_json::from_str::(&body) + && json.get("success").and_then(Value::as_bool) == Some(false) + { + anyhow::bail!("VNish mining restart request failed: {body}"); + } + + Ok(()) + } + pub async fn change_password(&self, password: &str) -> anyhow::Result { let settings = json!({ "password": { diff --git a/asic-rs-firmwares/volcminer/src/backends/v1/mod.rs b/asic-rs-firmwares/volcminer/src/backends/v1/mod.rs index 7b370075..64f18fda 100644 --- a/asic-rs-firmwares/volcminer/src/backends/v1/mod.rs +++ b/asic-rs-firmwares/volcminer/src/backends/v1/mod.rs @@ -834,6 +834,7 @@ impl FactoryReset for VolcMinerV1 { } } +impl RestartMining for VolcMinerV1 {} impl RestoreStockOs for VolcMinerV1 {} #[async_trait] diff --git a/asic-rs-firmwares/whatsminer/src/backends/v1/mod.rs b/asic-rs-firmwares/whatsminer/src/backends/v1/mod.rs index 52f6f91b..c9d3e42e 100644 --- a/asic-rs-firmwares/whatsminer/src/backends/v1/mod.rs +++ b/asic-rs-firmwares/whatsminer/src/backends/v1/mod.rs @@ -620,6 +620,7 @@ impl FactoryReset for WhatsMinerV1 { } } +impl RestartMining for WhatsMinerV1 {} impl RestoreStockOs for WhatsMinerV1 {} #[async_trait] diff --git a/asic-rs-firmwares/whatsminer/src/backends/v2/mod.rs b/asic-rs-firmwares/whatsminer/src/backends/v2/mod.rs index d6875a4f..0f164a92 100644 --- a/asic-rs-firmwares/whatsminer/src/backends/v2/mod.rs +++ b/asic-rs-firmwares/whatsminer/src/backends/v2/mod.rs @@ -762,6 +762,7 @@ impl FactoryReset for WhatsMinerV2 { } } +impl RestartMining for WhatsMinerV2 {} impl RestoreStockOs for WhatsMinerV2 {} #[async_trait] diff --git a/asic-rs-firmwares/whatsminer/src/backends/v3/mod.rs b/asic-rs-firmwares/whatsminer/src/backends/v3/mod.rs index 59631cd7..df9ade54 100644 --- a/asic-rs-firmwares/whatsminer/src/backends/v3/mod.rs +++ b/asic-rs-firmwares/whatsminer/src/backends/v3/mod.rs @@ -789,6 +789,7 @@ impl FactoryReset for WhatsMinerV3 { } } +impl RestartMining for WhatsMinerV3 {} impl RestoreStockOs for WhatsMinerV3 {} #[async_trait] diff --git a/c/asic-rs-ffi/src/miner.rs b/c/asic-rs-ffi/src/miner.rs index f6395956..4a2ceb16 100644 --- a/c/asic-rs-ffi/src/miner.rs +++ b/c/asic-rs-ffi/src/miner.rs @@ -248,6 +248,7 @@ pub unsafe extern "C" fn asic_rs_miner_supports_json(miner: *const AsicMiner) -> "set_tuning_percent": m.supports_set_tuning_percent(), "presets": m.supports_presets(), "restart": m.supports_restart(), + "restart_mining": m.supports_restart_mining(), "pause": m.supports_pause(), "resume": m.supports_resume(), "change_password": m.supports_change_password(), @@ -1063,6 +1064,17 @@ pub unsafe extern "C" fn asic_rs_miner_restart(miner: *const AsicMiner) -> i32 { }) } +/// Restart the mining program without rebooting the miner. Returns 1/0/-1. +/// +/// # Safety +/// `miner` must be a live handle. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn asic_rs_miner_restart_mining(miner: *const AsicMiner) -> i32 { + ffi_guard(-1, || { + miner_control(miner, |m| result_bool(block_on(m.restart_mining())?)) + }) +} + /// Pause mining. `at_time_secs` < 0 means immediately. Returns 1/0/-1. /// /// # Safety @@ -1248,6 +1260,21 @@ mod tests { } } + #[test] + fn unsupported_restart_mining_reports_an_error() { + let miner = Box::into_raw(Box::new(AsicMiner::new(Box::new(TestMiner)))); + unsafe { + assert_eq!(asic_rs_miner_restart_mining(miner), -1); + assert!( + CStr::from_ptr(asic_rs_last_error()) + .to_str() + .unwrap() + .contains("Restarting the mining program is not supported") + ); + asic_rs_miner_free(miner); + } + } + #[test] fn scheduling_rejects_invalid_durations_without_panicking() { for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, f64::MAX] { diff --git a/c/asic-rs-ffi/src/test.rs b/c/asic-rs-ffi/src/test.rs index de3b973e..6483e2ca 100644 --- a/c/asic-rs-ffi/src/test.rs +++ b/c/asic-rs-ffi/src/test.rs @@ -91,6 +91,9 @@ impl Restart for TestMiner { panic!("backend restart panic") } } + +impl RestartMining for TestMiner {} + #[async_trait] impl RestoreStockOs for TestMiner { fn supports_restore_stock_os(&self) -> bool { diff --git a/docs/api.md b/docs/api.md index 49847347..5aee89f3 100644 --- a/docs/api.md +++ b/docs/api.md @@ -154,6 +154,7 @@ Python exposes matching `supports_*` properties; Go returns them from | Capability | Control | | --- | --- | | `supports_restart` | `restart()` | +| `supports_restart_mining` | `restart_mining()` | | `supports_pause` | `pause()` | | `supports_resume` | `resume()` | | `supports_set_fault_light` | `set_fault_light(...)` | diff --git a/docs/supported-devices.md b/docs/supported-devices.md index 3bdb06d5..e9afc312 100644 --- a/docs/supported-devices.md +++ b/docs/supported-devices.md @@ -13,26 +13,26 @@ Legend: ## Support Matrix -| Firmware type | Pools Config | Scaling Config | Tuning Config | Fan Config | Light | Power Limit | Restart | Pause/Resume | Upgrade FW | Change PWD | Factory Reset Settings | Restore Stock OS | Read Logs | -| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| AntMiner Stock | :lucide-check-check: | :lucide-x: | :lucide-list-todo: | :lucide-list-todo: | :lucide-check-check: | :lucide-x: | :lucide-check-check: | :lucide-list-todo: | :lucide-check-check: | :lucide-check-check: | :lucide-check-check: | :lucide-x: | :lucide-list-todo: | -| Auradine Stock | :lucide-check-check: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-check-check: | :lucide-check-check: | :lucide-check-check: | :lucide-check-check: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | -| AvalonMiner Stock | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-check-check: | :lucide-check-check: | :lucide-list-todo: | :lucide-check-check: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | -| Bitaxe Stock | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-list-todo: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | -| Braiins | :lucide-check-check: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-check-check: | :lucide-check-check: | :lucide-check-check: | :lucide-check-check: | :lucide-x: | :lucide-check-check: | :lucide-list-todo: | :lucide-list-todo: | :lucide-check-check: | -| Elphapex Stock | :lucide-check-check: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-check-check: | :lucide-x: | :lucide-check-check: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | -| ForgeOS Stock | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | -| FutureBit Stock | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | -| Goldshell Stock | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | -| LuxOS | :lucide-check-check: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-check-check: | :lucide-x: | :lucide-check-check: | :lucide-check-check: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-check-check: | :lucide-x: | -| Marathon | :lucide-check-check: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-check-check: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | -| Nerdaxe Stock | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | -| Proto Stock | :lucide-check-check: | :lucide-x: | :lucide-check-check: | :lucide-check-check: | :lucide-check-check: | :lucide-check-check: | :lucide-check-check: | :lucide-check-check: | :lucide-x: | :lucide-check-check: | :lucide-x: | :lucide-x: | :lucide-check-check: | -| SealMiner Stock | :lucide-check-check: | :lucide-x: | :lucide-check-check: | :lucide-x: | :lucide-check-check: | :lucide-check-check: | :lucide-check-check: | :lucide-check-check: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | -| UMC OS | :lucide-check-check: | :lucide-check-check: | :lucide-check-check: | :lucide-check-check: | :lucide-check-check: | :lucide-x: | :lucide-check-check: | :lucide-check-check: | :lucide-check-check: | :lucide-check-check: | :lucide-x: | :lucide-list-todo: | :lucide-check-check: | -| VNish | :lucide-check-check: | :lucide-x: | :lucide-check-check: | :lucide-x: | :lucide-check-check: | :lucide-check-check: | :lucide-check-check: | :lucide-check-check: | :lucide-x: | :lucide-check-check: | :lucide-check-check: | :lucide-check-check: | :lucide-check-check: | -| VolcMiner Stock | :lucide-check-check: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-check-check: | :lucide-x: | :lucide-x: | :lucide-check-check: | :lucide-x: | :lucide-x: | :lucide-x: | -| WhatsMiner Stock | :lucide-list-todo: | :lucide-x: | :lucide-list-todo: | :lucide-x: | :lucide-list-todo: | :lucide-list-todo: | :lucide-list-todo: | :lucide-list-todo: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | +| Firmware type | Pools Config | Scaling Config | Tuning Config | Fan Config | Light | Power Limit | Restart | Restart Mining | Pause/Resume | Upgrade FW | Change PWD | Factory Reset Settings | Restore Stock OS | Read Logs | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| AntMiner Stock | :lucide-check-check: | :lucide-x: | :lucide-list-todo: | :lucide-list-todo: | :lucide-check-check: | :lucide-x: | :lucide-check-check: | :lucide-x: | :lucide-list-todo: | :lucide-check-check: | :lucide-check-check: | :lucide-check-check: | :lucide-x: | :lucide-list-todo: | +| Auradine Stock | :lucide-check-check: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-check-check: | :lucide-check-check: | :lucide-check-check: | :lucide-check-check: | :lucide-check-check: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | +| AvalonMiner Stock | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-check-check: | :lucide-check-check: | :lucide-list-todo: | :lucide-x: | :lucide-check-check: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | +| Bitaxe Stock | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-list-todo: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | +| Braiins | :lucide-check-check: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-check-check: | :lucide-check-check: | :lucide-check-check: | :lucide-check-check: | :lucide-check-check: | :lucide-x: | :lucide-check-check: | :lucide-list-todo: | :lucide-list-todo: | :lucide-check-check: | +| Elphapex Stock | :lucide-check-check: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-check-check: | :lucide-x: | :lucide-check-check: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | +| ForgeOS Stock | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | +| FutureBit Stock | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | +| Goldshell Stock | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | +| LuxOS | :lucide-check-check: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-check-check: | :lucide-x: | :lucide-check-check: | :lucide-check-check: | :lucide-check-check: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-check-check: | :lucide-x: | +| Marathon | :lucide-check-check: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-check-check: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | +| Nerdaxe Stock | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | +| Proto Stock | :lucide-check-check: | :lucide-x: | :lucide-check-check: | :lucide-check-check: | :lucide-check-check: | :lucide-check-check: | :lucide-check-check: | :lucide-x: | :lucide-check-check: | :lucide-x: | :lucide-check-check: | :lucide-x: | :lucide-x: | :lucide-check-check: | +| SealMiner Stock | :lucide-check-check: | :lucide-x: | :lucide-check-check: | :lucide-x: | :lucide-check-check: | :lucide-check-check: | :lucide-check-check: | :lucide-x: | :lucide-check-check: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | +| UMC OS | :lucide-check-check: | :lucide-check-check: | :lucide-check-check: | :lucide-check-check: | :lucide-check-check: | :lucide-x: | :lucide-check-check: | :lucide-check-check: | :lucide-check-check: | :lucide-check-check: | :lucide-check-check: | :lucide-x: | :lucide-list-todo: | :lucide-check-check: | +| VNish | :lucide-check-check: | :lucide-x: | :lucide-check-check: | :lucide-x: | :lucide-check-check: | :lucide-check-check: | :lucide-check-check: | :lucide-check-check: | :lucide-check-check: | :lucide-x: | :lucide-check-check: | :lucide-check-check: | :lucide-check-check: | :lucide-check-check: | +| VolcMiner Stock | :lucide-check-check: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-check-check: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-check-check: | :lucide-x: | :lucide-x: | :lucide-x: | +| WhatsMiner Stock | :lucide-list-todo: | :lucide-x: | :lucide-list-todo: | :lucide-x: | :lucide-list-todo: | :lucide-list-todo: | :lucide-list-todo: | :lucide-x: | :lucide-list-todo: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | :lucide-x: | ## Exact Supported Models diff --git a/go/asic_go/include/asic_rs_ffi.h b/go/asic_go/include/asic_rs_ffi.h index 51c00489..389cede0 100644 --- a/go/asic_go/include/asic_rs_ffi.h +++ b/go/asic_go/include/asic_rs_ffi.h @@ -536,6 +536,12 @@ int32_t asic_rs_miner_set_tuning_percent(const struct AsicMiner *miner, uint8_t // `miner` must be a live handle. int32_t asic_rs_miner_restart(const struct AsicMiner *miner); +// Restart the mining program without rebooting the miner. Returns 1/0/-1. +// +// # Safety +// `miner` must be a live handle. +int32_t asic_rs_miner_restart_mining(const struct AsicMiner *miner); + // Pause mining. `at_time_secs` < 0 means immediately. Returns 1/0/-1. // // # Safety diff --git a/go/asic_go/miner.go b/go/asic_go/miner.go index c953dd73..d494f785 100644 --- a/go/asic_go/miner.go +++ b/go/asic_go/miner.go @@ -599,6 +599,13 @@ func (m *Miner) Restart() (bool, error) { }) } +// RestartMining restarts the mining program without rebooting the miner. +func (m *Miner) RestartMining() (bool, error) { + return m.control(func(ptr *C.AsicMiner) C.int32_t { + return C.asic_rs_miner_restart_mining(ptr) + }) +} + // Pause pauses mining. A nil atTime means immediately. func (m *Miner) Pause(atTime *time.Duration) (bool, error) { secs := -1.0 diff --git a/go/asic_go/types.go b/go/asic_go/types.go index dd6baed7..b2cbd618 100644 --- a/go/asic_go/types.go +++ b/go/asic_go/types.go @@ -772,6 +772,7 @@ type Supports struct { SetTuningPercent bool `json:"set_tuning_percent"` Presets bool `json:"presets"` Restart bool `json:"restart"` + RestartMining bool `json:"restart_mining"` Pause bool `json:"pause"` Resume bool `json:"resume"` ChangePassword bool `json:"change_password"` diff --git a/python/pyasic-rs/pyasic_rs/asic_rs.pyi b/python/pyasic-rs/pyasic_rs/asic_rs.pyi index 4cfbe7f5..51b76320 100644 --- a/python/pyasic-rs/pyasic_rs/asic_rs.pyi +++ b/python/pyasic-rs/pyasic_rs/asic_rs.pyi @@ -512,6 +512,7 @@ class Miner: def prepare_firmware(self, /, path: StrOrBytesPath) -> Awaitable[FirmwareImage]: ... def read_logs(self, /) -> Awaitable[str |None]: ... def restart(self, /) -> Awaitable[bool]: ... + def restart_mining(self, /) -> Awaitable[bool]: ... def restore_stock_os(self, /) -> Awaitable[RestoreStockOsResult]: ... def resume(self, /, at_time: "timedelta | float | int | None" = None) -> Awaitable[bool]: ... def revalidate(self, /) -> Awaitable[bool |None]: ... @@ -547,6 +548,8 @@ class Miner: @property def supports_restart(self, /) -> bool: ... @property + def supports_restart_mining(self, /) -> bool: ... + @property def supports_restore_stock_os(self, /) -> bool: ... @property def supports_resume(self, /) -> bool: ... diff --git a/python/pyasic-rs/src/miner.rs b/python/pyasic-rs/src/miner.rs index 07529ea8..2518a175 100644 --- a/python/pyasic-rs/src/miner.rs +++ b/python/pyasic-rs/src/miner.rs @@ -201,6 +201,11 @@ impl Miner { fn supports_restart(&self, py: Python<'_>) -> bool { self.with_miner(py, |miner| miner.supports_restart()) } + /// Whether this miner can restart its mining program without rebooting. + #[getter] + fn supports_restart_mining(&self, py: Python<'_>) -> bool { + self.with_miner(py, |miner| miner.supports_restart_mining()) + } /// Whether this miner supports pause commands. #[getter] fn supports_pause(&self, py: Python<'_>) -> bool { @@ -678,6 +683,18 @@ impl Miner { .map_err(|e| PyRuntimeError::new_err(e.to_string())) }) } + /// Restart the mining program without rebooting the miner. + /// + pub fn restart_mining<'a>(&self, py: Python<'a>) -> PyResult> { + let inner = Arc::clone(&self.inner); + future_into_py(py, async move { + let inner = inner.read().await; + inner + .restart_mining() + .await + .map_err(|e| PyRuntimeError::new_err(e.to_string())) + }) + } /// Pause mining immediately or after a delay. /// /// `at_time` may be `datetime.timedelta`, non-negative seconds, or `None`. diff --git a/scripts/docs/gen_supported_devices.py b/scripts/docs/gen_supported_devices.py index a995f967..a7998aea 100644 --- a/scripts/docs/gen_supported_devices.py +++ b/scripts/docs/gen_supported_devices.py @@ -20,6 +20,7 @@ ("SetFaultLight", "supports_set_fault_light", "Light"), ("SetPowerLimit", "supports_set_power_limit", "Power Limit"), ("Restart", "supports_restart", "Restart"), + ("RestartMining", "supports_restart_mining", "Restart Mining"), ("Resume", "supports_resume", "Pause/Resume"), ("UpgradeFirmware", "supports_upgrade_firmware", "Upgrade FW"), ("ChangePassword", "supports_change_password", "Change PWD"),