diff --git a/asic-rs-firmwares/antminer/src/backends/v2020/mod.rs b/asic-rs-firmwares/antminer/src/backends/v2020/mod.rs index f83ab721..ca76f0a8 100644 --- a/asic-rs-firmwares/antminer/src/backends/v2020/mod.rs +++ b/asic-rs-firmwares/antminer/src/backends/v2020/mod.rs @@ -509,14 +509,32 @@ impl GetDataLocations for AntMinerV2020 { tag: None, }, )], - DataField::Wattage => vec![( - RPC_STATS, - DataExtractor { - func: get_by_pointer, - key: Some("/STATS/1"), - tag: None, - }, - )], + DataField::Wattage => { + // Not a `const` because `json!` is not const-evaluable. + let rpc_new_stats = MinerCommand::RPC { + command: "stats", + parameters: Some(json!({ "new_api": true })), + }; + + vec![ + ( + RPC_STATS, + DataExtractor { + func: get_by_pointer, + key: Some("/STATS/1"), + tag: None, + }, + ), + ( + rpc_new_stats, + DataExtractor { + func: get_by_pointer, + key: Some("/STATS/0"), + tag: None, + }, + ), + ] + } DataField::SerialNumber => vec![ ( WEB_SYSTEM_INFO, @@ -835,6 +853,9 @@ impl GetWattage for AntMinerV2020 { if let Some(power) = stats_data .get("power") .or_else(|| stats_data.get("Power")) + // Same firmware version spells this differently per model: the + // L9 reports `power`, the L11 `watt`. + .or_else(|| stats_data.get("watt")) .and_then(|v| v.as_f64()) { return Some(Power::from_watts(power)); diff --git a/asic-rs-firmwares/antminer/src/backends/v2020/rpc.rs b/asic-rs-firmwares/antminer/src/backends/v2020/rpc.rs index a27f2d58..23962f35 100644 --- a/asic-rs-firmwares/antminer/src/backends/v2020/rpc.rs +++ b/asic-rs-firmwares/antminer/src/backends/v2020/rpc.rs @@ -10,6 +10,35 @@ use asic_rs_core::{ use async_trait::async_trait; use serde_json::{Value, json}; +/// Bitmain's one API extension to the cgminer protocol. +const NEW_API_FLAG: &str = "new_api"; + +/// Build the JSON request for a cgminer-style RPC call. +/// +/// `new_api` is a top-level flag alongside `command`, not a value of cgminer's +/// `parameter` argument. Nested under `parameter` the firmware ignores it and +/// answers with the legacy payload — a well-formed success a caller cannot +/// tell apart from the real thing. Everything else keeps the `parameter` +/// wrapper cgminer expects, e.g. `switchpool`'s pool index. +fn build_rpc_request(command: &str, parameters: Option) -> Value { + let mut request = json!({ "command": command }); + + match parameters { + Some(Value::Object(mut params)) => { + if let Some(new_api) = params.remove(NEW_API_FLAG) { + request[NEW_API_FLAG] = new_api; + } + if !params.is_empty() { + request["parameter"] = Value::Object(params); + } + } + Some(params) => request["parameter"] = params, + None => {} + } + + request +} + #[derive(Debug)] pub struct AntMinerRPCAPI { ip: IpAddr, @@ -28,16 +57,7 @@ impl AntMinerRPCAPI { _privileged: bool, parameters: Option, ) -> anyhow::Result { - let request = if let Some(params) = parameters { - json!({ - "command": command, - "parameter": params - }) - } else { - json!({ - "command": command - }) - }; + let request = build_rpc_request(command, parameters); let json_str = request.to_string(); let message = format!("{}\n", json_str); @@ -159,3 +179,52 @@ impl StatusFromAntMiner for RPCCommandStatus { Ok(Self::Success) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_api_becomes_a_top_level_flag() { + // The firmware only honours `new_api` at the top level; nested under + // `parameter` it is ignored and the legacy payload comes back. + assert_eq!( + build_rpc_request("stats", Some(json!({"new_api": true}))), + json!({"command": "stats", "new_api": true}) + ); + } + + #[test] + fn scalar_parameters_keep_the_cgminer_wrapper() { + // cgminer's own convention, e.g. `switchpool` with a pool index. + assert_eq!( + build_rpc_request("switchpool", Some(json!("1"))), + json!({"command": "switchpool", "parameter": "1"}) + ); + } + + #[test] + fn other_object_parameters_keep_the_cgminer_wrapper() { + // Only `new_api` is lifted; anything else stays where cgminer wants it. + assert_eq!( + build_rpc_request("ascset", Some(json!({"idx": 0}))), + json!({"command": "ascset", "parameter": {"idx": 0}}) + ); + } + + #[test] + fn new_api_is_lifted_out_of_a_larger_parameter_object() { + assert_eq!( + build_rpc_request("stats", Some(json!({"new_api": true, "idx": 0}))), + json!({"command": "stats", "new_api": true, "parameter": {"idx": 0}}) + ); + } + + #[test] + fn absent_parameters_send_the_bare_command() { + assert_eq!( + build_rpc_request("version", None), + json!({"command": "version"}) + ); + } +} 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 71b5b6ce..3d5c30da 100644 --- a/asic-rs-firmwares/antminer/src/backends/v2023_07/mod.rs +++ b/asic-rs-firmwares/antminer/src/backends/v2023_07/mod.rs @@ -485,14 +485,32 @@ impl GetDataLocations for AntMinerV202307 { tag: None, }, )], - DataField::Wattage => vec![( - RPC_STATS, - DataExtractor { - func: get_by_pointer, - key: Some("/STATS/1"), - tag: None, - }, - )], + DataField::Wattage => { + // Not a `const` because `json!` is not const-evaluable. + let rpc_new_stats = MinerCommand::RPC { + command: "stats", + parameters: Some(json!({ "new_api": true })), + }; + + vec![ + ( + RPC_STATS, + DataExtractor { + func: get_by_pointer, + key: Some("/STATS/1"), + tag: None, + }, + ), + ( + rpc_new_stats, + DataExtractor { + func: get_by_pointer, + key: Some("/STATS/0"), + tag: None, + }, + ), + ] + } DataField::SerialNumber => vec![ ( WEB_SYSTEM_INFO, @@ -812,6 +830,9 @@ impl GetWattage for AntMinerV202307 { if let Some(power) = stats_data .get("power") .or_else(|| stats_data.get("Power")) + // Same firmware version spells this differently per model: the + // L9 reports `power`, the L11 `watt`. + .or_else(|| stats_data.get("watt")) .and_then(|v| v.as_f64()) { return Some(Power::from_watts(power)); diff --git a/asic-rs-firmwares/antminer/src/backends/v2023_07/rpc.rs b/asic-rs-firmwares/antminer/src/backends/v2023_07/rpc.rs index a27f2d58..23962f35 100644 --- a/asic-rs-firmwares/antminer/src/backends/v2023_07/rpc.rs +++ b/asic-rs-firmwares/antminer/src/backends/v2023_07/rpc.rs @@ -10,6 +10,35 @@ use asic_rs_core::{ use async_trait::async_trait; use serde_json::{Value, json}; +/// Bitmain's one API extension to the cgminer protocol. +const NEW_API_FLAG: &str = "new_api"; + +/// Build the JSON request for a cgminer-style RPC call. +/// +/// `new_api` is a top-level flag alongside `command`, not a value of cgminer's +/// `parameter` argument. Nested under `parameter` the firmware ignores it and +/// answers with the legacy payload — a well-formed success a caller cannot +/// tell apart from the real thing. Everything else keeps the `parameter` +/// wrapper cgminer expects, e.g. `switchpool`'s pool index. +fn build_rpc_request(command: &str, parameters: Option) -> Value { + let mut request = json!({ "command": command }); + + match parameters { + Some(Value::Object(mut params)) => { + if let Some(new_api) = params.remove(NEW_API_FLAG) { + request[NEW_API_FLAG] = new_api; + } + if !params.is_empty() { + request["parameter"] = Value::Object(params); + } + } + Some(params) => request["parameter"] = params, + None => {} + } + + request +} + #[derive(Debug)] pub struct AntMinerRPCAPI { ip: IpAddr, @@ -28,16 +57,7 @@ impl AntMinerRPCAPI { _privileged: bool, parameters: Option, ) -> anyhow::Result { - let request = if let Some(params) = parameters { - json!({ - "command": command, - "parameter": params - }) - } else { - json!({ - "command": command - }) - }; + let request = build_rpc_request(command, parameters); let json_str = request.to_string(); let message = format!("{}\n", json_str); @@ -159,3 +179,52 @@ impl StatusFromAntMiner for RPCCommandStatus { Ok(Self::Success) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_api_becomes_a_top_level_flag() { + // The firmware only honours `new_api` at the top level; nested under + // `parameter` it is ignored and the legacy payload comes back. + assert_eq!( + build_rpc_request("stats", Some(json!({"new_api": true}))), + json!({"command": "stats", "new_api": true}) + ); + } + + #[test] + fn scalar_parameters_keep_the_cgminer_wrapper() { + // cgminer's own convention, e.g. `switchpool` with a pool index. + assert_eq!( + build_rpc_request("switchpool", Some(json!("1"))), + json!({"command": "switchpool", "parameter": "1"}) + ); + } + + #[test] + fn other_object_parameters_keep_the_cgminer_wrapper() { + // Only `new_api` is lifted; anything else stays where cgminer wants it. + assert_eq!( + build_rpc_request("ascset", Some(json!({"idx": 0}))), + json!({"command": "ascset", "parameter": {"idx": 0}}) + ); + } + + #[test] + fn new_api_is_lifted_out_of_a_larger_parameter_object() { + assert_eq!( + build_rpc_request("stats", Some(json!({"new_api": true, "idx": 0}))), + json!({"command": "stats", "new_api": true, "parameter": {"idx": 0}}) + ); + } + + #[test] + fn absent_parameters_send_the_bare_command() { + assert_eq!( + build_rpc_request("version", None), + json!({"command": "version"}) + ); + } +}