matroskin 0.1.0

whatsminer api client lib
Documentation
//! Implement `get.miner.status` command
//!
//! This command is used to get the miner status summary, pool information,
//! and/or device (edevs) information.
//!
//! - Command: [GetMinerStatus]
//! - ApiDoc: <https://apidoc.whatsminer.com/#api-Miner-btminer_get_status>

use core::str;

use serde::Deserialize;

use crate::{
    command::{Command, CommandLevel},
    error::Result,
    permission::PermissionLevel,
};

/// This command represents the `get.miner.status` operation.
///
/// It is used to get the miner status summary, pool information,
/// and/or device (edevs) information.
///
/// The `param` field controls which sections are returned. If no param
/// flags are set, the API returns no data (empty `msg`).
///
/// - ApiDoc: <https://apidoc.whatsminer.com/#api-Miner-btminer_get_status>
///
/// # Example
/// ```rust,no_run
/// use matroskin::actor::Actor;
/// use matroskin::command::get_miner_status::GetMinerStatus;
/// use matroskin::account::Account;
/// use matroskin::password::Password;
///
/// #[tokio::main]
/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
///     let addr = "10.10.10.10:4433";
///     let username = Account::Super;
///     let password = Password::Super;
///
///     let actor = Actor::new(addr, username, password);
///     actor.connect()?;
///
///     // Request all status sections (summary, pools, edevs)
///     let response = actor.send(GetMinerStatus::all()).await?;
///     println!("Status: {:#?}", response);
///
///     Ok(())
/// }
/// ```
/// This command represents the `get.miner.status` operation.
///
/// Each flag controls whether the corresponding section is included
/// in the response. Flags are combined with `+` in the request param
/// string (e.g. `"summary+pools"`).
///
/// - ApiDoc: <https://apidoc.whatsminer.com/#api-Miner-btminer_get_status>
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, serde::Serialize)]
pub struct GetMinerStatus {
    /// Include miner status summary
    pub summary: bool,
    /// Include pool information
    pub pools: bool,
    /// Include device (edevs) information
    pub edevs: bool,
}

impl GetMinerStatus {
    /// Create a new `get.miner.status` command with explicit flags.
    pub fn new(summary: bool, pools: bool, edevs: bool) -> Self {
        Self {
            summary,
            pools,
            edevs,
        }
    }

    /// Request miner status summary only.
    pub fn summary() -> Self {
        Self {
            summary: true,
            pools: false,
            edevs: false,
        }
    }

    /// Request pool information only.
    pub fn pools() -> Self {
        Self {
            summary: false,
            pools: true,
            edevs: false,
        }
    }

    /// Request device (edevs) information only.
    pub fn edevs() -> Self {
        Self {
            summary: false,
            pools: false,
            edevs: true,
        }
    }

    /// Request all sections (summary, pools, edevs).
    pub fn all() -> Self {
        Self {
            summary: true,
            pools: true,
            edevs: true,
        }
    }
}

/// [GetMinerStatus] Response
///
/// Only the sections requested via [GetMinerStatus] will be
/// populated; the others will be `None`.
#[derive(Debug, Default, Deserialize)]
pub struct GetMinerStatusResponse {
    /// Miner status summary (present when `summary` param is set)
    #[serde(default)]
    pub summary: Option<MinerStatusSummary>,
    /// Pool information list (present when `pools` param is set)
    #[serde(default)]
    pub pools: Option<Vec<MinerStatusPool>>,
    /// Device information list (present when `edevs` param is set)
    #[serde(default)]
    pub edevs: Option<Vec<MinerStatusEdev>>,
}

/// Miner status summary returned by `get.miner.status` with `summary` param.
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct MinerStatusSummary {
    /// Elapsed time since boot (seconds)
    pub elapsed: i64,
    /// Boot-up time (seconds)
    pub bootup_time: i64,
    /// Average frequency across all boards
    pub freq_avg: i64,
    /// Target frequency (0 = not set)
    pub target_freq: i64,
    /// Factory-calibrated hash rate (TH/s)
    pub factory_hash: f64,
    /// Average hash rate (TH/s)
    pub hash_average: f64,
    /// 1-minute hash rate (TH/s)
    pub hash_1min: f64,
    /// 15-minute hash rate (TH/s)
    pub hash_15min: f64,
    /// Real-time hash rate (TH/s)
    pub hash_realtime: f64,
    /// Power consumption rate (W/TH)
    pub power_rate: f64,
    /// 5-minute average power consumption (W)
    pub power_5min: f64,
    /// Real-time power consumption (W)
    pub power_realtime: f64,
    /// Environment temperature (°C)
    pub environment_temperature: f64,
    /// Board temperatures (°C), one entry per hash board
    pub board_temperature: Vec<f64>,
    /// Minimum chip temperature across all boards (°C)
    pub chip_temp_min: f64,
    /// Average chip temperature across all boards (°C)
    pub chip_temp_avg: f64,
    /// Maximum chip temperature across all boards (°C)
    pub chip_temp_max: f64,
    /// Power limit (W)
    pub power_limit: i64,
    /// Whether frequency upscaling is finished (1 = yes)
    pub up_freq_finish: i64,
    /// Intake fan speed (RPM)
    pub fan_speed_in: i64,
    /// Exhaust fan speed (RPM)
    pub fan_speed_out: i64,
}

