Skip to content

feat(timezone): get / set / list miner timezone - #295

Open
pos-ei-don wants to merge 1 commit into
256foundation:masterfrom
pos-ei-don:feat-set-timezone-upstream
Open

feat(timezone): get / set / list miner timezone#295
pos-ei-don wants to merge 1 commit into
256foundation:masterfrom
pos-ei-don:feat-set-timezone-upstream

Conversation

@pos-ei-don

@pos-ei-don pos-ei-don commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Why I need this: on VNish the timezone is a fixed UTC offset with no DST handling, so it has to be switched by hand twice a year (summer/winter) — and that gets forgotten, leaving the miner's clock (and its logs/timestamps) an hour off for months. Keeping a device's clock correct across DST is exactly the kind of thing a controller like Home Assistant should own, so I want to be able to read and set the timezone from there instead of remembering to do it on each miner.

This adds a SetTimezone capability (get / set / list + supports) so a consumer can do that:

  • New SetTimezone trait (default: unsupported), added to the Miner supertrait + blanket impl; all backends implement it (trivial except the two below).
  • BraiinsOS 26.04: named zones via bos.timezone / timezoneList / setTimezone — once a named zone is set, BOS handles DST itself.
  • VNish: a fixed UTC offset via /settings regional.timezone (read-modify-write); since VNish has no DST awareness, the consumer (re)applies the correct offset at each DST change.
  • Python: get_timezone / list_timezones / set_timezone + supports_set_timezone.

CI-green; live write-test pending on my fleet. Happy to shape the API (e.g. a typed timezone, or naming) to your preference.

Comment thread asic-rs-core/src/traits/miner.rs Outdated
Comment thread asic-rs-core/src/traits/miner.rs Outdated
@pos-ei-don

Copy link
Copy Markdown
Contributor Author

Re the list (your follow-up): agreed — I'll use the miner's own list where it has one and fall back to a built-in default list otherwise, no chrono needed.

Re moving it under SupportsConfigs: makes sense, I'll refactor into the config model as get_timezone_config/set_timezone_config to match the other configs (same shape as the temperature config). I'll push that rework.

For context on the end goal: this is so Home Assistant can keep a miner's timezone correct across the DST changeover automatically — VNish has no DST handling, so by hand it just gets forgotten.

Comment on lines +32 to +55
#[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<Self> {
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(),
})
}
}
}

@b-rowan b-rowan Jun 23, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be able to remove this, automatic with the pydantic macros, reference here:

#[cfg_attr(
feature = "python",
pyclass(name = "Pool", skip_from_py_object, get_all, module = "asic_rs")
)]
#[cfg_attr(
feature = "python",
asic_rs_pydantic::py_pydantic_model(new, name = "Pool")
)]
#[derive(Debug, Clone, Serialize, Deserialize)]
/// A writable mining pool endpoint.
pub struct PoolConfig {
/// Pool URL including scheme, host, port, and optional Stratum V2 pubkey.
pub url: PoolURL,
/// Worker username sent to the pool.
pub username: String,
/// Worker password sent to the pool.
pub password: String,
}


#[cfg_attr(
feature = "python",
pyclass(skip_from_py_object, get_all, module = "asic_rs")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Drop skip_from_py_object

Comment on lines 76 to +674
impl GetConfigsLocations for BraiinsV2604 {
#[allow(unused_variables)]
fn get_configs_locations(&self, data_field: ConfigField) -> Vec<ConfigLocation> {
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![],
}
}
}

impl CollectConfigs for BraiinsV2604 {
fn get_config_collector(&self) -> ConfigCollector<'_> {
ConfigCollector::new(self)
}
}

