matroskin 0.1.0

whatsminer api client lib
Documentation
//! Trait for Whatsminer commands
//!
//! ## Base trait for building commands
//! - [Command]
//! ### Example
//! - example command [SetMinerFastboot]
//! ```
//! use matroskin::{command::{Command, CommandLevel}, error::Result, permission::PermissionLevel};
//! #[derive(Debug, Default)]
//! pub struct SetMinerFastboot {
//!     pub enabled: bool,
//! }
//!
//! impl Command for SetMinerFastboot {
//!     type Response = String;
//!     const CMD_NAME: &'static str = "set.miner.fastboot";
//!     const LEVEL: CommandLevel = CommandLevel::Secured;
//!     const MINIMAL_PERMISSIONS: PermissionLevel = PermissionLevel::ReadWrite;
//!     fn params(&self) -> Result<Option<impl serde::Serialize>> {
//!         Ok(Some(if self.enabled { "enable" } else { "disable" }))
//!     }
//! }
//! ```
//! ## Marks:
//! - Ready to use (hardware verified): `●`
//! - Mock verified only (unverified on physical hardware): `○`
//!
//! ## [list of commands](https://apidoc.whatsminer.com/):
//! - [x] ● [get.device.custom_data](https://apidoc.whatsminer.com/#api-Device-device_get_custom_data)
//! - [x] ● [get.device.info](https://apidoc.whatsminer.com/#api-Device-device_get_info)
//! - [x] ○ [set.device.custom_data](https://apidoc.whatsminer.com/#api-Device-device_set_custom_data)
//! - [x] ● [get.fan.setting](https://apidoc.whatsminer.com/#api-Fan-get_fan_settings)
//! - [x] ○ [set.fan.poweroff_cool](https://apidoc.whatsminer.com/#api-Fan-btminer_poweroff_cool)
//! - [x] ○ [set.fan.temp_offset](https://apidoc.whatsminer.com/#api-Fan-fan_set_temp_offset)
//! - [x] ○ [set.fan.zero_speed](https://apidoc.whatsminer.com/#api-Fan-btminer_zero_speed)
//! - [x] ● [get.log.download](https://apidoc.whatsminer.com/#api-Log-syslog_download)
//! - [x] ○ [set.log.upload](https://apidoc.whatsminer.com/#api-Log-syslog_upload)
//! - [x] ● [get.miner.history](https://apidoc.whatsminer.com/#api-Miner-btminer_get_history)
//! - [x] ● [get.miner.setting](https://apidoc.whatsminer.com/#api-Miner-btminer_get_miner_setting)
//! - [x] ● [get.miner.status](https://apidoc.whatsminer.com/#api-Miner-btminer_get_status)
//! - [x] ● [get.system.setting](https://apidoc.whatsminer.com/#api-System-btminer_get_system_setting)
//! - [x] ○ [set.miner.cointype](https://apidoc.whatsminer.com/#api-Miner-btminer_set_cointype)
//! - [x] ○ [set.miner.fast_hash](https://apidoc.whatsminer.com/#api-Miner-btminer_set_fast_hash)
//! - [x] ● [set.miner.fastboot](https://apidoc.whatsminer.com/#api-Miner-btminer_set_fastboot)
//! - [x] ○ [set.miner.heat_mode](https://apidoc.whatsminer.com/#api-Miner-btminer_set_heat_mode)
//! - [x] ○ [set.miner.pools](https://apidoc.whatsminer.com/#api-Miner-btminer_set_pools)
//! - [x] ○ [set.miner.power](https://apidoc.whatsminer.com/#api-Miner-btminer_set_power)
//! - [x] ○ [set.miner.power_limit](https://apidoc.whatsminer.com/#api-Miner-btminer_set_power_limit)
//! - [x] ○ [set.miner.power_mode](https://apidoc.whatsminer.com/#api-Miner-btminer_set_power_mode)
//! - [x] ○ [set.miner.power_percent](https://apidoc.whatsminer.com/#api-Miner-btminer_set_power_percent)
//! - [x] ○ [set.miner.report](https://apidoc.whatsminer.com/#api-Miner-btminer_set_report)
//! - [x] ○ [set.miner.restore_setting](https://apidoc.whatsminer.com/#api-Miner-btminer_set_restore_setting)
//! - [x] ○ [set.miner.service](https://apidoc.whatsminer.com/#api-Miner-btminer_set_service)
//! - [x] ○ [set.miner.target_freq](https://apidoc.whatsminer.com/#api-Miner-btminer_set_target_freq)
//! - [x] ○ [set.miner.upfreq_speed](https://apidoc.whatsminer.com/#api-Miner-btminer_set_upfreq_speed)
//! - [x] ○ [set.system.factory_reset](https://apidoc.whatsminer.com/#api-System-system_factory_reset)
//! - [x] ○ [set.system.hostname](https://apidoc.whatsminer.com/#api-System-system_set_hostname)
//! - [x] ○ [set.system.led](https://apidoc.whatsminer.com/#api-System-system_set_led)
//! - [x] ○ [set.system.net_config](https://apidoc.whatsminer.com/#api-System-system_net_config)
//! - [x] ○ [set.system.ntp_server](https://apidoc.whatsminer.com/#api-System-system_set_ntp)
//! - [x] ● [set.system.reboot](https://apidoc.whatsminer.com/#api-System-system_reboot)
//! - [x] ○ [set.system.time_randomized](https://apidoc.whatsminer.com/#api-System-system_set_time_randomized)
//! - [x] ○ [set.system.timezone](https://apidoc.whatsminer.com/#api-System-system_set_timezone)
//! - [x] ○ [set.system.update_firmware](https://apidoc.whatsminer.com/#api-System-system_update_firmware)
//! - [x] ○ [set.system.webpools](https://apidoc.whatsminer.com/#api-System-system_set_webpools)
//! - [x] ○ [set.user.change_passwd](https://apidoc.whatsminer.com/#api-User-user_set_passwd)
//! - [x] ● [set.user.permission](https://apidoc.whatsminer.com/#api-User-user_set_permission)