/// Pool information returned by `get.miner.status` with `pools` param.
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct MinerStatusPool {
    /// Pool ID
    pub id: i64,
    /// Pool URL (e.g. `stratum+tcp://...`)
    pub url: String,
    /// Pool connection status (e.g. `"alive"`)
    pub status: String,
    /// Pool account/worker name
    pub account: String,
    /// Whether this is the active stratum connection
    pub stratum_active: bool,
    /// Share reject rate (0–1)
    pub reject_rate: f64,
    /// UNIX timestamp of the last accepted share
    pub last_share_time: i64,
}

/// Device information returned by `get.miner.status` with `edevs` param.
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct MinerStatusEdev {
    /// Device ID
    pub id: i64,
    /// Slot index
    pub slot: i64,
    /// Average hash rate for this device (TH/s)
    pub hash_average: f64,
    /// Factory-calibrated hash rate for this device (TH/s)
    pub factory_hash: f64,
    /// Frequency for this device (MHz)
    pub freq: i64,
    /// Number of effective (working) chips
    pub effective_chips: i64,
    /// Minimum chip temperature on this device (°C)
    pub chip_temp_min: f64,
    /// Average chip temperature on this device (°C)
    pub chip_temp_avg: f64,
    /// Maximum chip temperature on this device (°C)
    pub chip_temp_max: f64,
}

impl Command for GetMinerStatus {
    type Response = GetMinerStatusResponse;
    const CMD_NAME: &'static str = "get.miner.status";
    const LEVEL: CommandLevel = CommandLevel::Public;
    const MINIMAL_PERMISSIONS: PermissionLevel = PermissionLevel::ReadOnly;