impl GetDataLocations for BraiinsV2604 {
fn get_locations(&self, data_field: DataField) -> Vec<DataLocation> {
const WEB_NETWORK: MinerCommand = MinerCommand::WebAPI {
command: "network",
parameters: None,
};
const WEB_VERSION: MinerCommand = MinerCommand::WebAPI {
command: "version",
parameters: None,
};
const WEB_MINER_DETAILS: MinerCommand = MinerCommand::WebAPI {
command: "miner/details",
parameters: None,
};
const WEB_LOCATE: MinerCommand = MinerCommand::WebAPI {
command: "actions/locate",
parameters: None,
};
const WEB_MINER_STATS: MinerCommand = MinerCommand::WebAPI {
command: "miner/stats",
parameters: None,
};
const WEB_PERFORMANCE_TUNER_STATE: MinerCommand = MinerCommand::WebAPI {
command: "performance/tuner-state",
parameters: None,
};
const WEB_MINER_CONFIGURATION: MinerCommand = MinerCommand::WebAPI {
command: "configuration/miner",
parameters: None,
};
const WEB_POOLS: MinerCommand = MinerCommand::WebAPI {
command: "pools",
parameters: None,
};
const WEB_COOLING_STATE: MinerCommand = MinerCommand::WebAPI {
command: "cooling/state",
parameters: None,
};
const WEB_HASHBOARDS: MinerCommand = MinerCommand::WebAPI {
command: "miner/hw/hashboards",
parameters: None,
};
const GQL_EVENTS_QUERY: MinerCommand = MinerCommand::GraphQL {
command: r#"{
events {
appeals {
id
kind
message
timestamp
}
}
}"#,
};

match data_field {
DataField::Mac => vec![(
WEB_NETWORK,
DataExtractor {
func: get_by_pointer,
key: Some("/mac_address"),
tag: None,
},
)],
DataField::Hostname => vec![(
WEB_NETWORK,
DataExtractor {
func: get_by_pointer,
key: Some("/hostname"),
tag: None,
},
)],
DataField::ApiVersion => vec![(
WEB_VERSION,
DataExtractor {
func: get_by_pointer,
key: Some(""),
tag: None,
},
)],
DataField::FirmwareVersion => vec![(
WEB_MINER_DETAILS,
DataExtractor {
func: get_by_pointer,
key: Some("/bos_version/current"),
tag: None,
},
)],
DataField::Hashrate => vec![(
WEB_MINER_STATS,
DataExtractor {
func: get_by_pointer,
key: Some("/miner_stats/real_hashrate/last_5s/gigahash_per_second"),
tag: None,
},
)],
DataField::ExpectedHashrate => vec![(
WEB_MINER_DETAILS,
DataExtractor {
func: get_by_pointer,
key: Some("/sticker_hashrate/gigahash_per_second"),
tag: None,
},
)],
DataField::Fans => vec![(
WEB_COOLING_STATE,
DataExtractor {
func: get_by_pointer,
key: Some("/fans"),
tag: None,
},
)],
DataField::Hashboards => vec![(
WEB_HASHBOARDS,
DataExtractor {
func: get_by_pointer,
key: Some("/hashboards"),
tag: None,
},
)],
DataField::LightFlashing => vec![(
WEB_LOCATE,
DataExtractor {
func: get_by_pointer,
key: Some(""),
tag: None,
},
)],
DataField::IsMining => vec![(
WEB_MINER_DETAILS,
DataExtractor {
func: get_by_pointer,
key: Some("/status"),
tag: None,
},
)],
DataField::Uptime => vec![(
WEB_MINER_DETAILS,
DataExtractor {
func: get_by_pointer,
key: Some("/system_uptime_s"),
tag: None,
},
)],
DataField::ControlBoardVersion => vec![(
WEB_MINER_DETAILS,
DataExtractor {
func: get_by_pointer,
key: Some("/control_board_soc_family"),
tag: None,
},
)],
DataField::Pools => vec![(
WEB_POOLS,
DataExtractor {
func: get_by_pointer,
key: Some("/0/pools"), // assuming there is 1 pool group
tag: None,
},
)],
DataField::Wattage => vec![(
WEB_MINER_STATS,
DataExtractor {
func: get_by_pointer,
key: Some("/power_stats/approximated_consumption/watt"),
tag: None,
},
)],
DataField::TuningTarget => vec![
(
WEB_MINER_CONFIGURATION,
DataExtractor {
func: get_by_pointer,
key: Some("/tuner/tuner_mode"),
tag: Some("mode"),
},
),
(
WEB_MINER_CONFIGURATION,
DataExtractor {
func: get_by_pointer,
key: Some("/tuner/power_target/watt"),
tag: Some("configured_power"),
},
),
(
WEB_MINER_CONFIGURATION,
DataExtractor {
func: get_by_pointer,
key: Some("/tuner/hashrate_target/terahash_per_second"),
tag: Some("configured_hashrate"),
},
),
(
WEB_PERFORMANCE_TUNER_STATE,
DataExtractor {
func: get_by_pointer,
key: Some("/mode_state/powertargetmodestate/current_target/watt"),
tag: Some("scaled_power"),
},
),
(
WEB_PERFORMANCE_TUNER_STATE,
DataExtractor {
func: get_by_pointer,
key: Some(
"/mode_state/hashratetargetmodestate/current_target/terahash_per_second",
),
tag: Some("scaled_hashrate"),
},
),
],
DataField::SerialNumber => vec![(
WEB_MINER_DETAILS,
DataExtractor {
func: get_by_pointer,
key: Some("/serial_number"),
tag: None,
},
)],
DataField::Messages => vec![(
GQL_EVENTS_QUERY,
DataExtractor {
func: get_by_pointer,
key: Some("/events/appeals"),
tag: None,
},
)],
_ => vec![],
}
}
}