pub mod get_device_custom_data;
pub mod get_device_info;
pub mod get_fan_setting;
pub(crate) mod get_log_download;
pub mod get_miner_history;
pub mod get_miner_setting;
pub mod get_miner_status;
pub mod get_system_setting;
pub mod set_device_custom_data;
pub mod set_fan_poweroff_cool;
pub mod set_fan_temp_offset;
pub mod set_fan_zero_speed;
pub mod set_log_upload;
pub mod set_miner_cointype;
pub mod set_miner_fast_hash;
pub mod set_miner_fastboot;
pub mod set_miner_heat_mode;
pub mod set_miner_pools;
pub mod set_miner_power;
pub mod set_miner_power_limit;
pub mod set_miner_power_mode;
pub mod set_miner_power_percent;
pub mod set_miner_report;
pub mod set_miner_restore_setting;
pub mod set_miner_service;
pub mod set_miner_target_freq;
pub mod set_miner_upfreq_speed;
pub mod set_system_factory_reset;
pub mod set_system_hostname;
pub mod set_system_led;
pub mod set_system_net_config;
pub mod set_system_ntp_server;
pub mod set_system_reboot;
pub mod set_system_time_randomized;
pub mod set_system_timezone;
pub(crate) mod set_system_update_firmware;
pub mod set_system_webpools;
pub mod set_user_change_passwd;
pub mod set_user_permission;

#[cfg(doc)]
use crate::command::set_miner_fastboot::SetMinerFastboot;
#[cfg(doc)]
use crate::response::Response;

use crate::permission::PermissionLevel;
use crate::request::Request;
use crate::{
    auth_data::AuthData,
    error::{Error, Result},
    redacted::Redacted,
};
use core::str;

/// Access level for a WhatsMiner command.
///
/// Determines whether authentication and/or parameter encryption are required.
///
/// | Level | Auth | Encrypted params | Commands |
/// |-------|------|------------------|----------|
/// | [Public] | ✗ | ✗ | `get.*` (read-only) |
/// | [Secured] | ✓ | ✗ | `set.*` (plain) |
/// | [Encrypted] | ✓ | ✓ | `set.*` (sensitive params) |
///
/// [Public]: CommandLevel::Public
/// [Secured]: CommandLevel::Secured
/// [Encrypted]: CommandLevel::Encrypted
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum CommandLevel {
    /// No authentication required. Used for `get.*` (read-only) commands.
    Public,
    /// Authentication required, params sent in plaintext. Typical `set.*` commands.
    Secured,
    /// Authentication required, params AES-encrypted. Commands with sensitive data
    /// (e.g. pool credentials, passwords).
    Encrypted,
}

impl CommandLevel {
    /// Returns `true` if this level requires authentication (token + account).
    pub const fn requires_auth(self) -> bool {
        !matches!(self, Self::Public)
    }