    fn params(&self) -> Result<Option<impl serde::Serialize>> {
        if !self.summary && !self.pools && !self.edevs {
            return Ok(None);
        }
        let mut parts = Vec::with_capacity(3);
        if self.summary {
            parts.push("summary");
        }
        if self.pools {
            parts.push("pools");
        }
        if self.edevs {
            parts.push("edevs");
        }
        Ok(Some(parts.join("+")))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    const SUMMARY_JSON: &str = r#"{"code":0,"when":1692685476,"msg":{"summary":{"elapsed":1074,"bootup-time":1175,"freq-avg":788,"target-freq":0,"factory-hash":101.366,"hash-average":101.847,"hash-1min":101.072,"hash-15min":101.847,"hash-realtime":101.847,"power-rate":31.886,"power-5min":3247.641,"power-realtime":3268,"environment-temperature":34.8,"board-temperature":[69.6,70.1,72.3],"chip-temp-min":83.2,"chip-temp-avg":92.9,"chip-temp-max":100.3,"power-limit":3500,"up-freq-finish":1,"fan-speed-in":4980,"fan-speed-out":5070}},"desc":"get.miner.status"}"#;

    const POOLS_JSON: &str = r#"{"code":0,"when":1692685470,"msg":{"pools":[{"id":1,"url":"stratum+tcp://192.168.2.24:5353","status":"alive","account":"xxxxxxxx","stratum-active":true,"reject-rate":0,"last-share-time":1692685469}]},"desc":"get.miner.status"}"#;

    const EDEVS_JSON: &str = r#"{"code":0,"when":1692685473,"msg":{"edevs":[{"id":0,"slot":0,"hash-average":33.972,"factory-hash":33.789,"freq":785,"effective-chips":70,"chip-temp-min":84.9,"chip-temp-avg":92.2,"chip-temp-max":97.4}]},"desc":"get.miner.status"}"#;

    #[test]
    fn request_no_params_returns_none() {
        let cmd = GetMinerStatus::default();
        let encoded = cmd.encode(None).unwrap();
        let json: serde_json::Value = serde_json::from_slice(&encoded.0).unwrap();
        assert_eq!(json["cmd"], "get.miner.status");
        assert!(json.get("param").is_none());
        assert!(json.get("token").is_none());
    }

    #[test]
    fn request_summary_only() {
        let cmd = GetMinerStatus::summary();
        let encoded = cmd.encode(None).unwrap();
        let json: serde_json::Value = serde_json::from_slice(&encoded.0).unwrap();
        assert_eq!(json["cmd"], "get.miner.status");
        assert_eq!(json["param"], "summary");
    }

    #[test]
    fn request_pools_only() {
        let cmd = GetMinerStatus::pools();
        let encoded = cmd.encode(None).unwrap();
        let json: serde_json::Value = serde_json::from_slice(&encoded.0).unwrap();
        assert_eq!(json["cmd"], "get.miner.status");
        assert_eq!(json["param"], "pools");
    }

    #[test]
    fn request_edevs_only() {
        let cmd = GetMinerStatus::edevs();
        let encoded = cmd.encode(None).unwrap();
        let json: serde_json::Value = serde_json::from_slice(&encoded.0).unwrap();
        assert_eq!(json["cmd"], "get.miner.status");
        assert_eq!(json["param"], "edevs");
    }

    #[test]
    fn request_summary_plus_pools() {
        let cmd = GetMinerStatus::new(true, true, false);
        let encoded = cmd.encode(None).unwrap();
        let json: serde_json::Value = serde_json::from_slice(&encoded.0).unwrap();
        assert_eq!(json["cmd"], "get.miner.status");
        assert_eq!(json["param"], "summary+pools");
    }

    #[test]
    fn request_all_params() {
        let cmd = GetMinerStatus::all();
        let encoded = cmd.encode(None).unwrap();
        let json: serde_json::Value = serde_json::from_slice(&encoded.0).unwrap();
        assert_eq!(json["cmd"], "get.miner.status");
        assert_eq!(json["param"], "summary+pools+edevs");
    }
    #[test]
    fn deserialize_summary_response_fixture() {
        let response = GetMinerStatus::parse_response(SUMMARY_JSON.as_bytes()).unwrap();
        assert!(response.code.is_success());
        assert_eq!(response.desc, "get.miner.status");
        assert_eq!(response.when, 1692685476);

        let summary = response.msg.summary.unwrap();
        assert_eq!(summary.elapsed, 1074);
        assert_eq!(summary.bootup_time, 1175);
        assert_eq!(summary.freq_avg, 788);
        assert_eq!(summary.target_freq, 0);
        assert!((summary.factory_hash - 101.366).abs() < f64::EPSILON);
        assert!((summary.hash_average - 101.847).abs() < f64::EPSILON);
        assert!((summary.hash_1min - 101.072).abs() < f64::EPSILON);
        assert!((summary.hash_15min - 101.847).abs() < f64::EPSILON);
        assert!((summary.hash_realtime - 101.847).abs() < f64::EPSILON);
        assert!((summary.power_rate - 31.886).abs() < f64::EPSILON);
        assert!((summary.power_5min - 3247.641).abs() < f64::EPSILON);
        assert!((summary.power_realtime - 3268.0).abs() < f64::EPSILON);
        assert!((summary.environment_temperature - 34.8).abs() < f64::EPSILON);
        assert_eq!(summary.board_temperature, vec![69.6, 70.1, 72.3]);
        assert!((summary.chip_temp_min - 83.2).abs() < f64::EPSILON);
        assert!((summary.chip_temp_avg - 92.9).abs() < f64::EPSILON);
        assert!((summary.chip_temp_max - 100.3).abs() < f64::EPSILON);
        assert_eq!(summary.power_limit, 3500);
        assert_eq!(summary.up_freq_finish, 1);
        assert_eq!(summary.fan_speed_in, 4980);
        assert_eq!(summary.fan_speed_out, 5070);

        // No pools or edevs in this response
        assert!(response.msg.pools.is_none());
        assert!(response.msg.edevs.is_none());
    }

    #[test]
    fn deserialize_pools_response_fixture() {
        let response = GetMinerStatus::parse_response(POOLS_JSON.as_bytes()).unwrap();
        assert!(response.code.is_success());
        assert_eq!(response.desc, "get.miner.status");
        assert_eq!(response.when, 1692685470);

        let pools = response.msg.pools.unwrap();
        assert_eq!(pools.len(), 1);
        let pool = &pools[0];
        assert_eq!(pool.id, 1);
        assert_eq!(pool.url, "stratum+tcp://192.168.2.24:5353");
        assert_eq!(pool.status, "alive");
        assert_eq!(pool.account, "xxxxxxxx");
        assert!(pool.stratum_active);
        assert!((pool.reject_rate - 0.0).abs() < f64::EPSILON);
        assert_eq!(pool.last_share_time, 1692685469);

        // No summary or edevs in this response
        assert!(response.msg.summary.is_none());
        assert!(response.msg.edevs.is_none());
    }

    #[test]
    fn deserialize_edevs_response_fixture() {
        let response = GetMinerStatus::parse_response(EDEVS_JSON.as_bytes()).unwrap();
        assert!(response.code.is_success());
        assert_eq!(response.desc, "get.miner.status");
        assert_eq!(response.when, 1692685473);

        let edevs = response.msg.edevs.unwrap();
        assert_eq!(edevs.len(), 1);
        let edev = &edevs[0];
        assert_eq!(edev.id, 0);
        assert_eq!(edev.slot, 0);
        assert!((edev.hash_average - 33.972).abs() < f64::EPSILON);
        assert!((edev.factory_hash - 33.789).abs() < f64::EPSILON);
        assert_eq!(edev.freq, 785);
        assert_eq!(edev.effective_chips, 70);
        assert!((edev.chip_temp_min - 84.9).abs() < f64::EPSILON);
        assert!((edev.chip_temp_avg - 92.2).abs() < f64::EPSILON);
        assert!((edev.chip_temp_max - 97.4).abs() < f64::EPSILON);

        // No summary or pools in this response
        assert!(response.msg.summary.is_none());
        assert!(response.msg.pools.is_none());
    }
}