impl GetIP for BraiinsV2604 {
fn get_ip(&self) -> IpAddr {
self.ip
}
}

impl GetDeviceInfo for BraiinsV2604 {
fn get_device_info(&self) -> DeviceInfo {
self.device_info.clone()
}
}

impl CollectData for BraiinsV2604 {
fn get_collector(&self) -> DataCollector<'_> {
DataCollector::new(self)
}
}

impl GetMAC for BraiinsV2604 {
fn parse_mac(&self, data: &HashMap<DataField, Value>) -> Option<MacAddr> {
data.extract::<String>(DataField::Mac)
.and_then(|s| MacAddr::from_str(&s).ok())
}
}

impl GetHostname for BraiinsV2604 {
fn parse_hostname(&self, data: &HashMap<DataField, Value>) -> Option<String> {
data.extract::<String>(DataField::Hostname)
}
}

impl GetApiVersion for BraiinsV2604 {
fn parse_api_version(&self, data: &HashMap<DataField, Value>) -> Option<String> {
let major = data.extract_nested::<f64>(DataField::ApiVersion, "major");
let minor = data.extract_nested::<f64>(DataField::ApiVersion, "minor");
let patch = data.extract_nested::<f64>(DataField::ApiVersion, "patch");

Some(format!("{}.{}.{}", major?, minor?, patch?))
}
}

impl GetFirmwareVersion for BraiinsV2604 {
fn parse_firmware_version(&self, data: &HashMap<DataField, Value>) -> Option<String> {
data.extract::<String>(DataField::FirmwareVersion)
}
}