    /// Returns `true` if this level requires AES parameter encryption.
    pub const fn requires_encryption(self) -> bool {
        matches!(self, Self::Encrypted)
    }
}

// ── Command trait ─────────────────────────────────────────────────────────

/// Base trait for building commands.
///
/// Commands are pure descriptors — they carry parameters and know how to
/// encode themselves, but they do not own I/O resources (readers/writers)
/// or channel endpoints. The [`Actor`](crate::actor::Actor) handles
/// dispatch and I/O.
///
/// # Example
/// ```
/// use matroskin::{command::{Command, CommandLevel}, error::Result, permission::PermissionLevel};
/// #[derive(Debug, Default)]
/// pub struct SetMinerFastboot {
///     pub enabled: bool,
/// }
///
/// impl Command for SetMinerFastboot {
///     type Response = String;
///     const CMD_NAME: &'static str = "set.miner.fastboot";
///     const LEVEL: CommandLevel = CommandLevel::Secured;
///     const MINIMAL_PERMISSIONS: PermissionLevel = PermissionLevel::ReadWrite;
///     fn params(&self) -> Result<Option<impl serde::Serialize>> {
///         Ok(Some(if self.enabled { "enable" } else { "disable" }))
///     }
/// }
/// ```
pub trait Command {
    /// Deserializable msg payload type.
    type Response: serde::de::DeserializeOwned;
    const CMD_NAME: &'static str;
    const LEVEL: CommandLevel;
    const MINIMAL_PERMISSIONS: PermissionLevel = PermissionLevel::ReadWrite;

    fn params(&self) -> Result<Option<impl serde::Serialize>>;

    fn encode(&self, auth: Option<AuthData>) -> Result<Redacted<Vec<u8>>> {
        let parameter = self.params()?;
        let bytes = match (parameter, auth) {
            (None, auth_data) => {
                let req = Request::new(Self::CMD_NAME, auth_data, None::<()>);
                serde_json::to_vec(&req)?
            }
            (Some(param), auth_data) => {
                if Self::LEVEL.requires_encryption() {
                    let Some(auth_data) = auth_data else {
                        return Err(Error::CommandShouldHaveAuthData(Self::CMD_NAME));
                    };
                    let param_json = zeroize::Zeroizing::new(serde_json::to_vec(&param)?);
                    let encrypted = auth_data.encrypt(&param_json)?;
                    let req = Request::new(Self::CMD_NAME, Some(auth_data), Some(encrypted));
                    serde_json::to_vec(&req)?
                } else {
                    let req = Request::new(Self::CMD_NAME, auth_data, Some(param));
                    serde_json::to_vec(&req)?
                }
            }
        };
        Ok(Redacted(bytes))
    }

