From 2742c4332254f5b0cfcfd242e06366f22946b608 Mon Sep 17 00:00:00 2001 From: pos-ei-don <1822533+pos-ei-don@users.noreply.github.com> Date: Mon, 29 Jun 2026 21:07:06 +0000 Subject: [PATCH] feat(timezone): get/set/list miner timezone via TimezoneConfig (#295) Timezone config-model on current master: SupportsTimezoneConfig + TimezoneConfig under the config-collector; VNish 1.2.x + 1.3.x read/set the fixed UTC offset from /settings /regional/timezone (fallback whole-hour offsets); BraiinsOS reads its timezone list; python bindings + class registration. Single feature on master. --- asic-rs-core/src/config/collector.rs | 1 + asic-rs-core/src/config/mod.rs | 1 + asic-rs-core/src/config/timezone.rs | 55 ++++++++++++++ asic-rs-core/src/traits/miner.rs | 30 +++++++- .../antminer/src/backends/v2020/mod.rs | 1 + .../antminer/src/backends/v2023_07/mod.rs | 1 + .../auradine/src/backends/v1/mod.rs | 1 + .../avalonminer/src/backends/avalon_a/mod.rs | 1 + .../avalonminer/src/backends/avalon_q/mod.rs | 1 + .../bitaxe/src/backends/v2_0_0/mod.rs | 1 + .../bitaxe/src/backends/v2_9_0/mod.rs | 1 + .../braiins/src/backends/v21_09/mod.rs | 1 + .../braiins/src/backends/v25_03/mod.rs | 1 + .../braiins/src/backends/v25_05/mod.rs | 1 + .../braiins/src/backends/v25_07/mod.rs | 1 + .../braiins/src/backends/v26_04/mod.rs | 71 ++++++++++++++++++- asic-rs-firmwares/epic/src/backends/v1/mod.rs | 1 + .../futurebit/src/backends/v2/mod.rs | 1 + .../luxminer/src/backends/v1/mod.rs | 1 + .../marathon/src/backends/v1/mod.rs | 1 + .../nerdaxe/src/backends/v1/mod.rs | 1 + .../proto/src/backends/v1/mod.rs | 1 + .../sealminer/src/backends/v2025/mod.rs | 1 + .../vnish/src/backends/v1_2_0/mod.rs | 59 +++++++++++++++ .../vnish/src/backends/v1_3_0/mod.rs | 60 ++++++++++++++++ .../volcminer/src/backends/v1/mod.rs | 1 + .../whatsminer/src/backends/v1/mod.rs | 1 + .../whatsminer/src/backends/v2/mod.rs | 1 + .../whatsminer/src/backends/v3/mod.rs | 1 + python/pyasic_rs/__init__.py | 2 + python/pyasic_rs/asic_rs.pyi | 22 ++++++ python/pyasic_rs/config.py | 2 + src/python/miner.rs | 31 +++++++- src/python/mod.rs | 1 + 34 files changed, 353 insertions(+), 4 deletions(-) create mode 100644 asic-rs-core/src/config/timezone.rs diff --git a/asic-rs-core/src/config/collector.rs b/asic-rs-core/src/config/collector.rs index 88aa4a53..d7189487 100644 --- a/asic-rs-core/src/config/collector.rs +++ b/asic-rs-core/src/config/collector.rs @@ -17,6 +17,7 @@ pub enum ConfigField { Tuning, Fan, Temperature, + Timezone, } /// A function pointer type that takes a JSON `Value` and an optional key, diff --git a/asic-rs-core/src/config/mod.rs b/asic-rs-core/src/config/mod.rs index 3a84beaa..300ae374 100644 --- a/asic-rs-core/src/config/mod.rs +++ b/asic-rs-core/src/config/mod.rs @@ -3,4 +3,5 @@ pub mod fan; pub mod pools; pub mod scaling; pub mod temperature; +pub mod timezone; pub mod tuning; diff --git a/asic-rs-core/src/config/timezone.rs b/asic-rs-core/src/config/timezone.rs new file mode 100644 index 00000000..08997ff0 --- /dev/null +++ b/asic-rs-core/src/config/timezone.rs @@ -0,0 +1,55 @@ +#[cfg(feature = "python")] +use pyo3::prelude::*; +use serde::{Deserialize, Serialize}; + +#[cfg_attr( + feature = "python", + pyclass(skip_from_py_object, get_all, module = "asic_rs") +)] +#[cfg_attr(feature = "python", asic_rs_pydantic::py_pydantic_model)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +/// Timezone configuration. +pub struct TimezoneConfig { + /// The configured timezone (a named IANA zone on BraiinsOS, or a fixed UTC offset like "GMT+1" on VNish). + pub timezone: Option, + /// The timezones the miner accepts (named zones where available; may be a default fallback list). + pub available: Vec, +} + +#[cfg(feature = "python")] +#[pymethods] +impl TimezoneConfig { + #[new] + #[pyo3(signature = (timezone = None, available = None))] + fn py_new(timezone: Option, available: Option>) -> Self { + Self { + timezone, + available: available.unwrap_or_default(), + } + } +} + +#[cfg(feature = "python")] +mod python_impls { + use asic_rs_pydantic::get_optional_field; + use pyo3::{Borrowed, PyAny, PyErr, PyResult, conversion::FromPyObject, types::PyAnyMethods}; + + use super::TimezoneConfig; + + impl FromPyObject<'_, '_> for TimezoneConfig { + type Error = PyErr; + + fn extract(obj: Borrowed<'_, '_, PyAny>) -> PyResult { + Ok(TimezoneConfig { + timezone: get_optional_field(&obj, "timezone")? + .map(|value| value.extract()) + .transpose()? + .flatten(), + available: get_optional_field(&obj, "available")? + .map(|value| value.extract()) + .transpose()? + .unwrap_or_default(), + }) + } + } +} diff --git a/asic-rs-core/src/traits/miner.rs b/asic-rs-core/src/traits/miner.rs index 5cf340a0..57f32134 100644 --- a/asic-rs-core/src/traits/miner.rs +++ b/asic-rs-core/src/traits/miner.rs @@ -16,6 +16,7 @@ use crate::{ pools::PoolGroupConfig, scaling::ScalingConfig, temperature::TemperatureConfig, + timezone::TimezoneConfig, tuning::TuningConfig, }, data::{ @@ -87,6 +88,7 @@ pub trait SupportsConfigs: + SupportsTemperatureConfig + SupportsTuningConfig + SupportsFanConfig + + SupportsTimezoneConfig { } @@ -96,7 +98,8 @@ impl< + SupportsScalingConfig + SupportsTemperatureConfig + SupportsTuningConfig - + SupportsFanConfig, + + SupportsFanConfig + + SupportsTimezoneConfig, > SupportsConfigs for T { } @@ -950,6 +953,31 @@ pub trait SupportsTemperatureConfig: CollectConfigs { } } +#[async_trait] +pub trait SupportsTimezoneConfig: CollectConfigs { + #[allow(unused_variables)] + async fn set_timezone_config(&self, config: TimezoneConfig) -> anyhow::Result { + anyhow::bail!("Setting timezone config is not supported on this platform"); + } + #[tracing::instrument(level = "debug")] + async fn get_timezone_config(&self) -> anyhow::Result { + let mut collector = self.get_config_collector(); + let data = collector.collect(&[ConfigField::Timezone]).await; + self.parse_timezone_config(&data) + } + #[allow(unused_variables)] + fn parse_timezone_config( + &self, + data: &HashMap, + ) -> anyhow::Result { + anyhow::bail!("Getting timezone config is not supported on this platform"); + } + + fn supports_timezone_config(&self) -> bool { + false + } +} + #[async_trait] pub trait SupportsTuningConfig: CollectConfigs { #[allow(unused_variables)] diff --git a/asic-rs-firmwares/antminer/src/backends/v2020/mod.rs b/asic-rs-firmwares/antminer/src/backends/v2020/mod.rs index 1f6006da..d834fc6c 100644 --- a/asic-rs-firmwares/antminer/src/backends/v2020/mod.rs +++ b/asic-rs-firmwares/antminer/src/backends/v2020/mod.rs @@ -890,6 +890,7 @@ impl GetFluidTemperature for AntMinerV2020 { impl GetPsuFans for AntMinerV2020 {} impl GetTuningCapabilities for AntMinerV2020 {} +impl SupportsTimezoneConfig for AntMinerV2020 {} impl GetMessages for AntMinerV2020 { fn parse_messages(&self, data: &HashMap) -> Vec { 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 89a3e46e..dabc286a 100644 --- a/asic-rs-firmwares/antminer/src/backends/v2023_07/mod.rs +++ b/asic-rs-firmwares/antminer/src/backends/v2023_07/mod.rs @@ -793,6 +793,7 @@ impl GetFluidTemperature for AntMinerV202307 { impl GetPsuFans for AntMinerV202307 {} impl GetTuningCapabilities for AntMinerV202307 {} +impl SupportsTimezoneConfig for AntMinerV202307 {} impl GetMessages for AntMinerV202307 { fn parse_messages(&self, data: &HashMap) -> Vec { diff --git a/asic-rs-firmwares/auradine/src/backends/v1/mod.rs b/asic-rs-firmwares/auradine/src/backends/v1/mod.rs index cc048026..cf0c4bb4 100644 --- a/asic-rs-firmwares/auradine/src/backends/v1/mod.rs +++ b/asic-rs-firmwares/auradine/src/backends/v1/mod.rs @@ -1058,6 +1058,7 @@ impl GetPsuFans for AuradineV1 { } impl GetFluidTemperature for AuradineV1 {} +impl SupportsTimezoneConfig for AuradineV1 {} impl GetWattage for AuradineV1 { fn parse_wattage(&self, data: &HashMap) -> Option { 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 412d48c3..8673bed2 100644 --- a/asic-rs-firmwares/avalonminer/src/backends/avalon_a/mod.rs +++ b/asic-rs-firmwares/avalonminer/src/backends/avalon_a/mod.rs @@ -469,6 +469,7 @@ impl GetControlBoardVersion for AvalonAMiner { } impl GetHostname for AvalonAMiner {} +impl SupportsTimezoneConfig for AvalonAMiner {} impl GetApiVersion for AvalonAMiner { fn parse_api_version(&self, data: &HashMap) -> Option { 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 4d3f853e..85995207 100644 --- a/asic-rs-firmwares/avalonminer/src/backends/avalon_q/mod.rs +++ b/asic-rs-firmwares/avalonminer/src/backends/avalon_q/mod.rs @@ -443,6 +443,7 @@ impl GetMAC for AvalonQMiner { } impl GetSerialNumber for AvalonQMiner {} +impl SupportsTimezoneConfig for AvalonQMiner {} impl GetHostname for AvalonQMiner {} 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 24214f95..25318d3d 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 @@ -565,6 +565,7 @@ impl UpgradeFirmware for Bitaxe200 { } impl HasAuth for Bitaxe200 {} +impl SupportsTimezoneConfig for Bitaxe200 {} impl HasDefaultAuth for Bitaxe200 {} #[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 75c130aa..bd6ef0e0 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 @@ -549,6 +549,7 @@ impl UpgradeFirmware for Bitaxe290 { } impl HasAuth for Bitaxe290 {} +impl SupportsTimezoneConfig for Bitaxe290 {} impl HasDefaultAuth for Bitaxe290 {} #[async_trait] 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 c0326bde..64901db1 100644 --- a/asic-rs-firmwares/braiins/src/backends/v21_09/mod.rs +++ b/asic-rs-firmwares/braiins/src/backends/v21_09/mod.rs @@ -429,6 +429,7 @@ impl GetTuningCapabilities for BraiinsV2109 { Some(power_target_capabilities(power_target)) } } +impl SupportsTimezoneConfig for BraiinsV2109 {} impl GetHashboards for BraiinsV2109 { fn parse_hashboards(&self, data: &HashMap) -> Vec { 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 19abc5c8..117aaff6 100644 --- a/asic-rs-firmwares/braiins/src/backends/v25_03/mod.rs +++ b/asic-rs-firmwares/braiins/src/backends/v25_03/mod.rs @@ -424,6 +424,7 @@ impl GetTuningCapabilities for BraiinsV2503 { Some(power_target_capabilities(power_target)) } } +impl SupportsTimezoneConfig for BraiinsV2503 {} impl GetHashboards for BraiinsV2503 { fn parse_hashboards(&self, data: &HashMap) -> Vec { 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 76a4d94d..33e3295e 100644 --- a/asic-rs-firmwares/braiins/src/backends/v25_05/mod.rs +++ b/asic-rs-firmwares/braiins/src/backends/v25_05/mod.rs @@ -425,6 +425,7 @@ impl GetTuningCapabilities for BraiinsV2505 { Some(power_target_capabilities(power_target)) } } +impl SupportsTimezoneConfig for BraiinsV2505 {} impl GetHashboards for BraiinsV2505 { fn parse_hashboards(&self, data: &HashMap) -> Vec { 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 72fd4cac..4031c173 100644 --- a/asic-rs-firmwares/braiins/src/backends/v25_07/mod.rs +++ b/asic-rs-firmwares/braiins/src/backends/v25_07/mod.rs @@ -635,6 +635,7 @@ impl GetTuningCapabilities for BraiinsV2507 { Some(tuner_constraints_capabilities(tuner)) } } +impl SupportsTimezoneConfig for BraiinsV2507 {} impl GetPsuFans for BraiinsV2507 {} 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 90d06df2..eafc22f7 100644 --- a/asic-rs-firmwares/braiins/src/backends/v26_04/mod.rs +++ b/asic-rs-firmwares/braiins/src/backends/v26_04/mod.rs @@ -3,8 +3,9 @@ use std::{collections::HashMap, net::IpAddr, str::FromStr, time::Duration}; use anyhow; use asic_rs_core::{ config::{ - collector::{ConfigCollector, ConfigField, ConfigLocation}, + collector::{ConfigCollector, ConfigExtractor, ConfigField, ConfigLocation}, pools::PoolGroupConfig, + timezone::TimezoneConfig, }, data::{ board::{BoardData, MinerControlBoard}, @@ -79,7 +80,20 @@ impl APIClient for BraiinsV2604 { impl GetConfigsLocations for BraiinsV2604 { #[allow(unused_variables)] fn get_configs_locations(&self, data_field: ConfigField) -> Vec { - vec![] + const GQL_TIMEZONE: MinerCommand = MinerCommand::GraphQL { + command: "{ bos { timezone { id } timezoneList { id } } }", + }; + match data_field { + ConfigField::Timezone => vec![( + GQL_TIMEZONE, + ConfigExtractor { + func: get_by_pointer, + key: Some("/bos"), + tag: None, + }, + )], + _ => vec![], + } } } @@ -634,6 +648,59 @@ impl GetTuningCapabilities for BraiinsV2604 { } } +#[async_trait] +impl SupportsTimezoneConfig for BraiinsV2604 { + fn supports_timezone_config(&self) -> bool { + true + } + + fn parse_timezone_config( + &self, + data: &HashMap, + ) -> anyhow::Result { + let obj = data + .get(&ConfigField::Timezone) + .ok_or_else(|| anyhow::anyhow!("No timezone data returned"))?; + let timezone = obj + .pointer("/timezone/id") + .and_then(|v| v.as_str()) + .map(String::from); + let available = obj + .pointer("/timezoneList") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|t| t.get("id").and_then(|v| v.as_str()).map(String::from)) + .collect() + }) + .unwrap_or_default(); + Ok(TimezoneConfig { + timezone, + available, + }) + } + + async fn set_timezone_config(&self, config: TimezoneConfig) -> anyhow::Result { + let timezone = match config.timezone { + Some(tz) => tz, + None => anyhow::bail!("Timezone config has no timezone to set"), + }; + let mutation = r#"mutation ($tz: String!) { + bos { setTimezone(timezone: $tz) { __typename } } + }"#; + let variables = json!({ "tz": timezone }); + let result = self + .graphql + .send_graphql_command(mutation, true, Some(variables)) + .await?; + // BosResult is a union; a `BosError` variant signals failure. + let typename = result + .pointer("/bos/setTimezone/__typename") + .and_then(|v| v.as_str()); + Ok(matches!(typename, Some(t) if t != "BosError")) + } +} + impl GetPsuFans for BraiinsV2604 {} impl GetMessages for BraiinsV2604 { diff --git a/asic-rs-firmwares/epic/src/backends/v1/mod.rs b/asic-rs-firmwares/epic/src/backends/v1/mod.rs index 8abe9bb1..ec6eca33 100644 --- a/asic-rs-firmwares/epic/src/backends/v1/mod.rs +++ b/asic-rs-firmwares/epic/src/backends/v1/mod.rs @@ -837,6 +837,7 @@ impl GetFans for PowerPlayV1 { impl GetPsuFans for PowerPlayV1 {} impl GetTuningCapabilities for PowerPlayV1 {} +impl SupportsTimezoneConfig for PowerPlayV1 {} impl GetFluidTemperature for PowerPlayV1 {} diff --git a/asic-rs-firmwares/futurebit/src/backends/v2/mod.rs b/asic-rs-firmwares/futurebit/src/backends/v2/mod.rs index 9c65122b..2ec5a5a9 100644 --- a/asic-rs-firmwares/futurebit/src/backends/v2/mod.rs +++ b/asic-rs-firmwares/futurebit/src/backends/v2/mod.rs @@ -588,6 +588,7 @@ impl GetFans for ApolloV2 { } impl GetPsuFans for ApolloV2 {} +impl SupportsTimezoneConfig for ApolloV2 {} impl GetFluidTemperature for ApolloV2 {} diff --git a/asic-rs-firmwares/luxminer/src/backends/v1/mod.rs b/asic-rs-firmwares/luxminer/src/backends/v1/mod.rs index 17998501..e97f2605 100644 --- a/asic-rs-firmwares/luxminer/src/backends/v1/mod.rs +++ b/asic-rs-firmwares/luxminer/src/backends/v1/mod.rs @@ -985,6 +985,7 @@ impl GetTuningTarget for LuxMinerV1 { impl GetScaledTuningTarget for LuxMinerV1 {} impl GetTuningCapabilities for LuxMinerV1 {} +impl SupportsTimezoneConfig for LuxMinerV1 {} impl GetPsuFans for LuxMinerV1 {} impl GetMessages for LuxMinerV1 { diff --git a/asic-rs-firmwares/marathon/src/backends/v1/mod.rs b/asic-rs-firmwares/marathon/src/backends/v1/mod.rs index 3ecdcfb1..f6d4fd95 100644 --- a/asic-rs-firmwares/marathon/src/backends/v1/mod.rs +++ b/asic-rs-firmwares/marathon/src/backends/v1/mod.rs @@ -616,6 +616,7 @@ impl GetMAC for MaraV1 { } impl GetSerialNumber for MaraV1 {} +impl SupportsTimezoneConfig for MaraV1 {} impl GetHostname for MaraV1 { fn parse_hostname(&self, data: &HashMap) -> Option { diff --git a/asic-rs-firmwares/nerdaxe/src/backends/v1/mod.rs b/asic-rs-firmwares/nerdaxe/src/backends/v1/mod.rs index 36fdae10..ec840bdc 100644 --- a/asic-rs-firmwares/nerdaxe/src/backends/v1/mod.rs +++ b/asic-rs-firmwares/nerdaxe/src/backends/v1/mod.rs @@ -223,6 +223,7 @@ impl GetMAC for NerdAxeV1 { } impl GetSerialNumber for NerdAxeV1 {} +impl SupportsTimezoneConfig for NerdAxeV1 {} impl GetHostname for NerdAxeV1 { fn parse_hostname(&self, data: &HashMap) -> Option { data.extract::(DataField::Hostname) diff --git a/asic-rs-firmwares/proto/src/backends/v1/mod.rs b/asic-rs-firmwares/proto/src/backends/v1/mod.rs index e00a579e..e852b8c8 100644 --- a/asic-rs-firmwares/proto/src/backends/v1/mod.rs +++ b/asic-rs-firmwares/proto/src/backends/v1/mod.rs @@ -607,6 +607,7 @@ impl GetFans for ProtoV1 { } impl GetPsuFans for ProtoV1 {} +impl SupportsTimezoneConfig for ProtoV1 {} impl GetFluidTemperature for ProtoV1 {} impl GetWattage for ProtoV1 { diff --git a/asic-rs-firmwares/sealminer/src/backends/v2025/mod.rs b/asic-rs-firmwares/sealminer/src/backends/v2025/mod.rs index b6d654dc..20eb80dc 100644 --- a/asic-rs-firmwares/sealminer/src/backends/v2025/mod.rs +++ b/asic-rs-firmwares/sealminer/src/backends/v2025/mod.rs @@ -354,6 +354,7 @@ impl GetSerialNumber for SealMinerV2025 { } impl GetHostname for SealMinerV2025 {} +impl SupportsTimezoneConfig for SealMinerV2025 {} impl GetApiVersion for SealMinerV2025 { fn parse_api_version(&self, data: &HashMap) -> Option { 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 1d77039a..2ac0551b 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 @@ -6,6 +6,7 @@ use asic_rs_core::{ collector::{ConfigCollector, ConfigExtractor, ConfigField, ConfigLocation}, pools::PoolGroupConfig, temperature::TemperatureConfig, + timezone::TimezoneConfig, }, data::{ board::{BoardData, ChipData, MinerControlBoard}, @@ -66,6 +67,10 @@ impl GetConfigsLocations for VnishV120 { command: "summary", parameters: None, }; + const WEB_SETTINGS: MinerCommand = MinerCommand::WebAPI { + command: "settings", + parameters: None, + }; match data_field { ConfigField::Temperature => vec![( WEB_SUMMARY, @@ -75,6 +80,14 @@ impl GetConfigsLocations for VnishV120 { tag: None, }, )], + ConfigField::Timezone => vec![( + WEB_SETTINGS, + ConfigExtractor { + func: get_by_pointer, + key: Some("/regional/timezone"), + tag: None, + }, + )], _ => vec![], } } @@ -565,6 +578,52 @@ impl GetFans for VnishV120 { } impl GetPsuFans for VnishV120 {} +#[async_trait] +impl SupportsTimezoneConfig for VnishV120 { + fn supports_timezone_config(&self) -> bool { + true + } + + /// VNish stores a fixed UTC offset (e.g. `"GMT+2"`) — it does not track DST, + /// and it has no list endpoint, so we fall back to the whole-hour offsets. + fn parse_timezone_config( + &self, + data: &HashMap, + ) -> anyhow::Result { + const DEFAULT_OFFSETS: &[&str] = &[ + "GMT-12", "GMT-11", "GMT-10", "GMT-9", "GMT-8", "GMT-7", "GMT-6", "GMT-5", "GMT-4", + "GMT-3", "GMT-2", "GMT-1", "GMT+0", "GMT+1", "GMT+2", "GMT+3", "GMT+4", "GMT+5", + "GMT+6", "GMT+7", "GMT+8", "GMT+9", "GMT+10", "GMT+11", "GMT+12", "GMT+13", "GMT+14", + ]; + let obj = data + .get(&ConfigField::Timezone) + .ok_or_else(|| anyhow::anyhow!("No timezone data returned"))?; + let timezone = obj + .pointer("/current") + .and_then(|v| v.as_str()) + .map(String::from); + Ok(TimezoneConfig { + timezone, + available: DEFAULT_OFFSETS.iter().map(|s| s.to_string()).collect(), + }) + } + + /// Set the fixed UTC offset (read-modify-write the settings object). + async fn set_timezone_config(&self, config: TimezoneConfig) -> anyhow::Result { + let timezone = match config.timezone { + Some(tz) => tz, + None => anyhow::bail!("Timezone config has no timezone to set"), + }; + let mut settings = self.web.settings().await?; + match settings.pointer_mut("/regional/timezone") { + Some(tz) => { + tz["current"] = json!(timezone); + } + None => anyhow::bail!("VNish settings has no /regional/timezone"), + } + self.web.set_settings(settings).await.map(|_| true) + } +} impl GetFluidTemperature for VnishV120 { fn parse_fluid_temperature(&self, data: &HashMap) -> Option { 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 c6973040..bd0511a2 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 @@ -6,6 +6,7 @@ use asic_rs_core::{ collector::{ConfigCollector, ConfigExtractor, ConfigField, ConfigLocation}, pools::PoolGroupConfig, temperature::TemperatureConfig, + timezone::TimezoneConfig, }, data::{ board::{BoardData, ChipData, MinerControlBoard}, @@ -66,6 +67,10 @@ impl GetConfigsLocations for VnishV130 { command: "summary", parameters: None, }; + const WEB_SETTINGS: MinerCommand = MinerCommand::WebAPI { + command: "settings", + parameters: None, + }; match data_field { ConfigField::Temperature => vec![( WEB_SUMMARY, @@ -75,6 +80,14 @@ impl GetConfigsLocations for VnishV130 { tag: None, }, )], + ConfigField::Timezone => vec![( + WEB_SETTINGS, + ConfigExtractor { + func: get_by_pointer, + key: Some("/regional/timezone"), + tag: None, + }, + )], _ => vec![], } } @@ -1019,6 +1032,53 @@ impl SupportsTemperatureConfig for VnishV130 { } } +#[async_trait] +impl SupportsTimezoneConfig for VnishV130 { + fn supports_timezone_config(&self) -> bool { + true + } + + /// VNish 1.3.x stores a fixed UTC offset (e.g. `"GMT+2"`) under + /// `/regional/timezone` in `/settings` — same shape as 1.2.x. No DST, no list + /// endpoint, so fall back to the whole-hour offsets. + fn parse_timezone_config( + &self, + data: &HashMap, + ) -> anyhow::Result { + const DEFAULT_OFFSETS: &[&str] = &[ + "GMT-12", "GMT-11", "GMT-10", "GMT-9", "GMT-8", "GMT-7", "GMT-6", "GMT-5", "GMT-4", + "GMT-3", "GMT-2", "GMT-1", "GMT+0", "GMT+1", "GMT+2", "GMT+3", "GMT+4", "GMT+5", + "GMT+6", "GMT+7", "GMT+8", "GMT+9", "GMT+10", "GMT+11", "GMT+12", "GMT+13", "GMT+14", + ]; + let obj = data + .get(&ConfigField::Timezone) + .ok_or_else(|| anyhow::anyhow!("No timezone data returned"))?; + let timezone = obj + .pointer("/current") + .and_then(|v| v.as_str()) + .map(String::from); + Ok(TimezoneConfig { + timezone, + available: DEFAULT_OFFSETS.iter().map(|s| s.to_string()).collect(), + }) + } + + async fn set_timezone_config(&self, config: TimezoneConfig) -> anyhow::Result { + let timezone = match config.timezone { + Some(tz) => tz, + None => anyhow::bail!("Timezone config has no timezone to set"), + }; + let mut settings = self.web.settings().await?; + match settings.pointer_mut("/regional/timezone") { + Some(tz) => { + tz["current"] = json!(timezone); + } + None => anyhow::bail!("VNish settings has no /regional/timezone"), + } + self.web.set_settings(settings).await.map(|_| true) + } +} + #[async_trait] impl UpgradeFirmware for VnishV130 { fn supports_upgrade_firmware(&self) -> bool { diff --git a/asic-rs-firmwares/volcminer/src/backends/v1/mod.rs b/asic-rs-firmwares/volcminer/src/backends/v1/mod.rs index 260e09d5..515cb96f 100644 --- a/asic-rs-firmwares/volcminer/src/backends/v1/mod.rs +++ b/asic-rs-firmwares/volcminer/src/backends/v1/mod.rs @@ -813,6 +813,7 @@ impl SupportsTuningConfig for VolcMinerV1 {} impl SupportsFanConfig for VolcMinerV1 {} impl SupportsTemperatureConfig for VolcMinerV1 {} +impl SupportsTimezoneConfig for VolcMinerV1 {} impl GetTuningPercent for VolcMinerV1 {} impl SetTuningPercent for VolcMinerV1 {} diff --git a/asic-rs-firmwares/whatsminer/src/backends/v1/mod.rs b/asic-rs-firmwares/whatsminer/src/backends/v1/mod.rs index 4cb1903b..1a85d788 100644 --- a/asic-rs-firmwares/whatsminer/src/backends/v1/mod.rs +++ b/asic-rs-firmwares/whatsminer/src/backends/v1/mod.rs @@ -264,6 +264,7 @@ impl GetMAC for WhatsMinerV1 { } impl GetSerialNumber for WhatsMinerV1 {} +impl SupportsTimezoneConfig for WhatsMinerV1 {} impl GetHostname for WhatsMinerV1 {} impl GetApiVersion for WhatsMinerV1 { fn parse_api_version(&self, data: &HashMap) -> Option { diff --git a/asic-rs-firmwares/whatsminer/src/backends/v2/mod.rs b/asic-rs-firmwares/whatsminer/src/backends/v2/mod.rs index b8cb4e12..eee741a1 100644 --- a/asic-rs-firmwares/whatsminer/src/backends/v2/mod.rs +++ b/asic-rs-firmwares/whatsminer/src/backends/v2/mod.rs @@ -306,6 +306,7 @@ impl GetMAC for WhatsMinerV2 { } impl GetSerialNumber for WhatsMinerV2 {} +impl SupportsTimezoneConfig for WhatsMinerV2 {} impl GetHostname for WhatsMinerV2 { fn parse_hostname(&self, data: &HashMap) -> Option { data.extract::(DataField::Hostname) diff --git a/asic-rs-firmwares/whatsminer/src/backends/v3/mod.rs b/asic-rs-firmwares/whatsminer/src/backends/v3/mod.rs index a0326a57..4a218ae1 100644 --- a/asic-rs-firmwares/whatsminer/src/backends/v3/mod.rs +++ b/asic-rs-firmwares/whatsminer/src/backends/v3/mod.rs @@ -346,6 +346,7 @@ impl GetMAC for WhatsMinerV3 { } impl GetSerialNumber for WhatsMinerV3 {} +impl SupportsTimezoneConfig for WhatsMinerV3 {} impl GetHostname for WhatsMinerV3 { fn parse_hostname(&self, data: &HashMap) -> Option { data.extract::(DataField::Hostname) diff --git a/python/pyasic_rs/__init__.py b/python/pyasic_rs/__init__.py index b6a886ba..9e7d8479 100644 --- a/python/pyasic_rs/__init__.py +++ b/python/pyasic_rs/__init__.py @@ -12,6 +12,7 @@ Pool, PoolGroup, ScalingConfig, + TimezoneConfig, TuningConfig, ) from .factory import MinerFactory @@ -26,6 +27,7 @@ "Pool", "PoolGroup", "ScalingConfig", + "TimezoneConfig", "TuningConfig", "TuningTarget", ] diff --git a/python/pyasic_rs/asic_rs.pyi b/python/pyasic_rs/asic_rs.pyi index b4a6229b..b1543f94 100644 --- a/python/pyasic_rs/asic_rs.pyi +++ b/python/pyasic_rs/asic_rs.pyi @@ -384,6 +384,7 @@ class Miner: def get_scaling_config(self, /) -> Awaitable[ScalingConfig |None]: ... def get_serial_number(self, /) -> Awaitable[str |None]: ... def get_temperature_config(self, /) -> Awaitable[TemperatureConfig |None]: ... + def get_timezone_config(self, /) -> Awaitable[TimezoneConfig |None]: ... def get_tuning_config(self, /) -> Awaitable[TuningConfig |None]: ... def get_tuning_target(self, /) -> Awaitable[TuningTarget |None]: ... def get_tuning_percent(self, /) -> Awaitable[int |None]: ... @@ -409,6 +410,7 @@ class Miner: def set_tuning_percent(self, /, percent: int) -> Awaitable[bool |None]: ... def set_scaling_config(self, /, config: "ScalingConfig") -> Awaitable[bool |None]: ... def set_token(self, /, token: str) -> None: ... + def set_timezone_config(self, /, config: "TimezoneConfig") -> Awaitable[bool |None]: ... def set_tuning_config(self, /, config: "TuningConfig", scaling_config: "ScalingConfig | None" = None) -> Awaitable[bool |None]: ... @property def supports_change_password(self, /) -> bool: ... @@ -436,6 +438,7 @@ class Miner: def supports_set_tuning_percent(self, /) -> bool: ... @property def supports_temperature_config(self, /) -> bool: ... + def supports_timezone_config(self, /) -> bool: ... @property def supports_tuning_config(self, /) -> bool: ... @property @@ -903,6 +906,25 @@ class TemperatureConfig: @classmethod def model_validate(cls, /, obj: "object", **_kwargs: "object") -> "TemperatureConfig": ... +class TimezoneConfig: + @classmethod + def __get_pydantic_core_schema__(cls, /, _source_type: "object", _handler: "object") -> "object": ... + def __new__(cls, /, timezone: str |None = None, available: list[str] |None = None) -> TimezoneConfig: ... + def __repr__(self, /) -> str: ... + @staticmethod + def _pydantic_serialize(value: "TimezoneConfig") -> "dict[str, object]": ... + @classmethod + def _pydantic_validate(cls, /, value: "object") -> "TimezoneConfig": ... + @property + def available(self, /) -> list[str]: ... + def model_dump(self, /, **_kwargs: "object") -> "dict[str, object]": ... + @classmethod + def model_json_schema(cls, /, **kwargs: "object") -> "dict[str, object]": ... + @classmethod + def model_validate(cls, /, obj: "object", **_kwargs: "object") -> "TimezoneConfig": ... + @property + def timezone(self, /) -> str |None: ... + @final class TuningCapabilities: @classmethod diff --git a/python/pyasic_rs/config.py b/python/pyasic_rs/config.py index cc1dbaf4..441dfd34 100644 --- a/python/pyasic_rs/config.py +++ b/python/pyasic_rs/config.py @@ -7,6 +7,7 @@ from pyasic_rs.asic_rs import FanConfig, FanMode from pyasic_rs.asic_rs import Pool, PoolGroup from pyasic_rs.asic_rs import ScalingConfig +from pyasic_rs.asic_rs import TimezoneConfig from pyasic_rs.asic_rs import TuningConfig from pyasic_rs.asic_rs import TemperatureConfig @@ -16,6 +17,7 @@ "Pool", "PoolGroup", "ScalingConfig", + "TimezoneConfig", "TuningConfig", "TemperatureConfig", ] diff --git a/src/python/miner.rs b/src/python/miner.rs index c08d484a..303e8ce0 100644 --- a/src/python/miner.rs +++ b/src/python/miner.rs @@ -5,7 +5,7 @@ use asic_rs_core::data::collector::DataField; use asic_rs_core::{ config::{ fan::FanConfig, pools::PoolGroupConfig as PoolGroup, scaling::ScalingConfig, - temperature::TemperatureConfig, tuning::TuningConfig, + temperature::TemperatureConfig, timezone::TimezoneConfig, tuning::TuningConfig, }, data::{ board::BoardData, @@ -223,6 +223,11 @@ impl Miner { fn supports_upgrade_firmware(&self, py: Python<'_>) -> bool { self.with_miner(py, |miner| miner.supports_upgrade_firmware()) } + /// Whether this miner supports reading and writing timezone configuration. + #[getter] + fn supports_timezone_config(&self, py: Python<'_>) -> bool { + self.with_miner(py, |miner| miner.supports_timezone_config()) + } /// Whether this miner supports scaling configuration. #[getter] fn supports_scaling_config(&self, py: Python<'_>) -> bool { @@ -667,6 +672,30 @@ impl Miner { Ok(inner.set_tuning_percent(percent).await.ok()) }) } + /// Await timezone configuration, or `None` when unsupported/unavailable. + pub fn get_timezone_config<'a>( + &self, + py: Python<'a>, + ) -> PyResult>> { + let inner = Arc::clone(&self.inner); + future_into_py(py, async move { + let inner = inner.read().await; + Ok(inner.get_timezone_config().await.ok()) + }) + } + /// Set timezone configuration. + #[pyo3(signature = (config: "TimezoneConfig"))] + pub fn set_timezone_config<'a>( + &self, + py: Python<'a>, + config: TimezoneConfig, + ) -> PyResult>> { + let inner = Arc::clone(&self.inner); + future_into_py(py, async move { + let inner = inner.read().await; + Ok(inner.set_timezone_config(config).await.ok()) + }) + } /// Replace the configured mining pool groups. #[pyo3(signature = (groups: "list[PoolGroup]"))] pub fn set_pools_config<'a>( diff --git a/src/python/mod.rs b/src/python/mod.rs index 6fc876c5..2f20a2e4 100644 --- a/src/python/mod.rs +++ b/src/python/mod.rs @@ -33,6 +33,7 @@ mod asic_rs { pools::{PoolConfig as Pool, PoolGroupConfig as PoolGroup}, scaling::ScalingConfig, temperature::TemperatureConfig, + timezone::TimezoneConfig, tuning::TuningConfig, }; #[pymodule_export]