impl GetHashboards for BraiinsV2604 {
fn parse_hashboards(&self, data: &HashMap<DataField, Value>) -> Vec<BoardData> {
let mut hashboards: Vec<BoardData> =
(0..self.device_info.hardware.board_count().unwrap_or(0))
.map(|idx| {
BoardData::new(idx, self.device_info.hardware.chips_for_board(idx as usize))
})
.collect();

let Some(chains_array) = data.get(&DataField::Hashboards).and_then(|v| v.as_array()) else {
return hashboards;
};

for board in hashboards.iter_mut() {
let Some(chain) = chains_array.iter().find(|c| {
c.pointer("/id")
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<u8>().ok())
.is_some_and(|id| id == board.position + 1)
}) else {
continue;
};

let chip_temperature = chain
.pointer("/highest_chip_temp/temperature/degree_c")
.and_then(|v| v.as_f64())
.map(Temperature::from_celsius);

board.hashrate = chain
.pointer("/stats/real_hashrate/last_5s/gigahash_per_second")
.and_then(|v| v.as_f64())
.map(|f| {
HashRate {
value: f,
unit: HashRateUnit::GigaHash,
algo: "SHA256".to_string(),
}
.as_unit(HashRateUnit::default())
});
board.expected_hashrate = chain
.pointer("/stats/nominal_hashrate/gigahash_per_second")
.and_then(|v| v.as_f64())
.map(|f| {
HashRate {
value: f,
unit: HashRateUnit::GigaHash,
algo: "SHA256".to_string(),
}
.as_unit(HashRateUnit::default())
});
board.board_temperature = chain
.pointer("/board_temp/degree_c")
.and_then(|v| v.as_f64())
.map(Temperature::from_celsius);
board.inlet_chip_temperature = chip_temperature;
board.outlet_chip_temperature = chip_temperature;
board.working_chips = chain
.pointer("/chips_count")
.and_then(|v| v.as_u64())
.map(|u| u as u16);
board.serial_number = chain
.pointer("/serial_number")
.and_then(|v| v.as_str())
.map(|u| u.to_string());
board.voltage = chain
.pointer("/current_voltage/volt")
.and_then(|v| v.as_f64())
.map(Voltage::from_volts);
board.frequency = chain
.pointer("/current_frequency/hertz")
.and_then(|v| v.as_f64())
.map(Frequency::from_hertz);
board.active = chain.pointer("/enabled").and_then(|v| v.as_bool());
}

hashboards
}
}

impl GetHashrate for BraiinsV2604 {
fn parse_hashrate(&self, data: &HashMap<DataField, Value>) -> Option<HashRate> {
data.extract_map::<f64, _>(DataField::Hashrate, |f| {
HashRate {
value: f,
unit: HashRateUnit::GigaHash,
algo: "SHA256".to_string(),
}
.as_unit(HashRateUnit::default())
})
}
}

impl GetExpectedHashrate for BraiinsV2604 {
fn parse_expected_hashrate(&self, data: &HashMap<DataField, Value>) -> Option<HashRate> {
data.extract_map::<f64, _>(DataField::ExpectedHashrate, |f| {
HashRate {
value: f,
unit: HashRateUnit::GigaHash,
algo: "SHA256".to_string(),
}
.as_unit(HashRateUnit::default())
})
}
}

impl GetFans for BraiinsV2604 {
fn parse_fans(&self, data: &HashMap<DataField, Value>) -> Vec<FanData> {
let mut fans: Vec<FanData> = Vec::new();

if let Some(fans_data) = data.get(&DataField::Fans)
&& let Some(fans_array) = fans_data.as_array()
{
for (idx, fan) in fans_array.iter().enumerate() {
if let Some(rpm) = fan.pointer("/rpm").and_then(|v| v.as_i64()) {
let pos = fan
.pointer("/position")
.and_then(|v| v.as_i64())
.unwrap_or(idx as i64);
fans.push(FanData {
position: pos as i16,
rpm: Some(AngularVelocity::from_rpm(rpm as f64)),
});
}
}
}

fans
}
}

impl GetLightFlashing for BraiinsV2604 {
fn parse_light_flashing(&self, data: &HashMap<DataField, Value>) -> Option<bool> {
data.extract::<bool>(DataField::LightFlashing)
}
}

impl GetUptime for BraiinsV2604 {
fn parse_uptime(&self, data: &HashMap<DataField, Value>) -> Option<Duration> {
data.extract_map::<u64, _>(DataField::Uptime, Duration::from_secs)
}
}

impl GetIsMining for BraiinsV2604 {
fn parse_is_mining(&self, data: &HashMap<DataField, Value>) -> bool {
data.extract::<u64>(DataField::IsMining) == Some(2)
}
}