    /// Returns `true` if this command requires authentication.
    fn is_secured(&self) -> bool {
        Self::LEVEL.requires_auth()
    }
    /// Returns `true` if this command requires parameter encryption.
    fn is_encrypted(&self) -> bool {
        Self::LEVEL.requires_encryption()
    }
    fn cmd_name(&self) -> &'static str {
        Self::CMD_NAME
    }
    /// Returns the command's access level.
    fn level(&self) -> CommandLevel {
        Self::LEVEL
    }

    fn parse_response(raw: &[u8]) -> Result<crate::response::Response<Self::Response>> {
        crate::response::parse_response(raw)
    }
}

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

    struct DummyCommand;

    impl Command for DummyCommand {
        type Response = String;
        const CMD_NAME: &'static str = "dummy.cmd";
        const LEVEL: CommandLevel = CommandLevel::Public;
        fn params(&self) -> Result<Option<impl serde::Serialize>> {
            Ok(None::<()>)
        }
    }

    #[test]
    fn parse_response_success() {
        let raw = br#"{"code":0,"when":12345,"msg":"ok","desc":"dummy.cmd"}"#;
        let resp = DummyCommand::parse_response(raw).unwrap();
        assert_eq!(resp.code, ResponseCode::Success);
        assert_eq!(resp.when, 12345);
        assert_eq!(resp.msg, "ok");
        assert_eq!(resp.desc, "dummy.cmd");
    }

    #[test]
    fn parse_response_device_error_fail() {
        let raw = br#"{"code":-1,"when":12345,"msg":"operation failed","desc":"dummy.cmd"}"#;
        let err = DummyCommand::parse_response(raw).unwrap_err();
        match err {
            Error::Device { code, msg, desc } => {
                assert_eq!(code, ResponseCode::Fail);
                assert_eq!(msg, "operation failed");
                assert_eq!(desc, "dummy.cmd");
            }
            other => panic!("expected Error::Device, got: {other:?}"),
        }
    }

    #[test]
    fn parse_response_device_error_no_permission() {
        let raw = br#"{"code":-4,"when":12345,"msg":"no permission","desc":"dummy.cmd"}"#;
        let err = DummyCommand::parse_response(raw).unwrap_err();
        match err {
            Error::Device { code, msg, desc } => {
                assert_eq!(code, ResponseCode::NoPermission);
                assert_eq!(msg, "no permission");
                assert_eq!(desc, "dummy.cmd");
            }
            other => panic!("expected Error::Device, got: {other:?}"),
        }
    }

    #[test]
    fn parse_response_invalid_utf8() {
        let raw = &[0xFF, 0xFE, 0xFD];
        let err = DummyCommand::parse_response(raw).unwrap_err();
        assert!(matches!(err, Error::InvalidUtf8));
    }

    #[test]
    fn parse_response_invalid_json() {
        let raw = b"not a json";
        let err = DummyCommand::parse_response(raw).unwrap_err();
        assert!(matches!(err, Error::Json(_)));
    }

    #[derive(Debug)]
    struct SecuredCmd(u32);
    impl Command for SecuredCmd {
        type Response = String;
        const CMD_NAME: &'static str = "set.dummy";
        const LEVEL: CommandLevel = CommandLevel::Secured;
        fn params(&self) -> Result<Option<impl serde::Serialize>> {
            Ok(Some(self.0))
        }
    }

    #[derive(Debug)]
    struct EncryptedCmd(String);
    impl Command for EncryptedCmd {
        type Response = String;
        const CMD_NAME: &'static str = "set.secret";
        const LEVEL: CommandLevel = CommandLevel::Encrypted;
        fn params(&self) -> Result<Option<impl serde::Serialize>> {
            Ok(Some(&self.0))
        }
    }

    #[test]
    fn encode_public_command() {
        let cmd = DummyCommand;
        let encoded = cmd.encode(None).unwrap();
        let v: serde_json::Value = serde_json::from_slice(&encoded.0).unwrap();
        assert_eq!(v["cmd"], "dummy.cmd");
        assert!(v.get("param").is_none());
        assert!(v.get("token").is_none());
    }

    #[test]
    fn encode_secured_command() {
        use crate::account::Account;
        use crate::password::Password;
        let auth =
            AuthData::new::<SecuredCmd>(Account::Super, &Password::Super, "dummy_salt").unwrap();
        let cmd = SecuredCmd(42);
        let encoded = cmd.encode(Some(auth)).unwrap();
        let v: serde_json::Value = serde_json::from_slice(&encoded.0).unwrap();
        assert_eq!(v["cmd"], "set.dummy");
        assert_eq!(v["param"], 42);
        assert_eq!(v["account"], "super");
        assert!(v.get("token").is_some());
    }

    #[test]
    fn encode_encrypted_command_requires_auth() {
        let cmd = EncryptedCmd("secret_payload".to_string());
        let err = cmd.encode(None).unwrap_err();
        assert!(matches!(
            err,
            Error::CommandShouldHaveAuthData("set.secret")
        ));
    }

    #[test]
    fn encode_encrypted_command_with_auth() {
        use crate::account::Account;
        use crate::password::Password;
        let auth =
            AuthData::new::<EncryptedCmd>(Account::Super, &Password::Super, "dummy_salt").unwrap();
        let cmd = EncryptedCmd("secret_payload".to_string());
        let encoded = cmd.encode(Some(auth)).unwrap();
        let v: serde_json::Value = serde_json::from_slice(&encoded.0).unwrap();
        use aes::cipher::{BlockDecryptMut, KeyInit, block_padding::Pkcs7};
        use sha2::{Digest, Sha256};
        let expected_key =
            Sha256::digest(format!("set.secretsuperdummy_salt{}", v["ts"]).as_bytes());
        let mut ciphertext = base64_light::base64_decode(v["param"].as_str().unwrap());
        let cipher = aes::Aes256::new_from_slice(&expected_key).unwrap();
        let cleartext = cipher.decrypt_padded_mut::<Pkcs7>(&mut ciphertext).unwrap();
        assert_eq!(cleartext, br#""secret_payload""#);
        assert_eq!(v["cmd"], "set.secret");
    }
}