From 027616330180992afc17baaf426e41a76e516503 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Tue, 6 Oct 2026 19:24:38 +0000 Subject: [PATCH] Ember for Miner UI 4 (0.3.16): state.mining.watts_total and pounds_per_day (the fleet's fresh draw, the price from settings), state.address.balance_wei with balance_age_s and balance_note (eth_getBalance through the node's RPC every 30 s), state.address.price_gbp_per_ign null with its one documented source (a signed manifest field); GET /api/live from a local source (src/live.rs: igneum_getRecentBlocks when the node carries it, else the public site's reply cached 60 s, source and age_s on every reply); tests Co-Authored-By: Claude Fable 5.1 --- app/igneum-app/src/ember.rs | 29 +++++ app/igneum-app/src/engine.rs | 63 ++++++++++ app/igneum-app/src/live.rs | 221 +++++++++++++++++++++++++++++++++++ app/igneum-app/src/main.rs | 1 + app/igneum-app/src/ota.rs | 2 +- app/igneum-app/src/prover.rs | 2 +- app/igneum-app/src/server.rs | 6 + app/igneum-app/src/state.rs | 13 +++ docs/plans/ember-tune.md | 10 ++ 9 files changed, 345 insertions(+), 2 deletions(-) create mode 100644 app/igneum-app/src/live.rs diff --git a/app/igneum-app/src/ember.rs b/app/igneum-app/src/ember.rs index 113751eff..bca7fe6e3 100644 --- a/app/igneum-app/src/ember.rs +++ b/app/igneum-app/src/ember.rs @@ -906,6 +906,21 @@ pub fn tuned_line(mhs: f64, watts: f64, eff: f64) -> String { format!("Tuned: {mhs:.1} MH/s at {watts:.0} W ({eff:.3} MH/W)") } +/// Miner UI 4 (6 October 2026): the fleet's draw now: the sum over cards that mine and have a draw reading under +/// `max_age_s` old (a stale reading is not a draw). +pub fn fleet_watts<'a>(cards: impl Iterator, now: f64, max_age_s: f64) -> f64 { + cards.filter(|(state, w, at)| *state == "mining" && *w > 0.0 && now - *at < max_age_s).map(|(_, w, _)| w).sum() +} + +/// A hex quantity (0x-prefixed, as eth_getBalance answers) as a decimal string; None when it is not one. +pub fn wei_from_hex(hex: &str) -> Option { + let h = hex.trim().strip_prefix("0x").unwrap_or(hex.trim()); + if h.is_empty() { + return Some("0".into()); + } + u128::from_str_radix(h, 16).ok().map(|v| v.to_string()) +} + /// Miner UI 4 (6 October 2026): a card's own goal when set, else the global one. pub fn goal_for(card_goal: &str, global: &str) -> Goal { Goal::parse(if card_goal.trim().is_empty() { global } else { card_goal }) @@ -1034,6 +1049,20 @@ mod tests { assert!(Run::applied(&step, Readback { limit_w: 90.0, acked: false }, 100.0)); } + #[test] + fn the_fleet_draw_sums_mining_cards_with_a_fresh_reading_and_the_balance_reads_in_wei() { + let now = 1_791_300_000.0; + let cards = vec![("mining", 226.8, now - 5.0), ("mining", 75.6, now - 10.0), ("mining", 201.0, now - 120.0), ("off", 30.0, now - 1.0), ("mining", 0.0, now)]; + let w = fleet_watts(cards.iter().map(|(s, w, at)| (*s, *w, *at)), now, 60.0); + assert!((w - 302.4).abs() < 1e-9, "the stale 9070 XT reading and the card that is off do not count: {w}"); + assert!((pounds_per_day(302.4, 28.5) - 2.068416).abs() < 1e-6); + assert_eq!(wei_from_hex("0x1a"), Some("26".into())); + assert_eq!(wei_from_hex("0x0"), Some("0".into())); + assert_eq!(wei_from_hex("0x"), Some("0".into())); + assert_eq!(wei_from_hex("0xde0b6b3a7640000"), Some("1000000000000000000".into()), "one IGN"); + assert_eq!(wei_from_hex("soon"), None); + } + #[test] fn a_card_goal_overrides_the_global_goal_and_empty_follows_it() { assert_eq!(goal_for("", "balanced"), Goal::Balanced); diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs index 7a69bb149..130399744 100644 --- a/app/igneum-app/src/engine.rs +++ b/app/igneum-app/src/engine.rs @@ -28,6 +28,8 @@ pub struct CardChoice { pub enum Cmd { Detect, + /// the payout address's balance (eth_getBalance, hex) or why it could not be read + BalanceRead(Result), ApplyCards(Vec), /// one enumeration of the cards finished (the first, or a re-detection: src/hotplug.rs) Detected(crate::detect::Detection), @@ -404,6 +406,10 @@ fn address_state(s: &Settings, wallet_path: &std::path::Path) -> crate::state::A source: s.address_source.clone(), key_saved: s.key_saved, wallet_file: if s.address_source == "generated" { wallet_path.display().to_string() } else { String::new() }, + balance_wei: None, + balance_age_s: -1.0, + balance_note: String::new(), + price_gbp_per_ign: None, } } @@ -610,6 +616,10 @@ pub struct Engine { tune_acked: Option, /// cards whose confirm check found a better neighbour: the full plan runs next tune_full_due: std::collections::HashSet, + /// Miner UI 4: the balance read (every 30 s while the node runs; 60 s after a failure) + balance_next: Option, + balance_busy: bool, + balance_at: f64, /// a sweep waiting for the cap-mode probe: (card index, forced) sweep_pending: Option<(usize, bool)>, /// how caps are set: Some(true) directly, Some(false) through the elevated helper, None = not probed yet @@ -714,6 +724,9 @@ impl Engine { sweep_helper_is_task: false, tune_acked: None, tune_full_due: std::collections::HashSet::new(), + balance_next: None, + balance_busy: false, + balance_at: 0.0, sweep_pending: None, sweep_direct: None, sweep_helper: false, @@ -1149,6 +1162,29 @@ impl Engine { } } Cmd::TuneProgress(v) => self.tune_progress(&v), + Cmd::BalanceRead(r) => { + self.balance_busy = false; + match r { + Ok(hex) => match crate::ember::wei_from_hex(&hex) { + Some(wei) => { + let mut st = self.st(); + st.address.balance_wei = Some(wei); + st.address.balance_note.clear(); + drop(st); + self.balance_at = crate::platform::unix_now_f(); + self.balance_next = Some(Instant::now() + Duration::from_secs(30)); + } + None => { + self.st().address.balance_note = format!("the node answered {hex} for the balance, not a quantity"); + self.balance_next = Some(Instant::now() + Duration::from_secs(60)); + } + }, + Err(e) => { + self.st().address.balance_note = e; + self.balance_next = Some(Instant::now() + Duration::from_secs(60)); + } + } + } Cmd::TuneCardGoal(key, goal) => { { let mut s = self.shared.settings.lock().unwrap(); @@ -3008,6 +3044,7 @@ impl Engine { self.tick_sweep(now); } self.derive(now); + self.tick_balance(now); if now.duration_since(self.last_status) >= Duration::from_secs(self.shared.runtime.status_secs as u64) { self.last_status = now; if self.running { @@ -3523,6 +3560,28 @@ impl Engine { } } + /// Miner UI 4: the payout address's balance through the node's own RPC, every 30 s while the node answers. + fn tick_balance(&mut self, now: Instant) { + if self.balance_busy || self.balance_next.map(|t| now < t).unwrap_or(false) { + return; + } + let (addr, node_up) = { + let st = self.st(); + (st.address.value.clone(), matches!(st.node.state.as_str(), "syncing" | "synced")) + }; + if addr.len() != 42 || !node_up { + self.balance_next = Some(now + Duration::from_secs(10)); + return; + } + self.balance_busy = true; + self.balance_next = Some(now + Duration::from_secs(30)); + let shared = self.shared.clone(); + std::thread::spawn(move || { + let r = crate::prover::evm_rpc(&shared, "eth_getBalance", serde_json::json!([addr, "latest"]), Duration::from_secs(8)).and_then(|v| v.as_str().map(|s| s.to_string()).ok_or_else(|| "eth_getBalance: not a string".to_string())); + shared.send(Cmd::BalanceRead(r)); + }); + } + /// Derived fields: ages, the hash total, the program countdown, the finality age, the mining state word. fn derive(&mut self, now: Instant) { let unix = crate::platform::unix_now_f(); @@ -3537,6 +3596,10 @@ impl Engine { c.eff_mhw = if c.state == "mining" && c.power_w > 1.0 && c.hash_now > 0.0 && unix - c.telemetry_at < 60.0 { c.hash_now / c.power_w } else { 0.0 }; } st.mining.hash_total = total; + // Miner UI 4: the fleet's draw and its £ a day (settings.power_price_pence; 0 when no price is set) + st.mining.watts_total = crate::ember::fleet_watts(st.mining.cards.iter().map(|c| (c.state.as_str(), c.power_w, c.telemetry_at)), unix, 60.0); + st.mining.pounds_per_day = if st.settings.power_price_pence > 0.0 { crate::ember::pounds_per_day(st.mining.watts_total, st.settings.power_price_pence) } else { 0.0 }; + st.address.balance_age_s = if self.balance_at > 0.0 { unix - self.balance_at } else { -1.0 }; crate::hotplug::age(&mut st.mining.cards, unix); let cut = unix - 3600.0; st.mining.found.retain(|t| *t > cut); diff --git a/app/igneum-app/src/live.rs b/app/igneum-app/src/live.rs new file mode 100644 index 000000000..aaaf69fb3 --- /dev/null +++ b/app/igneum-app/src/live.rs @@ -0,0 +1,221 @@ +//! GET /api/live on the engine (Miner UI 4, 6 October 2026): the observer's reply shape from a local source. +//! +//! Source "node": igneumd's `igneum_getRecentBlocks(seconds)` (0.3.16, the node lane) gives the last 600 s of +//! blocks with vote key hashes, blue scores and colours; the state fields are computed here (`shape_from_blocks`) +//! and the DAG numbers come from the node state the engine already holds. Source "site": until the node carries +//! the method (or when it fails), the public site's /api/live reply, fetched by curl and cached 60 s, with +//! `age_s`. Either way the reply carries `source`, and a reply that is not ready yet says `pending: true`. +use crate::engine::Shared; +use serde_json::{json, Value}; +use std::sync::{Mutex, OnceLock}; +use std::time::{Duration, Instant}; + +struct Cache { + body: Option, + at: Option, + source: &'static str, + busy: bool, + /// the node said "method not found": do not ask again before this + node_retry: Option, +} + +fn cache() -> &'static Mutex { + static C: OnceLock> = OnceLock::new(); + C.get_or_init(|| Mutex::new(Cache { body: None, at: None, source: "site", busy: false, node_retry: None })) +} + +pub const FRESH_S: u64 = 60; + +/// One block from `igneum_getRecentBlocks`. +#[derive(Clone, Debug)] +pub struct RecentBlock { + pub hash: String, + pub blue_score: u64, + pub daa_score: u64, + pub timestamp_ms: u64, + pub vote_key_hash: String, + pub is_chain_block: bool, + pub color: String, +} + +pub fn parse_recent(v: &Value) -> Vec { + let s = |b: &Value, k: &str| b.get(k).and_then(|x| x.as_str()).unwrap_or("").to_string(); + let n = |b: &Value, k: &str| b.get(k).and_then(|x| x.as_u64()).unwrap_or(0); + v.as_array() + .map(|a| { + a.iter() + .map(|b| RecentBlock { hash: s(b, "hash"), blue_score: n(b, "blue_score"), daa_score: n(b, "daa_score"), timestamp_ms: n(b, "timestamp_ms"), vote_key_hash: s(b, "vote_key_hash"), is_chain_block: b.get("is_chain_block").and_then(|x| x.as_bool()).unwrap_or(false), color: s(b, "color") }) + .collect() + }) + .unwrap_or_default() +} + +/// The observer's `state` fields that come from the blocks: distinct vote keys in 10 minutes, blocks in 10 +/// minutes, blocks per minute over the last 10 minutes (oldest first), and the `blocks` (last `window_s`) and +/// `miners` arrays. `now_ms` is the reference time. +pub fn shape_from_blocks(blocks: &[RecentBlock], now_ms: u64, window_s: u64) -> Value { + let ten = now_ms.saturating_sub(600_000); + let recent: Vec<&RecentBlock> = blocks.iter().filter(|b| b.timestamp_ms >= ten).collect(); + let mut miners: std::collections::BTreeMap<&str, u64> = Default::default(); + for b in &recent { + if !b.vote_key_hash.is_empty() { + *miners.entry(b.vote_key_hash.as_str()).or_insert(0) += 1; + } + } + let mut per_min = vec![0u64; 10]; + for b in &recent { + let idx = ((b.timestamp_ms - ten) / 60_000).min(9) as usize; + per_min[idx] += 1; + } + let win = now_ms.saturating_sub(window_s * 1000); + let rows: Vec = blocks + .iter() + .filter(|b| b.timestamp_ms >= win) + .map(|b| json!({ "hash": short(&b.hash), "blue_score": b.blue_score, "daa": b.daa_score, "timestamp_ms": b.timestamp_ms, "is_chain_block": b.is_chain_block, "color": if b.color.is_empty() { "pending" } else { b.color.as_str() }, "vote_key_hash": short(&b.vote_key_hash) })) + .collect(); + json!({ + "miners_10m": miners.len(), + "blocks_10m": recent.len(), + "blocks_per_minute": per_min, + "blocks": rows, + "miners": miners.iter().map(|(k, n)| json!({ "vote_key_hash": short(k), "blocks_10m": n })).collect::>(), + }) +} + +fn short(h: &str) -> String { + let h = h.trim_start_matches("0x"); + if h.len() > 12 { + format!("{}..{}", &h[..6], &h[h.len() - 6..]) + } else { + h.to_string() + } +} + +/// The reply for GET /api/live: cached when fresh, else a refresh is started and the last body (or `pending`) +/// comes back with its age. +pub fn reply(shared: &std::sync::Arc) -> Value { + let now = Instant::now(); + let (body, at, source, kick) = { + let mut c = cache().lock().unwrap(); + let fresh = c.at.map(|t| now.duration_since(t) < Duration::from_secs(FRESH_S)).unwrap_or(false); + let kick = !fresh && !c.busy; + if kick { + c.busy = true; + } + (c.body.clone(), c.at, c.source, kick) + }; + if kick { + let shared = shared.clone(); + std::thread::spawn(move || refresh(&shared)); + } + let age = at.map(|t| now.duration_since(t).as_secs_f64()).unwrap_or(-1.0); + match body { + Some(mut v) => { + if let Some(o) = v.as_object_mut() { + o.insert("source".into(), json!(source)); + o.insert("age_s".into(), json!((age * 10.0).round() / 10.0)); + o.insert("pending".into(), json!(false)); + } + v + } + None => json!({ "ok": false, "pending": true, "source": source, "age_s": -1 }), + } +} + +fn refresh(shared: &std::sync::Arc) { + let got = from_node(shared).map(|v| (v, "node")).or_else(|| from_site(shared).map(|v| (v, "site"))); + let mut c = cache().lock().unwrap(); + c.busy = false; + if let Some((v, src)) = got { + c.body = Some(v); + c.at = Some(Instant::now()); + c.source = src; + } +} + +/// The node's own blocks, when it carries `igneum_getRecentBlocks`; None (and a 10-minute pause) when it does not. +fn from_node(shared: &std::sync::Arc) -> Option { + { + let c = cache().lock().unwrap(); + if c.node_retry.map(|t| Instant::now() < t).unwrap_or(false) { + return None; + } + } + let node_up = matches!(shared.state.lock().unwrap().node.state.as_str(), "syncing" | "synced"); + if !node_up { + return None; + } + match crate::prover::evm_rpc(shared, "igneum_getRecentBlocks", json!([600]), Duration::from_secs(8)) { + Ok(v) if v.is_array() => { + let blocks = parse_recent(&v); + let now_ms = (crate::platform::unix_now_f() * 1000.0) as u64; + let mut shape = shape_from_blocks(&blocks, now_ms, 90); + let st = shared.state.lock().unwrap(); + let state = json!({ + "block_count": st.node.blocks, "header_count": st.node.headers, "daa": st.node.daa, "blue_score": st.node.blue, + "difficulty": st.node.difficulty, "peers": st.node.peers, "synced": st.node.synced, + "miners_10m": shape["miners_10m"], "blocks_10m": shape["blocks_10m"], "blocks_per_minute": shape["blocks_per_minute"], + "hashes_per_second_estimate": Value::Null, + }); + let finality = json!({ "last_lock": st.finality.last_lock, "last_lock_at": st.finality.last_lock_at, "age_s": st.finality.age_s, "votes": st.finality.votes }); + let o = shape.as_object_mut().unwrap(); + let blocks_v = o.remove("blocks").unwrap_or(Value::Null); + let miners_v = o.remove("miners").unwrap_or(Value::Null); + Some(json!({ "ok": true, "now": crate::platform::unix_now_f(), "state": state, "blocks": blocks_v, "miners": miners_v, "events": [], "finality": finality })) + } + Ok(_) => None, + Err(e) => { + if e.contains("not found") || e.contains("-32601") || e.contains("Method") { + cache().lock().unwrap().node_retry = Some(Instant::now() + Duration::from_secs(600)); + } + None + } + } +} + +/// The public site's reply (the same fetch the OTA network guard makes). +fn from_site(shared: &std::sync::Arc) -> Option { + let url = std::env::var("IGNEUM_APP_LIVE_API").unwrap_or_else(|_| crate::ota::live_api_from(&shared.packaged.live_page)); + if url.is_empty() { + return None; + } + let out = crate::detect::run_timeout(std::process::Command::new(crate::platform::tool("curl")).args(["-fsS", "--max-time", "10", &url]), None, Duration::from_secs(12))?; + serde_json::from_str::(out.trim()).ok().filter(|v| v.is_object()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn b(ts: u64, key: &str, chain: bool) -> RecentBlock { + RecentBlock { hash: format!("0x{:064x}", ts), blue_score: ts / 1000, daa_score: ts / 1000, timestamp_ms: ts, vote_key_hash: key.into(), is_chain_block: chain, color: if chain { "blue".into() } else { String::new() } } + } + + #[test] + fn the_state_fields_come_from_the_blocks_of_the_last_ten_minutes() { + let now = 1_791_300_000_000u64; + let blocks = vec![b(now - 5_000, "aa", true), b(now - 30_000, "bb", true), b(now - 95_000, "aa", false), b(now - 500_000, "cc", true), b(now - 700_000, "dd", true)]; + let v = shape_from_blocks(&blocks, now, 120); + assert_eq!(v["miners_10m"], 3, "dd is older than 10 minutes"); + assert_eq!(v["blocks_10m"], 4); + let pm = v["blocks_per_minute"].as_array().unwrap(); + assert_eq!(pm.len(), 10); + assert_eq!(pm[9], 2, "the newest minute holds the 5 s and 30 s blocks"); + assert_eq!(pm[8], 1, "the 95 s block"); + assert_eq!(pm[1], 1, "the 500 s block"); + assert_eq!(v["blocks"].as_array().unwrap().len(), 3, "the 120 s window"); + assert_eq!(v["blocks"][2]["color"], "pending", "an unmerged block without a colour"); + assert_eq!(v["blocks"][0]["color"], "blue"); + assert_eq!(v["miners"].as_array().unwrap().len(), 3); + } + + #[test] + fn recent_blocks_parse_from_the_node_reply_and_an_empty_reply_is_empty() { + let v: Value = serde_json::from_str(r#"[{"hash":"0xab","blue_score":5,"daa_score":6,"timestamp_ms":7,"vote_key_hash":"cd","is_chain_block":true,"color":"blue"}]"#).unwrap(); + let p = parse_recent(&v); + assert_eq!(p.len(), 1); + assert_eq!((p[0].blue_score, p[0].is_chain_block, p[0].color.as_str()), (5, true, "blue")); + assert!(parse_recent(&json!(null)).is_empty()); + assert_eq!(short("0x1234567890abcdef"), "123456..abcdef"); + } +} diff --git a/app/igneum-app/src/main.rs b/app/igneum-app/src/main.rs index 670837ce0..06bfa7635 100644 --- a/app/igneum-app/src/main.rs +++ b/app/igneum-app/src/main.rs @@ -35,6 +35,7 @@ mod verifier; mod wslhost; mod sweep; mod ember; +mod live; mod powertask; mod watchdog; diff --git a/app/igneum-app/src/ota.rs b/app/igneum-app/src/ota.rs index f10563d27..cfd00841f 100644 --- a/app/igneum-app/src/ota.rs +++ b/app/igneum-app/src/ota.rs @@ -954,7 +954,7 @@ impl Updater { // ---- the threads --------------------------------------------------------------------------------------------------- /// https://igneum.network/live -> https://igneum.network/api/live; "" when the build carries no live page. -fn live_api_from(live_page: &str) -> String { +pub(crate) fn live_api_from(live_page: &str) -> String { let Some(rest) = live_page.strip_prefix("https://") else { return String::new() }; let host = rest.split('/').next().unwrap_or(""); if host.is_empty() { String::new() } else { format!("https://{host}/api/live") } diff --git a/app/igneum-app/src/prover.rs b/app/igneum-app/src/prover.rs index c1053eee2..5215a91a9 100644 --- a/app/igneum-app/src/prover.rs +++ b/app/igneum-app/src/prover.rs @@ -132,7 +132,7 @@ struct Tools { cuda: bool, } -fn evm_rpc(shared: &Shared, method: &str, params: Value, timeout: Duration) -> Result { +pub(crate) fn evm_rpc(shared: &Shared, method: &str, params: Value, timeout: Duration) -> Result { let body = json!({ "jsonrpc": "2.0", "id": 1, "method": method, "params": params }).to_string(); let tmp = std::env::temp_dir().join(format!("igneum-prover-{}-{}.json", std::process::id(), method)); std::fs::write(&tmp, body).map_err(|e| e.to_string())?; diff --git a/app/igneum-app/src/server.rs b/app/igneum-app/src/server.rs index 99055a326..0cdd61c11 100644 --- a/app/igneum-app/src/server.rs +++ b/app/igneum-app/src/server.rs @@ -178,6 +178,12 @@ fn handle(mut stream: TcpStream, shared: Arc) { let v = shared.state_json(); json_resp(&mut stream, 200, v); } + ("GET", "/api/live") => { + // Miner UI 4: the observer's reply shape from the local node (igneum_getRecentBlocks) or the public + // site, with "source" and "age_s" (src/live.rs) + let v = crate::live::reply(&shared); + json_resp(&mut stream, 200, v); + } ("GET", "/api/log") => { let after = query_param(&req.query, "after").and_then(|s| s.parse().ok()).unwrap_or(0u64); let limit = query_param(&req.query, "limit").and_then(|s| s.parse().ok()).unwrap_or(400usize).min(2000); diff --git a/app/igneum-app/src/state.rs b/app/igneum-app/src/state.rs index ee090726f..bc5ed11e1 100644 --- a/app/igneum-app/src/state.rs +++ b/app/igneum-app/src/state.rs @@ -167,6 +167,10 @@ pub struct MiningState { /// dev-fee blocks (the miner's `dev-fee block` lines): this run, and lifetime pub fee_session: u64, pub fee_total: u64, + /// Miner UI 4 (6 October 2026): the sum of the mining cards' draw (a reading under 60 s old), and that draw as + /// £ a day at settings.power_price_pence (0 when no price is set) + pub watts_total: f64, + pub pounds_per_day: f64, } /// The miner software's dev fee as the miner reports it at start (`dev fee 1% (1 block in 100) to 0x...`). @@ -275,6 +279,15 @@ pub struct AddressState { pub source: String, pub key_saved: bool, pub wallet_file: String, + /// Miner UI 4: the payout address's balance in wei as a decimal string (eth_getBalance through the node's own + /// RPC, read every 30 s while the node runs); null until the first read; balance_age_s = -1 until then + pub balance_wei: Option, + pub balance_age_s: f64, + pub balance_note: String, // the last read's error, in words; "" when the last read was good + /// £ per IGN. null until a market exists. Its one source will be a SIGNED field of the OTA manifest (`price`: + /// gbp_per_ign, as_of, source), checked like the manifest's tuning object; the app never computes or fetches a + /// price on its own + pub price_gbp_per_ign: Option, } #[derive(Clone, Serialize, Default)] diff --git a/docs/plans/ember-tune.md b/docs/plans/ember-tune.md index 83737346b..0650c1cf5 100644 --- a/docs/plans/ember-tune.md +++ b/docs/plans/ember-tune.md @@ -318,6 +318,16 @@ Correction (6 October 2026, 19:0xZ): release-0.3.15 took ember-tune at 5429e82 ( Cards tab ships in 0.3.15 and reads the per-card goal and the before fields; so the row below is IN 0.3.15 except `card.tune_floor` (995530b), which rides the next cut. +For 0.3.16 (main, 6 October 2026 evening), the engine fields Miner UI 4 reads, on ember-tune past 5429e82: + +| Field | What | Where | +|---|---|---| +| `state.mining.watts_total` | the sum of the mining cards' draw (a reading under 60 s old) | `ember::fleet_watts`, engine `derive` + test | +| `state.mining.pounds_per_day` | that draw as £ a day at `settings.power_price_pence` (0 when no price) | `ember::pounds_per_day` | +| `state.address.balance_wei` | the payout address's balance in wei as a decimal string, `eth_getBalance` through the node's own RPC every 30 s while the node runs (60 s after a failure); null until read; `balance_age_s` (-1 until then), `balance_note` (the last error in words) | engine `tick_balance`, `Cmd::BalanceRead`, `ember::wei_from_hex` + test | +| `state.address.price_gbp_per_ign` | null. Its one source will be a SIGNED field of the OTA manifest (`price`: gbp_per_ign, as_of, source), checked like the tuning object; the app never computes or fetches a price itself | state.rs (documented), no code until a market exists | +| `GET /api/live` | the observer's reply shape from a local source: `"source": "node"` when igneumd carries `igneum_getRecentBlocks(seconds)` (the node lane, a283f5f0d364ceef0; the engine computes miners_10m, blocks_10m, blocks_per_minute, the 90 s blocks and the miners list from it and takes the DAG numbers from its node state), else `"source": "site"` (the public reply, fetched by curl, cached 60 s) with `age_s`; `pending: true` before the first fetch | src/live.rs (`shape_from_blocks`, `parse_recent` + tests), server.rs | + For Miner UI 4's Cards tab (Ember as its second layer), on ember-tune: | Commit | What | Where |