impl GetPools for BraiinsV2604 {
fn parse_pools(&self, data: &HashMap<DataField, Value>) -> Vec<PoolGroupData> {
let mut pools: Vec<PoolData> = Vec::new();

if let Some(pools_data) = data.get(&DataField::Pools)
&& let Some(pools_array) = pools_data.as_array()
{
for (idx, pool) in pools_array.iter().enumerate() {
let url = pool
.pointer("/url")
.and_then(|v| v.as_str())
.map(String::from)
.map(PoolURL::from);

let user = pool
.pointer("/user")
.and_then(|v| v.as_str())
.map(String::from);

let accepted_shares = pool
.pointer("/stats/accepted_shares")
.and_then(|v| v.as_u64());
let rejected_shares = pool
.pointer("/stats/rejected_shares")
.and_then(|v| v.as_u64());
let active = pool.pointer("/active").and_then(|v| v.as_bool());
let alive = pool.pointer("/alive").and_then(|v| v.as_bool());

pools.push(PoolData {
position: Some(idx as u16),
url,
accepted_shares,
rejected_shares,
active,
alive,
user,
});
}
}

vec![PoolGroupData {
name: String::new(),
quota: 1,
pools,
}]
}
}

impl GetSerialNumber for BraiinsV2604 {
fn parse_serial_number(&self, data: &HashMap<DataField, Value>) -> Option<String> {
data.extract::<String>(DataField::SerialNumber)
}
}

impl GetControlBoardVersion for BraiinsV2604 {
fn parse_control_board_version(
&self,
data: &HashMap<DataField, Value>,
) -> Option<MinerControlBoard> {
let cb_type = data.extract::<u64>(DataField::ControlBoardVersion)?;
match cb_type {
0 => None,
1 => Some(AntMinerControlBoard::CVITek).map(|cb| cb.into()),
2 => Some(AntMinerControlBoard::BeagleBoneBlack).map(|cb| cb.into()),
3 => Some(AntMinerControlBoard::AMLogic).map(|cb| cb.into()),
4 => Some(AntMinerControlBoard::Xilinx).map(|cb| cb.into()),
5 => Some(BraiinsControlBoard::BraiinsCB).map(|cb| cb.into()),
_ => None,
}
}
}

impl GetWattage for BraiinsV2604 {
fn parse_wattage(&self, data: &HashMap<DataField, Value>) -> Option<Power> {
data.extract_map::<i64, _>(DataField::Wattage, |w| Power::from_watts(w as f64))
}
}

impl GetTuningTarget for BraiinsV2604 {
fn parse_tuning_target(&self, data: &HashMap<DataField, Value>) -> Option<TuningTarget> {
data.get(&DataField::TuningTarget)
.and_then(parse_configured_tuning_target)
}
}

impl GetScaledTuningTarget for BraiinsV2604 {
fn parse_scaled_tuning_target(&self, data: &HashMap<DataField, Value>) -> Option<TuningTarget> {
data.get(&DataField::TuningTarget)
.and_then(parse_scaled_tuning_target)
}
}

impl GetFluidTemperature for BraiinsV2604 {}
#[async_trait]
impl SupportsTimezoneConfig for BraiinsV2604 {
fn supports_timezone_config(&self) -> bool {
true
}

fn parse_timezone_config(
&self,
data: &HashMap<ConfigField, Value>,
) -> anyhow::Result<TimezoneConfig> {
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<bool> {
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"))
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy paste this impl for every braiins version , for some reason this isn't in the REST api.

tag: None,
},
)],
ConfigField::Timezone => vec![],

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't need to exist? Rather a _ => vec![],

Suggested change
ConfigField::Timezone => vec![],
_ => vec![],

Comment on lines +581 to +585
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",
];

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we pull in any "real" timezone objects in any the deps? Would be nice to be able to go to/from a properly structure timezone object for this, get more type hinting rather than just strings.

pos-ei-don added a commit to pos-ei-don/asic-rs that referenced this pull request Jun 29, 2026
…undation#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.
@pos-ei-don
pos-ei-don force-pushed the feat-set-timezone-upstream branch from c7d0f1e to 4a41821 Compare June 29, 2026 21:07
…undation#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.
@pos-ei-don
pos-ei-don force-pushed the feat-set-timezone-upstream branch from 4a41821 to 2742c43 Compare June 29, 2026 21:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants