diff --git a/asic-rs-firmwares/antminer/src/backends/v2020/mod.rs b/asic-rs-firmwares/antminer/src/backends/v2020/mod.rs index f83ab721..02b9bc54 100644 --- a/asic-rs-firmwares/antminer/src/backends/v2020/mod.rs +++ b/asic-rs-firmwares/antminer/src/backends/v2020/mod.rs @@ -509,14 +509,37 @@ impl GetDataLocations for AntMinerV2020 { tag: None, }, )], - DataField::Wattage => vec![( - RPC_STATS, - DataExtractor { - func: get_by_pointer, - key: Some("/STATS/1"), - tag: None, - }, - )], + DataField::Wattage => { + // Newer stock firmware reports power draw only in the `new_api` + // variant of `stats`. That is a different payload to the legacy + // one: its `STATS` array holds a single element, so the reading + // sits at `/STATS/0` rather than the `/STATS/1` used above. + // + // 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 +858,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..dbaba456 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}; +/// Build the JSON request for a cgminer-style RPC call. +/// +/// Bitmain's API extensions — `new_api` being the one this backend uses — are +/// top-level flags alongside `command`, not values of cgminer's `parameter` +/// argument. Wrapping them makes the firmware ignore the flag and silently +/// answer with the legacy payload, which is indistinguishable from a +/// successful call. +/// +/// An object is always one of those extensions; cgminer's own convention +/// passes a scalar (e.g. `switchpool`'s pool index), so scalars keep the +/// `parameter` wrapper. +fn build_rpc_request(command: &str, parameters: Option) -> Value { + match parameters { + Some(Value::Object(params)) => { + let mut request = params; + // Inserted last so a stray `command` key cannot displace it. + request.insert("command".to_string(), Value::from(command)); + Value::Object(request) + } + Some(params) => json!({ + "command": command, + "parameter": params + }), + None => json!({ + "command": command + }), + } +} + #[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,43 @@ impl StatusFromAntMiner for RPCCommandStatus { Ok(Self::Success) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn object_parameters_become_top_level_flags() { + // 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 absent_parameters_send_the_bare_command() { + assert_eq!( + build_rpc_request("version", None), + json!({"command": "version"}) + ); + } + + #[test] + fn command_wins_over_a_conflicting_parameter_key() { + assert_eq!( + build_rpc_request("stats", Some(json!({"command": "evil"}))), + json!({"command": "stats"}) + ); + } +} 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..c7ee40b0 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,37 @@ impl GetDataLocations for AntMinerV202307 { tag: None, }, )], - DataField::Wattage => vec![( - RPC_STATS, - DataExtractor { - func: get_by_pointer, - key: Some("/STATS/1"), - tag: None, - }, - )], + DataField::Wattage => { + // Newer stock firmware reports power draw only in the `new_api` + // variant of `stats`. That is a different payload to the legacy + // one: its `STATS` array holds a single element, so the reading + // sits at `/STATS/0` rather than the `/STATS/1` used above. + // + // 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 +835,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..dbaba456 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}; +/// Build the JSON request for a cgminer-style RPC call. +/// +/// Bitmain's API extensions — `new_api` being the one this backend uses — are +/// top-level flags alongside `command`, not values of cgminer's `parameter` +/// argument. Wrapping them makes the firmware ignore the flag and silently +/// answer with the legacy payload, which is indistinguishable from a +/// successful call. +/// +/// An object is always one of those extensions; cgminer's own convention +/// passes a scalar (e.g. `switchpool`'s pool index), so scalars keep the +/// `parameter` wrapper. +fn build_rpc_request(command: &str, parameters: Option) -> Value { + match parameters { + Some(Value::Object(params)) => { + let mut request = params; + // Inserted last so a stray `command` key cannot displace it. + request.insert("command".to_string(), Value::from(command)); + Value::Object(request) + } + Some(params) => json!({ + "command": command, + "parameter": params + }), + None => json!({ + "command": command + }), + } +} + #[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,43 @@ impl StatusFromAntMiner for RPCCommandStatus { Ok(Self::Success) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn object_parameters_become_top_level_flags() { + // 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 absent_parameters_send_the_bare_command() { + assert_eq!( + build_rpc_request("version", None), + json!({"command": "version"}) + ); + } + + #[test] + fn command_wins_over_a_conflicting_parameter_key() { + assert_eq!( + build_rpc_request("stats", Some(json!({"command": "evil"}))), + json!({"command": "stats"}) + ); + } +}